Sprint 11 — каркас для интеграции с операторами фискальных данных РК.
Реальные ApiKey'и появятся у user'а позже; задача — построить такой
фрейм, чтобы подключение оператора сводилось к вписыванию кредов в UI
без правок кода/деплоя.
Что сделано:
- IFiscalProvider (Application/Common/Fiscal) + FiscalResult,
FiscalProviderKind (None/Mock/Webkassa/Kassa24/OfdSolo),
IFiscalProviderFactory, FiscalNotConfiguredException.
- 4 реализации в Infrastructure/Fiscal:
• MockFiscalProvider — фейк MOCK-<8hex> через 300мс, идемпотентный
по Sale.Id (используется dev/stage и интеграционными тестами);
• WebkassaProvider — полный HTTP-pipeline Authorize→Check, парсинг
JSON-ответа, NDS-в-ставке, retry-safe через ExternalCheckNumber;
• Kassa24Provider / OfdSoloProvider — скелет с тем же контрактом,
RegisterAsync бросает FiscalNotConfiguredException (нужны
спецификации API от user'а, NDA-only).
- Миграция Phase11a: 5 колонок в retail_sales (FiscalNumber, QrCode,
Url, ProviderTxId, ProviderKind) + 5 в organizations (FiscalProvider
NOT NULL default 0, ApiKey/Secret encrypted, CashboxUniqueNumber,
ApiBaseUrl). Default 0 = обратная совместимость, существующие чеки
и продажи без фискализации работают как раньше.
- RetailSalesController.Post — TryFiscalizeAsync после commit'а
stock-транзакции. Best-effort: сетевые/HTTP-ошибки логируются, чек
остаётся проведённым. Идемпотентность по IsNullOrEmpty(FiscalNumber).
- OrgFiscalSettingsController: GET/PUT настройки + GET /providers
(опции для select'а) + POST /test-send (фейк-чек к выбранному
провайдеру, не сохраняет в БД).
- UI: FiscalSection в OrganizationSettingsPage с password-input'ами
для ApiKey/Secret (шифруются DataProtection.purpose=foodmarket.fiscal,
в GET — только has-* флаги), спец-значение "__clear__" для снятия,
кнопка «Тестовая отправка».
- Тесты: 11 unit (Mock 5 + Webkassa payload 6) + 3 integration
(Mock сохраняет FiscalNumber, test-send даёт MOCK-номер, None
не фискализует).
- docs/ofd-integration.md — гид с архитектурой, шагами подключения
Webkassa (полный pap), TODO для Касса24/ОФД-Соло, безопасностью
кредов, retry-сценариями.
Все 68 unit + 8 integration в Fiscal/Loyalty/RetailOversell — зелёные.
Web vite build — зелёный.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
185 lines
10 KiB
Markdown
185 lines
10 KiB
Markdown
# Интеграция с ОФД-операторами Казахстана
|
||
|
||
Sprint 11 — scaffolding, реальные провайдеры подключаются по мере
|
||
получения ApiKey от пользователя. Mock работает «из коробки».
|
||
|
||
## Архитектура
|
||
|
||
```
|
||
┌────────────────────────────────────────────────────────────────┐
|
||
│ RetailSalesController.Post │
|
||
│ → списать остатки (Serializable tx) │
|
||
│ → SaveChanges + COMMIT │
|
||
│ → IFiscalProviderFactory.ResolveAsync() │
|
||
│ → читает Organization.FiscalProvider │
|
||
│ → возвращает реализацию или null (None) │
|
||
│ → provider.RegisterAsync(sale) ← HTTP к оператору │
|
||
│ → сохранить FiscalNumber/FiscalQrCode на чек │
|
||
└────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
Ключевые файлы:
|
||
|
||
- `src/food-market.application/Common/Fiscal/IFiscalProvider.cs` —
|
||
контракт + enum'ы + исключения.
|
||
- `src/food-market.infrastructure/Fiscal/` — реализации (Mock + 3 оператора)
|
||
и фабрика.
|
||
- `src/food-market.api/Controllers/Organizations/OrgFiscalSettingsController.cs` —
|
||
GET/PUT настройки + POST `/test-send`.
|
||
- `src/food-market.api/Controllers/Sales/RetailSalesController.cs#TryFiscalizeAsync` —
|
||
точка вызова после commit'а stock-транзакции.
|
||
|
||
## Поведение по умолчанию
|
||
|
||
`Organization.FiscalProvider = 0` (None) — фискализация выключена,
|
||
чеки проводятся как и раньше, `RetailSale.FiscalNumber = null`.
|
||
**Существующие данные не меняются.**
|
||
|
||
Чтобы включить:
|
||
|
||
1. Войти в «Настройки организации → ОФД».
|
||
2. Выбрать провайдера в селекте, заполнить ApiKey/ApiSecret/CashboxUniqueNumber.
|
||
3. Нажать «Тестовая отправка» — провайдер дёрнет себя на фейк-чеке,
|
||
покажет либо `FiscalNumber=…` (успех), либо текст ошибки (нет кредов /
|
||
оператор недоступен / провайдер ещё не реализован).
|
||
4. Сохранить.
|
||
5. Следующий проведённый чек получит `FiscalNumber` от оператора.
|
||
|
||
## Mock-провайдер (dev / тесты)
|
||
|
||
`FiscalProvider = 1`. Возвращает детерминированный фейк через ~300мс:
|
||
|
||
```
|
||
FiscalNumber: MOCK-AB12CD34 ← первые 8 hex от Sale.Id
|
||
FiscalQrCode: https://mock.ofd.local/check/<id>?n=<FiscalNumber>
|
||
FiscalUrl: https://mock.ofd.local/check/<id>
|
||
ProviderTxId: mock-tx-AB12CD34EF56
|
||
```
|
||
|
||
Идемпотентен по `Sale.Id` — повторный вызов даёт тот же FiscalNumber
|
||
(integration-тест `FiscalMockFlowTests` это проверяет).
|
||
|
||
В тестах активируется через глобальный override:
|
||
|
||
```csharp
|
||
cfg.AddInMemoryCollection(new Dictionary<string, string?>
|
||
{
|
||
["Fiscal:Provider"] = "Mock",
|
||
});
|
||
```
|
||
|
||
Этот override **перебивает БД-настройку** для всех организаций сразу —
|
||
удобно для интеграционных тестов, где не хочется править Organization
|
||
в каждом сценарии.
|
||
|
||
## Webkassa (https://webkassa.kz)
|
||
|
||
**Самый распространённый ОФД РК.** Реализация — полный HTTP-flow с
|
||
парсингом JSON, готов к работе. Тесты — `WebkassaProviderTests` (10
|
||
сценариев на payload-маппинг через `BuildCheckPayload`).
|
||
|
||
### Что нужно от user'а
|
||
|
||
1. Зарегистрироваться в кабинете Webkassa, подписать договор.
|
||
2. Получить в кабинете:
|
||
- **Логин/пароль** API-пользователя (заводится в разделе
|
||
«Настройки → Пользователи»). НЕ персональный логин администратора —
|
||
отдельный API-юзер с правом «Создание чеков».
|
||
- **CashboxUniqueNumber** — уникальный номер вашей кассы в разделе
|
||
«Настройки → Кассы → Уникальный номер».
|
||
|
||
### Что вписать в настройках food-market
|
||
|
||
| Поле UI | Значение |
|
||
|--------------------------|-------------------------------------------------------|
|
||
| Провайдер | Webkassa |
|
||
| ApiKey / Логин | логин API-пользователя из кабинета Webkassa |
|
||
| ApiSecret / Пароль | его пароль |
|
||
| CashboxUniqueNumber | уникальный номер кассы (SWK… или цифровой) |
|
||
| Альтернативный URL | пусто (для теста — `https://devkkm.webkassa.kz/`) |
|
||
|
||
### Поток вызовов
|
||
|
||
```
|
||
POST /api/Authorize { Login, Password } → { Data.Token }
|
||
POST /api/Check { Token, CashboxUniqueNumber, OperationType,
|
||
ExternalCheckNumber, Positions[], Payments[] }
|
||
→ { Data.CheckNumber, QrCode, TicketUrl,
|
||
UniqueNumber }
|
||
```
|
||
|
||
- **OperationType** = 1 (продажа) или 2 (возврат). Мы выбираем по
|
||
`RetailSale.IsReturn`.
|
||
- **ExternalCheckNumber** — наш номер чека (например, `ПР-Y1-00019`).
|
||
Webkassa дедупит по этому полю → повторный POST с тем же номером
|
||
возвращает оригинальный чек, не создаёт дубль. Это обеспечивает
|
||
идемпотентность retry'я.
|
||
- **Tax** считается «в-ставке»: `LineTotal * vat / (100+vat)`.
|
||
Webkassa требует именно НДС в составе цены, а не сверху.
|
||
|
||
## Касса24 (https://kassa24.kz)
|
||
|
||
`FiscalProvider = 3`. **Skeleton**, реальная интеграция ждёт получения
|
||
спецификации API (NDA-only после подписания договора с Kaspi).
|
||
|
||
Когда документация появится — нужно реализовать в `Kassa24Provider`:
|
||
|
||
1. Аутентификацию (предположительно HMAC-SHA256 подпись ApiSecret'ом
|
||
по примеру Kaspi merchant API).
|
||
2. POST `/v1/check` (рабочее название).
|
||
3. Маппинг `RetailSale.Lines` → их формат позиций.
|
||
4. Парсинг ответа: `fiscalNumber`, `qrCode`, `ticketUrl`, `transactionId`.
|
||
|
||
В UI/тестовой отправке провайдер на сегодня возвращает
|
||
`FiscalNotConfiguredException` с понятным сообщением.
|
||
|
||
## ОФД-Соло (https://ofd-solo.kz)
|
||
|
||
`FiscalProvider = 4`. **Skeleton**, аналогично Касса24.
|
||
|
||
Особенности (из публичных источников):
|
||
- SOAP-based legacy + REST-обёртка (использовать REST).
|
||
- Аутентификация по token-логину (как Webkassa).
|
||
- Чек регистрируется одним вызовом (без двухшагового create/post).
|
||
|
||
## Безопасность кредов
|
||
|
||
`Organization.FiscalApiKeyEncrypted` / `FiscalApiSecretEncrypted` —
|
||
**DataProtection-шифрованный blob** (purpose=`foodmarket.fiscal`).
|
||
В API-ответах НЕ возвращаются: GET `/api/organization/fiscal` отдаёт
|
||
только `hasApiKey: bool` / `hasApiSecret: bool` флаги.
|
||
|
||
Чтобы изменить — PUT с непустым `newApiKey`/`newApiSecret`. Чтобы
|
||
СНЯТЬ (вернуться к None без потери остальных полей) — отправить
|
||
спец-значение `"__clear__"`.
|
||
|
||
При смене DataProtection ключа (rotation / restore из бэкапа без
|
||
ключей) — `Unprotect` упадёт. Провайдер бросит понятное сообщение
|
||
с просьбой «Введите ApiKey/ApiSecret заново».
|
||
|
||
## Чек-сценарий retry / network failure
|
||
|
||
Фискализация вызывается **после** commit'а stock-транзакции и
|
||
является best-effort:
|
||
|
||
- Сетевая ошибка / 5xx от оператора → лог `Warning`, чек остаётся
|
||
проведённым без FiscalNumber. UI отрендерит чек, на квитанции
|
||
будет «не фискализован» (нужно перепровести вручную:
|
||
unpost → post → провайдер дёрнется снова).
|
||
- `FiscalNotConfiguredException` → лог `Warning`, без алерта (это
|
||
валидная диагностика, не ошибка системы).
|
||
- Идемпотентность: `TryFiscalizeAsync` проверяет
|
||
`string.IsNullOrEmpty(sale.FiscalNumber)` и не дёргает провайдера,
|
||
если фискальный номер уже есть. Re-post чека (unpost→post) с уже
|
||
фискализованным состоянием → не дублирует регистрацию.
|
||
|
||
## Метрики и наблюдаемость (TODO sprint 12+)
|
||
|
||
Пока есть только логи (`Information` на успех, `Warning` на ошибку).
|
||
В следующем спринте добавить:
|
||
|
||
- `AppMetrics.IncrementFiscalized(provider)` / `IncrementFiscalFailed(provider)`.
|
||
- Алерт «провайдер X провалился N раз за последние M минут» —
|
||
возможно перевод на ручную фискализацию.
|
||
- Dashboard-виджет «фискальный статус» (% чеков с FiscalNumber за день).
|