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>
10 KiB
Интеграция с ОФД-операторами Казахстана
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.
Существующие данные не меняются.
Чтобы включить:
- Войти в «Настройки организации → ОФД».
- Выбрать провайдера в селекте, заполнить ApiKey/ApiSecret/CashboxUniqueNumber.
- Нажать «Тестовая отправка» — провайдер дёрнет себя на фейк-чеке,
покажет либо
FiscalNumber=…(успех), либо текст ошибки (нет кредов / оператор недоступен / провайдер ещё не реализован). - Сохранить.
- Следующий проведённый чек получит
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'а
- Зарегистрироваться в кабинете Webkassa, подписать договор.
- Получить в кабинете:
- Логин/пароль 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:
- Аутентификацию (предположительно HMAC-SHA256 подпись ApiSecret'ом по примеру Kaspi merchant API).
- POST
/v1/check(рабочее название). - Маппинг
RetailSale.Lines→ их формат позиций. - Парсинг ответа:
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 за день).