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

185 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Интеграция с ОФД-операторами Казахстана
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 за день).