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

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 за день).
