# Интеграция с ОФД-операторами Казахстана 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/?n= FiscalUrl: https://mock.ofd.local/check/ ProviderTxId: mock-tx-AB12CD34EF56 ``` Идемпотентен по `Sale.Id` — повторный вызов даёт тот же FiscalNumber (integration-тест `FiscalMockFlowTests` это проверяет). В тестах активируется через глобальный override: ```csharp cfg.AddInMemoryCollection(new Dictionary { ["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 за день).