food-market/docs/ofd-integration.md
nns 0d3ef81f72 feat(s11): ОФД-scaffolding — IFiscalProvider + 4 провайдера + UI/тесты
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>
2026-06-07 02:27:17 +05:00

10 KiB
Raw Permalink Blame History

Интеграция с ОФД-операторами Казахстана

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:

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 / FiscalApiSecretEncryptedDataProtection-шифрованный 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 за день).