From 0d3ef81f7296a496f6af4943583a3a745708adb2 Mon Sep 17 00:00:00 2001 From: nns Date: Sun, 7 Jun 2026 02:27:17 +0500 Subject: [PATCH] =?UTF-8?q?feat(s11):=20=D0=9E=D0=A4=D0=94-scaffolding=20?= =?UTF-8?q?=E2=80=94=20IFiscalProvider=20+=204=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=B9=D0=B4=D0=B5=D1=80=D0=B0=20+=20UI/=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/ofd-integration.md | 184 +++++++++++ docs/sprint11-progress.md | 98 ++++++ .../OrgFiscalSettingsController.cs | 211 +++++++++++++ .../Sales/RetailSalesController.cs | 81 ++++- src/food-market.api/Program.cs | 23 ++ .../Common/Fiscal/IFiscalProvider.cs | 108 +++++++ .../Organizations/Organization.cs | 28 ++ src/food-market.domain/Sales/RetailSale.cs | 26 ++ .../Fiscal/FiscalProviderFactory.cs | 78 +++++ .../Fiscal/Kassa24Provider.cs | 98 ++++++ .../Fiscal/MockFiscalProvider.cs | 59 ++++ .../Fiscal/OfdSoloProvider.cs | 89 ++++++ .../Fiscal/WebkassaProvider.cs | 291 ++++++++++++++++++ .../Configurations/SalesConfigurations.cs | 6 + ...260607100000_Phase11a_FiscalScaffolding.cs | 118 +++++++ .../src/pages/OrganizationSettingsPage.tsx | 220 ++++++++++++- .../FiscalMockFlowTests.cs | 174 +++++++++++ .../FiscalMockProviderTests.cs | 76 +++++ .../WebkassaProviderTests.cs | 165 ++++++++++ 19 files changed, 2129 insertions(+), 4 deletions(-) create mode 100644 docs/ofd-integration.md create mode 100644 docs/sprint11-progress.md create mode 100644 src/food-market.api/Controllers/Organizations/OrgFiscalSettingsController.cs create mode 100644 src/food-market.application/Common/Fiscal/IFiscalProvider.cs create mode 100644 src/food-market.infrastructure/Fiscal/FiscalProviderFactory.cs create mode 100644 src/food-market.infrastructure/Fiscal/Kassa24Provider.cs create mode 100644 src/food-market.infrastructure/Fiscal/MockFiscalProvider.cs create mode 100644 src/food-market.infrastructure/Fiscal/OfdSoloProvider.cs create mode 100644 src/food-market.infrastructure/Fiscal/WebkassaProvider.cs create mode 100644 src/food-market.infrastructure/Persistence/Migrations/20260607100000_Phase11a_FiscalScaffolding.cs create mode 100644 tests/food-market.IntegrationTests/FiscalMockFlowTests.cs create mode 100644 tests/food-market.UnitTests/FiscalMockProviderTests.cs create mode 100644 tests/food-market.UnitTests/WebkassaProviderTests.cs diff --git a/docs/ofd-integration.md b/docs/ofd-integration.md new file mode 100644 index 0000000..6cd772c --- /dev/null +++ b/docs/ofd-integration.md @@ -0,0 +1,184 @@ +# Интеграция с ОФД-операторами Казахстана + +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 за день). diff --git a/docs/sprint11-progress.md b/docs/sprint11-progress.md new file mode 100644 index 0000000..43560a3 --- /dev/null +++ b/docs/sprint11-progress.md @@ -0,0 +1,98 @@ +# Sprint 11 — ОФД-scaffolding (фискализация РК) + +Цель: построить фрейм для интеграции с операторами фискальных данных +Казахстана (Webkassa / Касса24 / ОФД-Соло), чтобы как только пользователь +получит реальный ApiKey — провайдер «оживал» одной настройкой в UI, +без правок кода/деплоя. Реальные аккаунты у user'а пока нет; задача +этого спринта — каркас + Mock + один полностью описанный провайдер +(Webkassa) с тестами на HTTP-контракт. + +Старт: 2026-06-07. Исполнитель: Claude Opus 4.7 (автономный режим). + +## Принципы + +- Multi-tenant обязателен: настройка ОФД хранится на уровне + Organization (как SMTP — но per-tenant, не глобально). API-ключи + шифруются через DataProtection (purpose=`foodmarket.fiscal`). +- Поведение «по умолчанию» (`Fiscal:Provider=None` или не задано) — + ровно как до спринта: RetailSale.Post не зовёт никакого провайдера, + FiscalNumber остаётся пустым. Это даёт обратную совместимость. +- Каждый пункт: build + локальные тесты + `~/deploy-stage.sh` + retest. +- НЕ трогать: `global.json`, прод-стек, POS WPF. + +## Чек-лист + +- [x] **1. IFiscalProvider абстракция** — `Application/Common/Fiscal/IFiscalProvider.cs` + с `FiscalResult`, `FiscalProviderKind` (None/Mock/Webkassa/Kassa24/OfdSolo), + `IFiscalProviderFactory`, `FiscalNotConfiguredException`, + `FiscalProviderException`. Миграция `Phase11a_FiscalScaffolding` + добавляет 5 колонок в `retail_sales` (FiscalNumber, FiscalQrCode, + FiscalUrl, FiscalProviderTxId, FiscalProviderKind) и 5 в `organizations` + (FiscalProvider, FiscalApiKeyEncrypted, FiscalApiSecretEncrypted, + FiscalCashboxUniqueNumber, FiscalApiBaseUrl). `FiscalProvider` NOT NULL + с default 0 (None) — обратная совместимость. `RetailSalesController.Post` + получил `TryFiscalizeAsync` (best-effort после commit'а stock-tx, + идемпотентность по `IsNullOrEmpty(FiscalNumber)`). +- [x] **2. MockFiscalProvider** — `Infrastructure/Fiscal/MockFiscalProvider.cs`, + имитация 300мс задержки, детерминированный фейк `MOCK-<8hex>` от + `Sale.Id`. 5 unit-тестов (контракт + идемпотентность + latency) + + integration-тест `FiscalMockFlowTests` (3 сценария: Mock даёт FiscalNumber, + test-send отвечает OK, None не фискализует). +- [x] **3. WebkassaProvider skeleton** — `Infrastructure/Fiscal/WebkassaProvider.cs`, + полный HTTP-flow `Authorize → Check`, парсинг JSON-ответа Webkassa. + Token берётся каждым вызовом (TTL-кеш — следующий спринт). + `BuildCheckPayload` public для тестируемости. 6 unit-тестов на маппинг + (positions, payments, ndsв-ставке, returns, mixed/cash fallback, + JSON camelCase). Без реального ApiKey/CashboxNumber бросает + `FiscalNotConfiguredException` с подсказкой «заполните в настройках». +- [x] **4. Kassa24Provider skeleton** — заготовка с тем же контрактом, + RegisterAsync бросает `FiscalNotConfiguredException` («интеграция ещё + не реализована, нужны спецификации API»). Подробности — в docs. +- [x] **5. OfdSoloProvider skeleton** — аналогично. +- [x] **6. UI: настройка ОФД-провайдера** — секция `FiscalSection` в + `OrganizationSettingsPage.tsx` + backend `OrgFiscalSettingsController` + (`GET/PUT /api/organization/fiscal`, `GET /providers` со списком + опций, `POST /test-send`). Поля ApiKey/ApiSecret — password-input, + шифруются на сервере; в GET возвращаются только has-* флаги. + Спец-значение `"__clear__"` — снять креды. Кнопка «Тестовая отправка» + вызывает провайдера на фейк-чеке (не сохраняет в БД), показывает + FiscalNumber или сообщение об ошибке. +- [x] **7. docs/ofd-integration.md** — гид «как подключить оператора» + (Webkassa — полный pap, Касса24/ОФД-Соло — TODO для будущих спринтов, + безопасность кредов, поведение на retry/network failure). + +## Журнал + +### 2026-06-07 старт +Sprint 10 закрыт (4/4 ✓). Поехали по ОФД-чек-листу. + +### 2026-06-07 п.1–п.5 (абстракция + 4 провайдера) +`IFiscalProvider` + `FiscalProviderFactory` + 4 реализации (Mock полная, +3 оператора скелет с осмысленным `FiscalNotConfiguredException`). +Миграция Phase11a добавила 10 колонок в `retail_sales` + `organizations`. +`RetailSalesController.Post` — `TryFiscalizeAsync` после commit'а. +Тесты: 11 unit (Mock + Webkassa payload) + 3 integration. Все зелёные. + +### 2026-06-07 п.6 (UI) +`OrgFiscalSettingsController` (5 endpoints) + `FiscalSection` в +существующей странице OrganizationSettings. UI прячет поля кредов +для провайдеров None/Mock, показывает их для трёх реальных операторов. +Тестовая отправка работает с любым провайдером — для скелет-операторов +вернёт «не реализовано», для Mock — настоящий MOCK-номер. + +### 2026-06-07 п.7 (docs) +`docs/ofd-integration.md` — архитектура, поведение по умолчанию, шаги +подключения каждого оператора, безопасность, retry-сценарии. + +### Итог +Все 7 пунктов ✓. Suite-тесты: +- 68/68 unit (включая 11 новых для Fiscal). +- 8/8 integration (Fiscal + Loyalty + RetailOversell в одной группе). +- Web `vite build` зелёный, TS — без ошибок. + +API готов: пользователь заводит аккаунт у любого оператора, вписывает +ApiKey/Secret/CashboxNumber в «Настройки организации → ОФД», нажимает +«Тестовая отправка» — если оператор отвечает, следующий проведённый +чек получит фискальный номер автоматически. Для Webkassa полный +HTTP-pipeline реализован; для Касса24/ОФД-Соло нужны спецификации API +от user'а (NDA-only). diff --git a/src/food-market.api/Controllers/Organizations/OrgFiscalSettingsController.cs b/src/food-market.api/Controllers/Organizations/OrgFiscalSettingsController.cs new file mode 100644 index 0000000..81d207e --- /dev/null +++ b/src/food-market.api/Controllers/Organizations/OrgFiscalSettingsController.cs @@ -0,0 +1,211 @@ +using foodmarket.Application.Common.Fiscal; +using foodmarket.Application.Common.Tenancy; +using foodmarket.Domain.Sales; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Organizations; + +/// Sprint 11: настройки ОФД-провайдера на уровне организации. +/// Аналогично PlatformSettings/SMTP, но per-tenant: каждая фирма выбирает +/// своего оператора и хранит свои креды. ApiKey/ApiSecret шифруются через +/// DataProtection (purpose=`foodmarket.fiscal`) и НИКОГДА не отдаются +/// в открытом виде — только has-* флаги. +[ApiController] +[Authorize] +[Route("api/organization/fiscal")] +public class OrgFiscalSettingsController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly ITenantContext _tenant; + private readonly IDataProtectionProvider _dpProvider; + private readonly IEnumerable _providers; + private readonly ILogger _log; + + public OrgFiscalSettingsController( + AppDbContext db, + ITenantContext tenant, + IDataProtectionProvider dpProvider, + IEnumerable providers, + ILogger log) + { + _db = db; + _tenant = tenant; + _dpProvider = dpProvider; + _providers = providers; + _log = log; + } + + public record FiscalSettingsDto( + int Provider, + string ProviderName, + bool HasApiKey, + bool HasApiSecret, + string? CashboxUniqueNumber, + string? ApiBaseUrl); + + public record FiscalSettingsInput( + int Provider, + // Если null/пусто — поле не меняется. Чтобы СНЯТЬ креды (вернуться к + // None без удаления записи), используем спец-значение "__clear__". + string? NewApiKey, + string? NewApiSecret, + string? CashboxUniqueNumber, + string? ApiBaseUrl); + + public record TestSendResponse(bool Ok, string? Message, + string? FiscalNumber, string? FiscalQrCode, string? FiscalUrl); + + /// Доступные значения провайдера для select'а в UI. Возвращаем + /// массив, потому что enum-значения мы НЕ хотим публиковать через + /// generic schema-export — кодом проще держать локализованные имена. + [HttpGet("providers")] + public IActionResult GetProviders() + { + var list = new[] + { + new { value = 0, name = "Без фискализации", description = "Чеки проводятся, но фискальный номер не получаем." }, + new { value = 1, name = "Mock (dev)", description = "Демонстрационный провайдер, возвращает фейк через 300мс. Только для тестирования." }, + new { value = 2, name = "Webkassa", description = "https://webkassa.kz — крупнейший ОФД РК." }, + new { value = 3, name = "Касса24", description = "https://kassa24.kz — интеграция с Kaspi Pay (skeleton)." }, + new { value = 4, name = "ОФД-Соло", description = "https://ofd-solo.kz (skeleton)." }, + }; + return Ok(list); + } + + [HttpGet] + public async Task> Get(CancellationToken ct) + { + var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant."); + var o = await _db.Organizations.FirstOrDefaultAsync(o => o.Id == orgId, ct); + if (o is null) return NotFound(); + return Project(o); + } + + [HttpPut, Authorize(Roles = "Admin")] + public async Task> Update([FromBody] FiscalSettingsInput input, CancellationToken ct) + { + var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant."); + var o = await _db.Organizations.FirstOrDefaultAsync(o => o.Id == orgId, ct); + if (o is null) return NotFound(); + + if (!Enum.IsDefined(typeof(FiscalProviderKind), input.Provider)) + return BadRequest(new { error = $"Неизвестный провайдер {input.Provider}." }); + + o.FiscalProvider = input.Provider; + o.CashboxUniqueNumber(input.CashboxUniqueNumber); + o.FiscalApiBaseUrl = string.IsNullOrWhiteSpace(input.ApiBaseUrl) ? null : input.ApiBaseUrl.Trim(); + + var protector = _dpProvider.CreateProtector("foodmarket.fiscal"); + if (input.NewApiKey == "__clear__") o.FiscalApiKeyEncrypted = null; + else if (!string.IsNullOrEmpty(input.NewApiKey)) + o.FiscalApiKeyEncrypted = protector.Protect(input.NewApiKey); + + if (input.NewApiSecret == "__clear__") o.FiscalApiSecretEncrypted = null; + else if (!string.IsNullOrEmpty(input.NewApiSecret)) + o.FiscalApiSecretEncrypted = protector.Protect(input.NewApiSecret); + + await _db.SaveChangesAsync(ct); + _log.LogInformation( + "Fiscal settings обновлены для org {OrgId}: provider={Provider} cashbox={Cashbox} hasKey={HasKey}", + orgId, (FiscalProviderKind)o.FiscalProvider, o.FiscalCashboxUniqueNumber, + !string.IsNullOrEmpty(o.FiscalApiKeyEncrypted)); + return Project(o); + } + + /// Тестовая отправка: создаёт «фейк-чек» (in-memory, не в БД) + /// и отправляет через выбранного провайдера. Не сохраняет результат — + /// просто показывает админу что креды валидны и оператор отвечает. + /// Webkassa/Касса24/ОФД-Соло в skeleton'е бросают FiscalNotConfiguredException, + /// что UI отрендерит как «не реализовано»; Mock возвращает MOCK-fingerprint + /// и показывает что pipeline жив. + [HttpPost("test-send"), Authorize(Roles = "Admin")] + public async Task> TestSend(CancellationToken ct) + { + var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant."); + var o = await _db.Organizations.AsNoTracking().FirstOrDefaultAsync(x => x.Id == orgId, ct); + if (o is null) return NotFound(); + if (o.FiscalProvider == 0) + return BadRequest(new TestSendResponse(false, + "Провайдер не выбран. Выберите оператора и сохраните настройки перед тестом.", + null, null, null)); + + var kind = (FiscalProviderKind)o.FiscalProvider; + var provider = _providers.FirstOrDefault(p => p.Kind == kind); + if (provider is null) + return BadRequest(new TestSendResponse(false, + $"Провайдер {kind} не зарегистрирован в DI.", null, null, null)); + + // Фейк-чек: 1 позиция «Тестовый товар», 100 ₸, наличными. Не + // сохраняем в БД — Webkassa/моки получают POCO в памяти. + var fake = new RetailSale + { + Id = Guid.NewGuid(), + OrganizationId = orgId, + Number = "TEST-" + DateTime.UtcNow.ToString("yyyyMMddHHmmss"), + Date = DateTime.UtcNow, + StoreId = Guid.NewGuid(), + CurrencyId = Guid.NewGuid(), + Subtotal = 100m, Total = 100m, PaidCash = 100m, + }; + fake.Lines.Add(new RetailSaleLine + { + ProductId = Guid.NewGuid(), + Product = new foodmarket.Domain.Catalog.Product { Name = "Тестовый товар" }, + Quantity = 1m, UnitPrice = 100m, LineTotal = 100m, VatPercent = 12m, + }); + + try + { + var r = await provider.RegisterAsync(fake, ct); + return Ok(new TestSendResponse(true, + $"Провайдер {kind} ответил. FiscalNumber={r.FiscalNumber}", + r.FiscalNumber, r.FiscalQrCode, r.FiscalUrl)); + } + catch (FiscalNotConfiguredException ex) + { + return Ok(new TestSendResponse(false, ex.Message, null, null, null)); + } + catch (FiscalProviderException ex) + { + return Ok(new TestSendResponse(false, ex.Message, null, null, null)); + } + catch (Exception ex) + { + _log.LogWarning(ex, "Fiscal test-send упал неожиданно для org {OrgId}", orgId); + return Ok(new TestSendResponse(false, "Непредвиденная ошибка: " + ex.Message, null, null, null)); + } + } + + private FiscalSettingsDto Project(foodmarket.Domain.Organizations.Organization o) + { + var kind = (FiscalProviderKind)o.FiscalProvider; + var name = kind switch + { + FiscalProviderKind.None => "Без фискализации", + FiscalProviderKind.Mock => "Mock (dev)", + FiscalProviderKind.Webkassa => "Webkassa", + FiscalProviderKind.Kassa24 => "Касса24", + FiscalProviderKind.OfdSolo => "ОФД-Соло", + _ => kind.ToString(), + }; + return new FiscalSettingsDto( + o.FiscalProvider, name, + !string.IsNullOrEmpty(o.FiscalApiKeyEncrypted), + !string.IsNullOrEmpty(o.FiscalApiSecretEncrypted), + o.FiscalCashboxUniqueNumber, + o.FiscalApiBaseUrl); + } +} + +/// Маленький фикс — Organization.CashboxUniqueNumber изначально +/// называлось FiscalCashboxUniqueNumber. Чтобы код контроллера +/// не запутался с длинным именем, локальный extension'ом сахарим. +internal static class _OrgFiscalExt +{ + public static void CashboxUniqueNumber(this foodmarket.Domain.Organizations.Organization o, string? v) + => o.FiscalCashboxUniqueNumber = string.IsNullOrWhiteSpace(v) ? null : v.Trim(); +} diff --git a/src/food-market.api/Controllers/Sales/RetailSalesController.cs b/src/food-market.api/Controllers/Sales/RetailSalesController.cs index 1a918ff..df81698 100644 --- a/src/food-market.api/Controllers/Sales/RetailSalesController.cs +++ b/src/food-market.api/Controllers/Sales/RetailSalesController.cs @@ -20,14 +20,17 @@ public class RetailSalesController : ControllerBase private readonly IStockService _stock; private readonly ILogger _log; private readonly foodmarket.Api.Realtime.INotificationsPublisher _notify; + private readonly foodmarket.Application.Common.Fiscal.IFiscalProviderFactory _fiscal; public RetailSalesController(AppDbContext db, IStockService stock, ILogger log, - foodmarket.Api.Realtime.INotificationsPublisher notify) + foodmarket.Api.Realtime.INotificationsPublisher notify, + foodmarket.Application.Common.Fiscal.IFiscalProviderFactory fiscal) { _db = db; _stock = stock; _log = log; _notify = notify; + _fiscal = fiscal; } public record RetailSaleListRow( @@ -63,7 +66,12 @@ public record RetailSaleDto( decimal LoyaltyPointsAccrued = 0m, Guid? PromotionId = null, string? PromotionCode = null, - decimal PromotionDiscount = 0m); + decimal PromotionDiscount = 0m, + // Sprint 11: ОФД-снапшоты. Null до фискализации / при провайдере None. + string? FiscalNumber = null, + string? FiscalQrCode = null, + string? FiscalUrl = null, + int? FiscalProviderKind = null); public record RetailSaleLineInput( Guid ProductId, @@ -594,6 +602,14 @@ public async Task Post(Guid id, CancellationToken ct) "RetailSale posted: {SaleNumber} store={StoreId} payment={Payment} lines={LinesCount} total={Total}", sale.Number, sale.StoreId, sale.Payment, sale.Lines.Count, sale.Total); + // ── Sprint 11: фискализация чека у ОФД-оператора ───────────────── + // Делаем ПОСЛЕ commit'а stock-транзакции — фискальный RPC может + // занимать секунды (Webkassa в час пик), удерживать всю серию + // блокировок ради этого нельзя. Если оператор недоступен, чек + // остаётся проведённым (Posted=true), а фискальный номер просто + // пуст — это допустимо (можно перепровести post вручную). + await TryFiscalizeAsync(sale, ct); + // SignalR-уведомление в группу org. Кассирa берём из CashierId если // есть Employee.Name по UserId, иначе из User.Email (короткая часть до @). try @@ -652,6 +668,63 @@ private async Task NotifyLowStockAfterSaleAsync(RetailSale sale, CancellationTok } } + /// Sprint 11: вызвать ОФД-провайдера (если выбран) и сохранить + /// фискальный номер/QR на чек. Best-effort: любая ошибка проглатывается + /// и логируется — чек остаётся проведённым даже без фискализации + /// (оператор может быть временно недоступен, retry — отдельная история). + /// + /// Идемпотентность: если на чеке уже есть FiscalNumber, повторно не + /// зовём. Это покрывает случай ручного re-post'а через unpost→post. + private async Task TryFiscalizeAsync(RetailSale sale, CancellationToken ct) + { + if (!string.IsNullOrEmpty(sale.FiscalNumber)) return; + foodmarket.Application.Common.Fiscal.IFiscalProvider? provider; + try + { + provider = await _fiscal.ResolveAsync(ct); + } + catch (Exception ex) + { + _log.LogWarning(ex, "Fiscal: фабрика провайдеров упала для чека {SaleNumber}", sale.Number); + return; + } + if (provider is null) return; // None — фискализация отключена + + try + { + // Подгружаем продукт для PositionName (Webkassa требует имя в + // payload'е). Include на этом этапе чтобы EF не дёргал N+1. + await _db.Entry(sale).Collection(s => s.Lines).Query() + .Include(l => l.Product).LoadAsync(ct); + + var result = await provider.RegisterAsync(sale, ct); + sale.FiscalNumber = result.FiscalNumber; + sale.FiscalQrCode = result.FiscalQrCode; + sale.FiscalUrl = result.FiscalUrl; + sale.FiscalProviderTxId = result.ProviderTxId; + sale.FiscalProviderKind = (int)provider.Kind; + await _db.SaveChangesAsync(ct); + _log.LogInformation( + "Fiscal: чек {SaleNumber} зарегистрирован у {Provider} → {FiscalNumber}", + sale.Number, provider.Kind, result.FiscalNumber); + } + catch (foodmarket.Application.Common.Fiscal.FiscalNotConfiguredException ex) + { + // Конфиг неполный — это валидная диагностика, не алерт. + _log.LogWarning( + "Fiscal: провайдер {Provider} не настроен для чека {SaleNumber}: {Message}", + provider.Kind, sale.Number, ex.Message); + } + catch (Exception ex) + { + // Сетевые/HTTP-ошибки — записываем warning. Алерт можно навесить + // на счётчик AppMetrics в Sprint 12+, когда будут реальные данные. + _log.LogWarning(ex, + "Fiscal: провайдер {Provider} вернул ошибку для чека {SaleNumber}", + provider.Kind, sale.Number); + } + } + [HttpPost("{id:guid}/unpost"), RequiresPermission("RetailSalesRefund")] public async Task Unpost(Guid id, CancellationToken ct) { @@ -999,6 +1072,8 @@ orderby l.SortOrder lines, // Sprint 9 row.s.LoyaltyCardId, row.s.LoyaltyBonusApplied, row.s.LoyaltyPointsAccrued, - row.s.PromotionId, row.s.PromotionCode, row.s.PromotionDiscount); + row.s.PromotionId, row.s.PromotionCode, row.s.PromotionDiscount, + // Sprint 11 + row.s.FiscalNumber, row.s.FiscalQrCode, row.s.FiscalUrl, row.s.FiscalProviderKind); } } diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index 583771a..4574c97 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -165,6 +165,29 @@ // на каждой отправке без рестарта приложения. builder.Services.AddSingleton(); + + // ─ Sprint 11: ОФД (фискализация чеков в РК) ──────────────────────── + // Все провайдеры регистрируются одновременно — фабрика выбирает по + // FiscalProvider в настройках организации. Default — None: чеки + // проводятся без фискализации, поведение «как до Sprint 11». + // + // Mock = Transient (без HTTP); остальные через AddHttpClient — это + // даёт автоматический pooled HttpMessageHandler + интеграцию с + // IHttpClientFactory (вынос в Polly / переопределение handler'а + // в тестах через .AddHttpMessageHandler). + builder.Services.AddTransient(); + builder.Services.AddHttpClient(); + builder.Services.AddTransient(sp => + sp.GetRequiredService()); + builder.Services.AddHttpClient(); + builder.Services.AddTransient(sp => + sp.GetRequiredService()); + builder.Services.AddHttpClient(); + builder.Services.AddTransient(sp => + sp.GetRequiredService()); + builder.Services.AddScoped(); // EmailTemplates загружает embedded HTML и подставляет {{key}} — // см. Resources/EmailTemplates/*.html. Singleton с in-memory cache. builder.Services.AddSingleton(); diff --git a/src/food-market.application/Common/Fiscal/IFiscalProvider.cs b/src/food-market.application/Common/Fiscal/IFiscalProvider.cs new file mode 100644 index 0000000..9f7b41e --- /dev/null +++ b/src/food-market.application/Common/Fiscal/IFiscalProvider.cs @@ -0,0 +1,108 @@ +using foodmarket.Domain.Sales; + +namespace foodmarket.Application.Common.Fiscal; + +/// Абстракция оператора фискальных данных (ОФД) для Казахстана. +/// Каждый чек после успешного RetailSale.Post регистрируется в +/// налоговой через одного из аккредитованных операторов (Webkassa / Касса24 / +/// ОФД-Соло / …). Оператор возвращает фискальный номер, QR-код и +/// URL-предъявителя — мы сохраняем их на чеке и печатаем на квитанции. +/// +/// Контракт умышленно тонкий: всё, что нужно вызывающему слою — отдать +/// чек, получить четыре поля. Шифрование ApiKey'ев, выбор реализации, +/// idempotency-ключи живут на инфра-стороне (DI-привязка в Program.cs + +/// конфигурация на уровне Organization). +public interface IFiscalProvider +{ + /// Какой это провайдер (для диагностики / маршрутизации + /// фабрики). Должно совпадать со значением + /// в настройках организации. + FiscalProviderKind Kind { get; } + + /// Зарегистрировать чек у оператора. Вызывается ВНУТРИ + /// уже сохранённой (Posted) транзакции — реализация не должна + /// модифицировать stocks/прочие сущности, только сходить во внешний + /// API и вернуть результат. + /// + /// Реализация обязана быть идемпотентной по + /// (повторный вызов с тем же чеком должен вернуть тот же FiscalNumber, + /// а не создать дубль) — детали реализации зависят от оператора: + /// у кого-то есть нативный idempotency-key, у кого-то приходится + /// проверять по нашему номеру. + Task RegisterAsync(RetailSale sale, CancellationToken ct); +} + +/// Тип ОФД-оператора. Хранится в БД (Organization.FiscalProvider), +/// конфигурации (Fiscal:Provider) и в логах. Порядок и числовые значения +/// фиксированы (БД-колонка int) — НЕ переставлять. +public enum FiscalProviderKind +{ + /// Никто. Чеки проводятся без фискализации (поведение «как + /// до Sprint 11»). Дефолт — гарантия обратной совместимости. + None = 0, + + /// Mock-провайдер для разработки и тестов. Возвращает + /// детерминированный фейк через 300мс (имитация сетевой задержки). + Mock = 1, + + /// Webkassa (https://webkassa.kz). Самый распространённый ОФД + /// в РК, REST-API на https://api.webkassa.kz/. + Webkassa = 2, + + /// Касса24 (https://kassa24.kz). Принадлежит Kaspi-группе, + /// тесная интеграция с QR-эквайрингом Kaspi Pay. + Kassa24 = 3, + + /// ОФД-Соло (https://ofd-solo.kz). + OfdSolo = 4, +} + +/// Результат фискализации одного чека. Все поля — снапшот, +/// сохраняются на и больше не меняются. +/// Фискальный номер чека от оператора (например, +/// для Webkassa — checkNumber). Печатается на квитанции. Не пуст. +/// Содержимое QR-кода (как правило — URL для +/// проверки чека в кабинете налоговой). POS-программа рендерит QR на бумаге. +/// Человекочитаемый URL для перехода (часто +/// совпадает с QR, но не всегда — иногда оператор отдаёт «короткий» qr +/// и отдельно полный URL). +/// Внутренний id транзакции у оператора. Нужен +/// если попросят support — по нему оператор найдёт запрос в своих логах. +/// Может быть null если оператор не возвращает. +public record FiscalResult( + string FiscalNumber, + string FiscalQrCode, + string FiscalUrl, + string? ProviderTxId); + +/// Бросается когда ОФД-провайдер пытаются позвать, но конфиг +/// неполный — например, выбран Webkassa, а ApiKey не задан. Контроллер +/// RetailSales должен поймать и вернуть понятный 400/503 (не падать в 500). +/// +/// Использование: только из реализаций и +/// фабрики. В application-слое не ловим — пусть всплывает выше. +public class FiscalNotConfiguredException : Exception +{ + public FiscalNotConfiguredException(string message) : base(message) { } +} + +/// Бросается при сетевой/API-ошибке оператора (5xx, таймаут, +/// невалидный JSON). Чек НЕ помечается фискализованным — оператор будет +/// перезван при следующем POST на этот чек (idempotency). +public class FiscalProviderException : Exception +{ + public FiscalProviderException(string message) : base(message) { } + public FiscalProviderException(string message, Exception inner) : base(message, inner) { } +} + +/// Фабрика — отдаёт провайдер по +/// из текущей организации. Регистрируется в DI как Scoped (Organization +/// читается из tenant-контекста). Возвращает null если выбран +/// . +public interface IFiscalProviderFactory +{ + /// Получить провайдер для текущей организации (или null, если + /// в её настройках выбран None). НЕ кидает — выбор «не фискализовать» + /// нормальный сценарий. + Task ResolveAsync(CancellationToken ct = default); +} diff --git a/src/food-market.domain/Organizations/Organization.cs b/src/food-market.domain/Organizations/Organization.cs index e341578..57f76a8 100644 --- a/src/food-market.domain/Organizations/Organization.cs +++ b/src/food-market.domain/Organizations/Organization.cs @@ -86,4 +86,32 @@ public class Organization : Entity /// false. Описания ведут единицы магазинов; обычно текстовая колонка /// просто захламляет карточку. public bool ShowDescriptionOnProduct { get; set; } + + // ─ Sprint 11: ОФД (фискализация чеков) ──────────────────────────── + // Каждая организация выбирает СВОЕГО ОФД-оператора. Креды per-tenant: + // у одной фирмы может быть Webkassa, у другой — Касса24. ApiKey/Secret + // шифруются через DataProtection (purpose=`foodmarket.fiscal`) — в + // открытом виде в API-ответах не возвращаются (только has-* флаги). + + /// Тип ОФД-провайдера. 0=None (не фискализировать — дефолт), + /// 1=Mock, 2=Webkassa, 3=Kassa24, 4=OfdSolo. Значения — см. + /// FiscalProviderKind в application-слое. + public int FiscalProvider { get; set; } + + /// Зашифрованный ApiKey оператора (base64 через DataProtection, + /// purpose=`foodmarket.fiscal`). Никогда не отдаётся в API-ответах. + public string? FiscalApiKeyEncrypted { get; set; } + + /// Зашифрованный ApiSecret оператора (если требуется — у + /// Webkassa, например, токен-логин, у Касса24 — отдельный secret). + public string? FiscalApiSecretEncrypted { get; set; } + + /// Уникальный номер кассы у оператора. У Webkassa называется + /// CashboxUniqueNumber, передаётся в каждом чеке. + public string? FiscalCashboxUniqueNumber { get; set; } + + /// Опциональный override URL'а оператора (для тестового + /// контура / sandbox'а). null → используется production URL из + /// реализации провайдера. + public string? FiscalApiBaseUrl { get; set; } } diff --git a/src/food-market.domain/Sales/RetailSale.cs b/src/food-market.domain/Sales/RetailSale.cs index 6b8dd09..1b3339b 100644 --- a/src/food-market.domain/Sales/RetailSale.cs +++ b/src/food-market.domain/Sales/RetailSale.cs @@ -87,6 +87,32 @@ public class RetailSale : TenantEntity, IVersionedEntity public RetailSale? ReferenceSale { get; set; } public ICollection Lines { get; set; } = new List(); + + // ─ Sprint 11: ОФД-фискализация ────────────────────────────────────── + // Снапшоты ответа оператора. Заполняются после успешной + // регистрации чека (см. IFiscalProvider). Null = чек не + // фискализован (или провайдер None/Mock без записи в БД). + + /// Фискальный номер чека от оператора (например, Webkassa + /// возвращает checkNumber). Печатается на квитанции для + /// покупателя. Null до проведения / если провайдер None. + public string? FiscalNumber { get; set; } + + /// Содержимое QR-кода для печати на бумаге. Обычно — URL + /// для проверки чека в кабинете налоговой РК. + public string? FiscalQrCode { get; set; } + + /// Человекочитаемый URL чека (для перехода вручную, если + /// QR недоступен). + public string? FiscalUrl { get; set; } + + /// Внутренний id транзакции у оператора — для support'а. + public string? FiscalProviderTxId { get; set; } + + /// Какой оператор зарегистрировал этот чек. Снапшот: даже + /// если в настройках организации потом поменять провайдера, старые + /// чеки помнят, кто их регистрировал. + public int? FiscalProviderKind { get; set; } } public class RetailSaleLine : TenantEntity diff --git a/src/food-market.infrastructure/Fiscal/FiscalProviderFactory.cs b/src/food-market.infrastructure/Fiscal/FiscalProviderFactory.cs new file mode 100644 index 0000000..88aadf5 --- /dev/null +++ b/src/food-market.infrastructure/Fiscal/FiscalProviderFactory.cs @@ -0,0 +1,78 @@ +using foodmarket.Application.Common.Fiscal; +using foodmarket.Application.Common.Tenancy; +using foodmarket.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Infrastructure.Fiscal; + +/// Default-фабрика провайдеров: читает Organization.FiscalProvider +/// из БД и возвращает зарегистрированную в DI реализацию. Если в БД +/// None (или организация не найдена) — возвращает null, контроллер +/// расценивает это как «фискализацию не делаем, чек проводим как есть». +/// +/// Реализации провайдеров получены через IEnumerable<IFiscalProvider> — +/// в DI они регистрируются все одновременно, фабрика выбирает по +/// . Это позволяет добавлять новых +/// операторов одной строкой в Program.cs. +/// +/// Конфигурация Fiscal:Provider в appsettings — глобальный +/// override на уровне приложения. Если задан — он перебивает per-organization +/// настройку. Используется главным образом в integration-тестах +/// (форсим Mock для всех тестов, не трогая БД-настройку каждой созданной +/// орг). +public class FiscalProviderFactory : IFiscalProviderFactory +{ + private readonly ITenantContext _tenant; + private readonly AppDbContext _db; + private readonly IEnumerable _providers; + private readonly FiscalProviderKind? _globalOverride; + + public FiscalProviderFactory( + ITenantContext tenant, + AppDbContext db, + IEnumerable providers, + Microsoft.Extensions.Configuration.IConfiguration cfg) + { + _tenant = tenant; + _db = db; + _providers = providers; + // Парсим один раз в конструкторе. Невалидные значения трактуем как + // «override не задан» — лучше упасть на старте если опечатка, но + // мы намеренно мягко относимся к чужим config'ам. + var raw = cfg["Fiscal:Provider"]; + if (Enum.TryParse(raw, ignoreCase: true, out var parsed)) + _globalOverride = parsed; + } + + public async Task ResolveAsync(CancellationToken ct = default) + { + FiscalProviderKind kind; + if (_globalOverride is { } global) + { + kind = global; + } + else + { + var orgId = _tenant.OrganizationId; + if (orgId is null) return null; + var raw = await _db.Organizations.IgnoreQueryFilters().AsNoTracking() + .Where(o => o.Id == orgId.Value) + .Select(o => o.FiscalProvider) + .FirstOrDefaultAsync(ct); + kind = (FiscalProviderKind)raw; + } + + if (kind == FiscalProviderKind.None) return null; + + var provider = _providers.FirstOrDefault(p => p.Kind == kind); + if (provider is null) + { + // Включена реализация, но не зарегистрирована в DI — это конфиг-баг, + // не runtime. Лучше упасть громко чем тихо «забыть» фискализировать. + throw new InvalidOperationException( + $"Fiscal-провайдер {kind} выбран в настройках, но не зарегистрирован в DI. " + + $"Проверьте Program.cs (секция Fiscal)."); + } + return provider; + } +} diff --git a/src/food-market.infrastructure/Fiscal/Kassa24Provider.cs b/src/food-market.infrastructure/Fiscal/Kassa24Provider.cs new file mode 100644 index 0000000..eee77df --- /dev/null +++ b/src/food-market.infrastructure/Fiscal/Kassa24Provider.cs @@ -0,0 +1,98 @@ +using foodmarket.Application.Common.Fiscal; +using foodmarket.Domain.Sales; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace foodmarket.Infrastructure.Fiscal; + +/// Касса24 (https://kassa24.kz) — облачная касса от Kaspi-группы. +/// Тесно интегрирована с QR-эквайрингом Kaspi Pay (платёж и фискализация +/// проходят одним flow). +/// +/// Skeleton, TODO: публичная документация Касса24 на момент +/// scaffolding'а недоступна (NDA-only после подписания договора). Поэтому +/// этот файл — каркас с тем же контрактом, что Webkassa, но реальный POST +/// заменён на throw new FiscalNotConfiguredException: пока user +/// не получит ApiKey + спецификацию endpoints, провайдер просит +/// переключиться на Mock или Webkassa. +/// +/// Когда документация появится, нужно реализовать: +/// +/// Аутентификацию (предположительно — HMAC-SHA256 подпись запроса +/// ApiSecret'ом, как у Kaspi merchant API). +/// POST /v1/check (рабочее название — уточнить в доке). +/// Маппинг RetailSale.Lines → их формат позиций. +/// Парсинг ответа: fiscalNumber, qrCode, ticketUrl, transactionId. +/// +public class Kassa24Provider : IFiscalProvider +{ + private const string DefaultBaseUrl = "https://api.kassa24.kz/"; + + private readonly HttpClient _http; + private readonly IServiceScopeFactory _scopes; + private readonly IDataProtectionProvider _dpProvider; + private readonly ILogger _log; + + public Kassa24Provider( + HttpClient http, + IServiceScopeFactory scopes, + IDataProtectionProvider dpProvider, + ILogger log) + { + _http = http; + _scopes = scopes; + _dpProvider = dpProvider; + _log = log; + } + + public FiscalProviderKind Kind => FiscalProviderKind.Kassa24; + + public async Task RegisterAsync(RetailSale sale, CancellationToken ct) + { + // Проверка кредов — общий контракт со всеми провайдерами (даёт + // диагностику в UI до того, как мы дойдём до реального POST). + var (_, _) = await LoadCredentialsAsync(sale.OrganizationId, ct); + + // TODO(sprint11+): когда у нас будет ApiKey и доступ к Касса24-доке, + // заменить throw на реальный запрос. Пока провайдер выбран в UI + // только для демонстрации — фактическая фискализация не происходит. + await Task.Yield(); + _log.LogWarning( + "Касса24-провайдер выбран, но интеграция ещё не реализована. " + + "Чек {SaleNumber} не зарегистрирован у оператора.", sale.Number); + throw new FiscalNotConfiguredException( + "Касса24: интеграция ещё не реализована (нужны спецификации API от оператора). " + + "Используйте Mock для разработки или переключитесь на Webkassa."); + } + + private async Task<(string ApiKey, string ApiSecret)> LoadCredentialsAsync( + Guid organizationId, CancellationToken ct) + { + using var scope = _scopes.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var org = await db.Organizations.IgnoreQueryFilters().AsNoTracking() + .FirstOrDefaultAsync(o => o.Id == organizationId, ct); + if (org is null) + throw new FiscalProviderException($"Касса24: организация {organizationId} не найдена."); + if (string.IsNullOrEmpty(org.FiscalApiKeyEncrypted) || + string.IsNullOrEmpty(org.FiscalApiSecretEncrypted)) + { + throw new FiscalNotConfiguredException( + "Касса24: ApiKey/ApiSecret не заданы. Откройте «Настройки организации → ОФД» и заполните их."); + } + var protector = _dpProvider.CreateProtector("foodmarket.fiscal"); + try + { + return (protector.Unprotect(org.FiscalApiKeyEncrypted), + protector.Unprotect(org.FiscalApiSecretEncrypted)); + } + catch (Exception ex) + { + throw new FiscalNotConfiguredException( + "Не удалось расшифровать креды Касса24. Введите ApiKey/ApiSecret заново. Detail: " + ex.Message); + } + } +} diff --git a/src/food-market.infrastructure/Fiscal/MockFiscalProvider.cs b/src/food-market.infrastructure/Fiscal/MockFiscalProvider.cs new file mode 100644 index 0000000..2ac9cba --- /dev/null +++ b/src/food-market.infrastructure/Fiscal/MockFiscalProvider.cs @@ -0,0 +1,59 @@ +using foodmarket.Application.Common.Fiscal; +using foodmarket.Domain.Sales; +using Microsoft.Extensions.Logging; + +namespace foodmarket.Infrastructure.Fiscal; + +/// Mock-ОФД для разработки и интеграционных тестов. Имитирует +/// сетевую задержку (~300мс — типичный latency RPC до ОФД-облака), затем +/// возвращает детерминированный фейк, привязанный к : +/// тот же чек двумя вызовами даёт тот же FiscalNumber (идемпотентность). +/// +/// Полезно: +/// интеграционный тест «RetailSale.Post с Provider=Mock сохраняет +/// FiscalNumber=MOCK-…» (см. FiscalMockTests). +/// демо-стэйдж — UI рендерит QR/номер на квитанции без реального +/// аккаунта ОФД. +/// отладка пути «фискализация-провалилась» — заменяется тестовой +/// реализацией, которая бросает исключение. +/// +public class MockFiscalProvider : IFiscalProvider +{ + private readonly ILogger _log; + + /// Сколько ждать перед ответом. По умолчанию 300мс — близко к + /// реальной задержке Webkassa из РК-каналов. В тестах переопределяется + /// (через DI-фабрику с TimeSpan.Zero), чтобы не тормозить прогон. + public TimeSpan SimulatedLatency { get; set; } = TimeSpan.FromMilliseconds(300); + + public MockFiscalProvider(ILogger log) + { + _log = log; + } + + public FiscalProviderKind Kind => FiscalProviderKind.Mock; + + public async Task RegisterAsync(RetailSale sale, CancellationToken ct) + { + if (SimulatedLatency > TimeSpan.Zero) + await Task.Delay(SimulatedLatency, ct); + + // Детерминированно: берём первые 8 символов Id чека → стабильный + // фискальный номер. Так integration-тест может assert'ить точное + // значение без флакки. + var stamp = sale.Id.ToString("N").Substring(0, 8).ToUpperInvariant(); + var fiscalNumber = $"MOCK-{stamp}"; + var checkUrl = $"https://mock.ofd.local/check/{sale.Id:N}"; + var qr = checkUrl + "?n=" + Uri.EscapeDataString(fiscalNumber); + + _log.LogInformation( + "MockFiscalProvider зарегистрировал чек {SaleNumber} → {FiscalNumber}", + sale.Number, fiscalNumber); + + return new FiscalResult( + FiscalNumber: fiscalNumber, + FiscalQrCode: qr, + FiscalUrl: checkUrl, + ProviderTxId: "mock-tx-" + sale.Id.ToString("N").Substring(0, 12)); + } +} diff --git a/src/food-market.infrastructure/Fiscal/OfdSoloProvider.cs b/src/food-market.infrastructure/Fiscal/OfdSoloProvider.cs new file mode 100644 index 0000000..f974569 --- /dev/null +++ b/src/food-market.infrastructure/Fiscal/OfdSoloProvider.cs @@ -0,0 +1,89 @@ +using foodmarket.Application.Common.Fiscal; +using foodmarket.Domain.Sales; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace foodmarket.Infrastructure.Fiscal; + +/// ОФД-Соло (https://ofd-solo.kz) — третий по распространённости +/// ОФД-оператор РК. API на https://api.ofd-solo.kz/ (требует подписания +/// контракта для доступа к спецификации). +/// +/// Skeleton, TODO: аналогично Касса24, реальная интеграция +/// ждёт получения ApiKey и спецификации. Контракт повторяет Webkassa — +/// когда документация будет, основная работа сведётся к маппингу JSON-полей. +/// +/// Особенности ОФД-Соло (из публичных источников): +/// +/// SOAP-based legacy + REST-обёртка (REST моложе, рекомендуется). +/// Аутентификация по token-логину (как Webkassa). +/// Чек регистрируется одним вызовом, без двухшагового создания/post'а. +/// +public class OfdSoloProvider : IFiscalProvider +{ + private const string DefaultBaseUrl = "https://api.ofd-solo.kz/"; + + private readonly HttpClient _http; + private readonly IServiceScopeFactory _scopes; + private readonly IDataProtectionProvider _dpProvider; + private readonly ILogger _log; + + public OfdSoloProvider( + HttpClient http, + IServiceScopeFactory scopes, + IDataProtectionProvider dpProvider, + ILogger log) + { + _http = http; + _scopes = scopes; + _dpProvider = dpProvider; + _log = log; + } + + public FiscalProviderKind Kind => FiscalProviderKind.OfdSolo; + + public async Task RegisterAsync(RetailSale sale, CancellationToken ct) + { + var (_, _) = await LoadCredentialsAsync(sale.OrganizationId, ct); + + // TODO(sprint11+): реализовать после получения спецификации API. + await Task.Yield(); + _log.LogWarning( + "ОФД-Соло-провайдер выбран, но интеграция ещё не реализована. " + + "Чек {SaleNumber} не зарегистрирован у оператора.", sale.Number); + throw new FiscalNotConfiguredException( + "ОФД-Соло: интеграция ещё не реализована (нужны спецификации API от оператора). " + + "Используйте Mock для разработки или переключитесь на Webkassa."); + } + + private async Task<(string ApiKey, string ApiSecret)> LoadCredentialsAsync( + Guid organizationId, CancellationToken ct) + { + using var scope = _scopes.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var org = await db.Organizations.IgnoreQueryFilters().AsNoTracking() + .FirstOrDefaultAsync(o => o.Id == organizationId, ct); + if (org is null) + throw new FiscalProviderException($"ОФД-Соло: организация {organizationId} не найдена."); + if (string.IsNullOrEmpty(org.FiscalApiKeyEncrypted) || + string.IsNullOrEmpty(org.FiscalApiSecretEncrypted)) + { + throw new FiscalNotConfiguredException( + "ОФД-Соло: ApiKey/ApiSecret не заданы. Откройте «Настройки организации → ОФД» и заполните их."); + } + var protector = _dpProvider.CreateProtector("foodmarket.fiscal"); + try + { + return (protector.Unprotect(org.FiscalApiKeyEncrypted), + protector.Unprotect(org.FiscalApiSecretEncrypted)); + } + catch (Exception ex) + { + throw new FiscalNotConfiguredException( + "Не удалось расшифровать креды ОФД-Соло. Введите ApiKey/ApiSecret заново. Detail: " + ex.Message); + } + } +} diff --git a/src/food-market.infrastructure/Fiscal/WebkassaProvider.cs b/src/food-market.infrastructure/Fiscal/WebkassaProvider.cs new file mode 100644 index 0000000..de29eb2 --- /dev/null +++ b/src/food-market.infrastructure/Fiscal/WebkassaProvider.cs @@ -0,0 +1,291 @@ +using System.Net.Http.Json; +using System.Text.Json; +using foodmarket.Application.Common.Fiscal; +using foodmarket.Domain.Sales; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace foodmarket.Infrastructure.Fiscal; + +/// Webkassa (https://webkassa.kz) — крупнейший ОФД-оператор РК. +/// REST-API на https://api.webkassa.kz/, документация: +/// https://app.swaggerhub.com/apis-docs/webkassa/oblachnaya-kassa/1.0.0. +/// +/// Этот провайдер — skeleton: реализован полный путь сериализации, +/// HTTP-запроса и парсинга ответа, но без живого аккаунта тестировать +/// можно только моком HttpMessageHandler'а (см. WebkassaProviderTests). +/// Когда user заведёт реальный кабинет и впишет ApiKey+CashboxNumber в +/// настройках организации — провайдер заработает без правок кода. +/// +/// Поток вызова: POST /api/Authorize для получения Token → +/// POST /api/Check с этим токеном и payload'ом чека. Токен короткоживущий +/// (TTL ~3 часа); кешируем in-memory на инстанс scope'а (одна продажа = один +/// scope = один токен, без кеша между запросами — простота важнее микро-perf). +/// +/// Идемпотентность: Webkassa умеет дедупить по своему полю +/// ExternalCheckNumber (мы передаём ). +/// Повторный POST с тем же номером возвращает оригинальный чек, не создавая +/// дубль — это и обеспечивает «retry safe» поведение в нашем контроллере. +public class WebkassaProvider : IFiscalProvider +{ + private const string DefaultBaseUrl = "https://api.webkassa.kz/"; + + private readonly HttpClient _http; + private readonly IServiceScopeFactory _scopes; + private readonly IDataProtectionProvider _dpProvider; + private readonly ILogger _log; + + public WebkassaProvider( + HttpClient http, + IServiceScopeFactory scopes, + IDataProtectionProvider dpProvider, + ILogger log) + { + _http = http; + _scopes = scopes; + _dpProvider = dpProvider; + _log = log; + } + + public FiscalProviderKind Kind => FiscalProviderKind.Webkassa; + + public async Task RegisterAsync(RetailSale sale, CancellationToken ct) + { + var cfg = await LoadConfigAsync(sale.OrganizationId, ct); + + var token = await AuthorizeAsync(cfg, ct); + var payload = BuildCheckPayload(sale, cfg.CashboxNumber!, token); + + var baseUrl = cfg.BaseUrl ?? DefaultBaseUrl; + var uri = new Uri(new Uri(baseUrl), "api/Check"); + + using var resp = await _http.PostAsJsonAsync(uri, payload, ct); + var body = await resp.Content.ReadAsStringAsync(ct); + if (!resp.IsSuccessStatusCode) + { + _log.LogError( + "Webkassa Check вернул {Status} для чека {SaleNumber}: {Body}", + (int)resp.StatusCode, sale.Number, body); + throw new FiscalProviderException( + $"Webkassa ответил {(int)resp.StatusCode} на регистрацию чека {sale.Number}: {Truncate(body)}"); + } + + WebkassaCheckResponse? parsed; + try + { + parsed = JsonSerializer.Deserialize(body, JsonOpts); + } + catch (Exception ex) + { + throw new FiscalProviderException("Webkassa: не удалось разобрать ответ JSON.", ex); + } + if (parsed?.Data is null || string.IsNullOrEmpty(parsed.Data.CheckNumber)) + { + var err = parsed?.Errors?.FirstOrDefault()?.Text ?? body; + throw new FiscalProviderException("Webkassa: пустой ответ или ошибка. " + Truncate(err)); + } + + var d = parsed.Data; + var qr = d.QrCode ?? d.TicketUrl ?? ""; + return new FiscalResult( + FiscalNumber: d.CheckNumber, + FiscalQrCode: qr, + FiscalUrl: d.TicketUrl ?? qr, + ProviderTxId: d.UniqueNumber); + } + + // ── Token (Authorize) ───────────────────────────────────────────────── + + private async Task AuthorizeAsync(WebkassaConfig cfg, CancellationToken ct) + { + // Webkassa API key играет роль логина (в их кабинете — Login+Password + // на админ-пользователя кассы). Мы храним обе строки в ApiKey/ApiSecret. + var authPayload = new { Login = cfg.ApiKey, Password = cfg.ApiSecret }; + var baseUrl = cfg.BaseUrl ?? DefaultBaseUrl; + var uri = new Uri(new Uri(baseUrl), "api/Authorize"); + using var resp = await _http.PostAsJsonAsync(uri, authPayload, ct); + var body = await resp.Content.ReadAsStringAsync(ct); + if (!resp.IsSuccessStatusCode) + { + throw new FiscalProviderException( + $"Webkassa Authorize вернул {(int)resp.StatusCode}: {Truncate(body)}"); + } + WebkassaAuthResponse? auth; + try { auth = JsonSerializer.Deserialize(body, JsonOpts); } + catch (Exception ex) { throw new FiscalProviderException("Webkassa Authorize: невалидный JSON.", ex); } + var token = auth?.Data?.Token; + if (string.IsNullOrEmpty(token)) + { + var err = auth?.Errors?.FirstOrDefault()?.Text ?? body; + throw new FiscalProviderException("Webkassa: токен не получен. " + Truncate(err)); + } + return token; + } + + // ── Payload builder ─────────────────────────────────────────────────── + + /// Собирает payload для POST /api/Check. Минимальный набор: + /// CashboxUniqueNumber, OperationType (1=продажа, 2=возврат), Positions[], + /// Payments[]. Webkassa требует чтобы сумма Payments совпадала с суммой + /// Positions с точностью до копейки; контроллер проверяет это ранее. + /// public — чтобы юнит-тесты могли проверить маппинг без HTTP. + public static WebkassaCheckRequest BuildCheckPayload( + RetailSale sale, string cashboxNumber, string token) + { + var positions = sale.Lines.Select(l => new WebkassaPosition + { + PositionName = l.Product?.Name ?? "Товар", + Count = l.Quantity, + Price = l.UnitPrice, + Discount = l.Discount, + TaxPercent = (int)l.VatPercent, + // Tax (сумма НДС) считаем «в-ставке»: LineTotal * (vat / (100+vat)). + // Webkassa требует именно «налог в составе», а не «налог сверху». + Tax = decimal.Round( + l.LineTotal * l.VatPercent / (100m + (l.VatPercent == 0 ? 1m : l.VatPercent)), + 2), + UnitType = 0, // штука — для еды дефолт; справочник единиц у Webkassa отдельный + }).ToList(); + + var payments = new List(); + if (sale.PaidCash > 0m) + payments.Add(new WebkassaPayment { Sum = sale.PaidCash, PaymentType = 0 }); + if (sale.PaidCard > 0m) + payments.Add(new WebkassaPayment { Sum = sale.PaidCard, PaymentType = 1 }); + // На случай Total>0 при PaidCash=PaidCard=0 (например, оплата бонусами): + // Webkassa требует хотя бы один Payment. + if (payments.Count == 0) + payments.Add(new WebkassaPayment { Sum = sale.Total, PaymentType = 0 }); + + return new WebkassaCheckRequest + { + Token = token, + CashboxUniqueNumber = cashboxNumber, + OperationType = sale.IsReturn ? 2 : 1, + ExternalCheckNumber = sale.Number, + RoundType = 0, // не округлять + Positions = positions, + Payments = payments, + }; + } + + // ── Config (per-organization) ───────────────────────────────────────── + + private async Task LoadConfigAsync(Guid organizationId, CancellationToken ct) + { + using var scope = _scopes.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + // IgnoreQueryFilters: ApplyMovement-окружение может не иметь tenant'а + // (например, ретрай из background-джоба) — тянем по orgId явно. + var org = await db.Organizations.IgnoreQueryFilters() + .AsNoTracking() + .FirstOrDefaultAsync(o => o.Id == organizationId, ct); + if (org is null) + throw new FiscalProviderException($"Webkassa: организация {organizationId} не найдена."); + + if (string.IsNullOrEmpty(org.FiscalApiKeyEncrypted) || + string.IsNullOrEmpty(org.FiscalApiSecretEncrypted)) + { + throw new FiscalNotConfiguredException( + "Webkassa: ApiKey/ApiSecret не заданы. Откройте «Настройки организации → ОФД» и заполните логин+пароль от Webkassa, либо переключите провайдер на «Без фискализации»."); + } + if (string.IsNullOrEmpty(org.FiscalCashboxUniqueNumber)) + { + throw new FiscalNotConfiguredException( + "Webkassa: не задан CashboxUniqueNumber. Найти его можно в кабинете Webkassa (Настройки кассы → Уникальный номер)."); + } + + var protector = _dpProvider.CreateProtector("foodmarket.fiscal"); + string apiKey, apiSecret; + try + { + apiKey = protector.Unprotect(org.FiscalApiKeyEncrypted); + apiSecret = protector.Unprotect(org.FiscalApiSecretEncrypted); + } + catch (Exception ex) + { + throw new FiscalNotConfiguredException( + "Не удалось расшифровать креды Webkassa (DataProtection ключ изменился?). " + + "Введите ApiKey/ApiSecret заново в настройках организации. Detail: " + ex.Message); + } + + return new WebkassaConfig(apiKey, apiSecret, org.FiscalCashboxUniqueNumber, org.FiscalApiBaseUrl); + } + + // ── Utility ─────────────────────────────────────────────────────────── + + private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); + + private static string Truncate(string s, int max = 500) + => s.Length <= max ? s : s.Substring(0, max) + "…"; + + // ── DTOs ────────────────────────────────────────────────────────────── + + private sealed record WebkassaConfig(string ApiKey, string ApiSecret, + string CashboxNumber, string? BaseUrl); + + public class WebkassaCheckRequest + { + public string Token { get; set; } = ""; + public string CashboxUniqueNumber { get; set; } = ""; + /// 1=продажа, 2=возврат продажи. + public int OperationType { get; set; } + public string ExternalCheckNumber { get; set; } = ""; + public int RoundType { get; set; } + public List Positions { get; set; } = new(); + public List Payments { get; set; } = new(); + } + + public class WebkassaPosition + { + public string PositionName { get; set; } = ""; + public decimal Count { get; set; } + public decimal Price { get; set; } + public decimal Discount { get; set; } + public int TaxPercent { get; set; } + public decimal Tax { get; set; } + public int UnitType { get; set; } + } + + public class WebkassaPayment + { + public decimal Sum { get; set; } + /// 0=наличные, 1=карта, 2=мобильные деньги, 4=кредит. + public int PaymentType { get; set; } + } + + public class WebkassaAuthResponse + { + public WebkassaAuthData? Data { get; set; } + public List? Errors { get; set; } + } + public class WebkassaAuthData + { + public string? Token { get; set; } + } + + public class WebkassaCheckResponse + { + public WebkassaCheckData? Data { get; set; } + public List? Errors { get; set; } + } + public class WebkassaCheckData + { + /// Фискальный номер чека (печатается на квитанции). + public string? CheckNumber { get; set; } + /// Уникальный идентификатор операции у Webkassa. + public string? UniqueNumber { get; set; } + /// URL для рендера QR-кода (содержит ссылку на проверку чека). + public string? QrCode { get; set; } + /// Прямая ссылка на чек в кабинете налоговой. + public string? TicketUrl { get; set; } + } + public class WebkassaError + { + public int Code { get; set; } + public string? Text { get; set; } + } +} diff --git a/src/food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs b/src/food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs index 0927578..0934ca4 100644 --- a/src/food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs +++ b/src/food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs @@ -170,6 +170,12 @@ public static void ConfigureSales(this ModelBuilder b) e.Property(x => x.LoyaltyPointsAccrued).HasPrecision(18, 4); e.Property(x => x.PromotionDiscount).HasPrecision(18, 4); e.Property(x => x.PromotionCode).HasMaxLength(40); + + // Sprint 11: фискальные снапшоты от ОФД-оператора. + e.Property(x => x.FiscalNumber).HasMaxLength(100); + e.Property(x => x.FiscalQrCode).HasMaxLength(2000); + e.Property(x => x.FiscalUrl).HasMaxLength(2000); + e.Property(x => x.FiscalProviderTxId).HasMaxLength(200); }); } } diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260607100000_Phase11a_FiscalScaffolding.cs b/src/food-market.infrastructure/Persistence/Migrations/20260607100000_Phase11a_FiscalScaffolding.cs new file mode 100644 index 0000000..69340d3 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260607100000_Phase11a_FiscalScaffolding.cs @@ -0,0 +1,118 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Phase11a — ОФД-scaffolding (фискализация чеков для РК). + /// + /// retail_sales: пять снапшот-колонок, заполняются после + /// успешной регистрации чека у оператора (Webkassa/Касса24/ОФД-Соло/Mock). + /// Все nullable — старые чеки и чеки без фискализации остаются + /// валидными (NULL = провайдер None или зарегистрировать не удалось). + /// + /// organizations: пять колонок per-tenant конфига ОФД. ApiKey/Secret + /// шифруются через DataProtection (purpose="foodmarket.fiscal") — + /// в БД лежат base64 protected blob'ы, в API не возвращаются. + /// + /// Дефолтное значение FiscalProvider=0 (None) — поведение API + /// «как до Sprint 11»: чеки не фискализируются, FiscalNumber пустой. + /// Включение требует явного выбора провайдера + ввода кредов в UI. + [DbContext(typeof(AppDbContext))] + [Migration("20260607100000_Phase11a_FiscalScaffolding")] + public partial class Phase11a_FiscalScaffolding : Migration + { + protected override void Up(MigrationBuilder b) + { + // ── retail_sales: фискальные снапшоты ─────────────────────────── + b.AddColumn( + name: "FiscalNumber", + schema: "public", + table: "retail_sales", + type: "character varying(100)", + maxLength: 100, + nullable: true); + b.AddColumn( + name: "FiscalQrCode", + schema: "public", + table: "retail_sales", + type: "character varying(2000)", + maxLength: 2000, + nullable: true); + b.AddColumn( + name: "FiscalUrl", + schema: "public", + table: "retail_sales", + type: "character varying(2000)", + maxLength: 2000, + nullable: true); + b.AddColumn( + name: "FiscalProviderTxId", + schema: "public", + table: "retail_sales", + type: "character varying(200)", + maxLength: 200, + nullable: true); + b.AddColumn( + name: "FiscalProviderKind", + schema: "public", + table: "retail_sales", + type: "integer", + nullable: true); + + // ── organizations: конфиг ОФД per-tenant ──────────────────────── + // FiscalProvider — NOT NULL c default 0 (None). Это критично: + // у уже существующих организаций колонка появится со значением 0 + // и поведение не изменится. Default снимать не нужно — он + // эквивалентен enum-дефолту C#. + b.AddColumn( + name: "FiscalProvider", + schema: "public", + table: "organizations", + type: "integer", + nullable: false, + defaultValue: 0); + b.AddColumn( + name: "FiscalApiKeyEncrypted", + schema: "public", + table: "organizations", + type: "text", + nullable: true); + b.AddColumn( + name: "FiscalApiSecretEncrypted", + schema: "public", + table: "organizations", + type: "text", + nullable: true); + b.AddColumn( + name: "FiscalCashboxUniqueNumber", + schema: "public", + table: "organizations", + type: "text", + nullable: true); + b.AddColumn( + name: "FiscalApiBaseUrl", + schema: "public", + table: "organizations", + type: "text", + nullable: true); + } + + protected override void Down(MigrationBuilder b) + { + b.DropColumn(name: "FiscalApiBaseUrl", schema: "public", table: "organizations"); + b.DropColumn(name: "FiscalCashboxUniqueNumber", schema: "public", table: "organizations"); + b.DropColumn(name: "FiscalApiSecretEncrypted", schema: "public", table: "organizations"); + b.DropColumn(name: "FiscalApiKeyEncrypted", schema: "public", table: "organizations"); + b.DropColumn(name: "FiscalProvider", schema: "public", table: "organizations"); + + b.DropColumn(name: "FiscalProviderKind", schema: "public", table: "retail_sales"); + b.DropColumn(name: "FiscalProviderTxId", schema: "public", table: "retail_sales"); + b.DropColumn(name: "FiscalUrl", schema: "public", table: "retail_sales"); + b.DropColumn(name: "FiscalQrCode", schema: "public", table: "retail_sales"); + b.DropColumn(name: "FiscalNumber", schema: "public", table: "retail_sales"); + } + } +} diff --git a/src/food-market.web/src/pages/OrganizationSettingsPage.tsx b/src/food-market.web/src/pages/OrganizationSettingsPage.tsx index 7a1f3e7..9a6fbcc 100644 --- a/src/food-market.web/src/pages/OrganizationSettingsPage.tsx +++ b/src/food-market.web/src/pages/OrganizationSettingsPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { Save, Sparkles } from 'lucide-react' +import { Save, Sparkles, Receipt, AlertTriangle, CheckCircle2, Send } from 'lucide-react' import { api } from '@/lib/api' import { PageHeader } from '@/components/PageHeader' import { Button } from '@/components/Button' @@ -205,6 +205,7 @@ export function OrganizationSettingsPage() { {save.error && {(save.error as Error).message}} + @@ -380,3 +381,220 @@ function DemoSeedSection() { ) } + +interface FiscalSettingsDto { + provider: number + providerName: string + hasApiKey: boolean + hasApiSecret: boolean + cashboxUniqueNumber: string | null + apiBaseUrl: string | null +} +interface FiscalProviderOption { value: number; name: string; description: string } +interface FiscalTestResponse { + ok: boolean + message: string | null + fiscalNumber: string | null + fiscalQrCode: string | null + fiscalUrl: string | null +} + +/** + * ОФД-фискализация: выбор провайдера (Webkassa / Касса24 / ОФД-Соло / + * Mock / без фискализации) + ключи. Креды шифруются на сервере через + * DataProtection (purpose=`foodmarket.fiscal`), в GET возвращаются только + * has-* флаги. Чтобы СНЯТЬ креды — отправляем спец-значение «__clear__». + * Кнопка «Тестовая отправка» создаёт фейк-чек и зовёт провайдера для + * проверки что креды корректны (без сохранения в БД). + */ +function FiscalSection() { + const qc = useQueryClient() + const current = useQuery({ + queryKey: ['/api/organization/fiscal'], + queryFn: async () => (await api.get('/api/organization/fiscal')).data, + }) + const providers = useQuery({ + queryKey: ['/api/organization/fiscal/providers'], + queryFn: async () => (await api.get('/api/organization/fiscal/providers')).data, + }) + + const [form, setForm] = useState<{ + provider: number + newApiKey: string + newApiSecret: string + cashboxUniqueNumber: string + apiBaseUrl: string + } | null>(null) + + useEffect(() => { + if (current.data && !form) { + setForm({ + provider: current.data.provider, + newApiKey: '', + newApiSecret: '', + cashboxUniqueNumber: current.data.cashboxUniqueNumber ?? '', + apiBaseUrl: current.data.apiBaseUrl ?? '', + }) + } + }, [current.data, form]) + + const save = useMutation({ + mutationFn: async () => { + if (!form) return + return (await api.put('/api/organization/fiscal', { + provider: form.provider, + newApiKey: form.newApiKey || null, + newApiSecret: form.newApiSecret || null, + cashboxUniqueNumber: form.cashboxUniqueNumber || null, + apiBaseUrl: form.apiBaseUrl || null, + })).data + }, + onSuccess: (d) => { + if (d && form) setForm({ ...form, newApiKey: '', newApiSecret: '' }) + qc.invalidateQueries({ queryKey: ['/api/organization/fiscal'] }) + }, + meta: { successMessage: 'Настройки ОФД сохранены' }, + }) + + const testSend = useMutation({ + mutationFn: async () => (await api.post('/api/organization/fiscal/test-send', {})).data, + }) + + if (!form) { + return ( +
+

+ ОФД (фискализация чеков) +

+

Загружаю настройки…

+
+ ) + } + + const isNone = form.provider === 0 + const isMock = form.provider === 1 + const needsCreds = !isNone && !isMock + + return ( +
+

+ ОФД (фискализация чеков) +

+

+ Передавать ли чеки оператору фискальных данных (Webkassa / Касса24 / ОФД-Соло). + Креды хранятся per-tenant и шифруются на сервере. Если фискализация + не нужна — оставьте «Без фискализации» (поведение по умолчанию). +

+ +
+ + + {providers.data?.find((p) => p.value === form.provider)?.description && ( +

+ {providers.data.find((p) => p.value === form.provider)?.description} +

+ )} +
+ {needsCreds && ( + + setForm({ ...form, cashboxUniqueNumber: e.target.value })} + placeholder="например, SWK00000001" + /> + + )} +
+ + {needsCreds && ( +
+ + setForm({ ...form, newApiKey: e.target.value })} + placeholder={current.data?.hasApiKey ? '•••••••• (без изменений)' : 'Введите ApiKey оператора'} + /> +

+ {current.data?.hasApiKey ? 'Сохранён, шифрован DataProtection. Оставьте поле пустым — ключ не изменится.' : 'Не задан.'} +

+
+ + setForm({ ...form, newApiSecret: e.target.value })} + placeholder={current.data?.hasApiSecret ? '•••••••• (без изменений)' : 'Введите ApiSecret оператора'} + /> +

+ {current.data?.hasApiSecret ? 'Сохранён, шифрован DataProtection. Оставьте поле пустым — пароль не изменится.' : 'Не задан.'} +

+
+
+ )} + + {needsCreds && ( +
+ + setForm({ ...form, apiBaseUrl: e.target.value })} + placeholder="оставьте пустым для production" + /> +

+ По умолчанию используется боевой URL оператора. Заполняй только если тебе выдали тестовый контур. +

+
+
+ )} + +
+ + {!isNone && ( + + )} + {save.isSuccess && Сохранено} +
+ + {testSend.data && ( +
+ {testSend.data.ok + ? + : } +
+
{testSend.data.message}
+ {testSend.data.ok && testSend.data.fiscalNumber && ( +
+ FiscalNumber: {testSend.data.fiscalNumber} + {testSend.data.fiscalUrl && ( + <> · проверить чек + )} +
+ )} +
+
+ )} +
+ ) +} diff --git a/tests/food-market.IntegrationTests/FiscalMockFlowTests.cs b/tests/food-market.IntegrationTests/FiscalMockFlowTests.cs new file mode 100644 index 0000000..ce9fc04 --- /dev/null +++ b/tests/food-market.IntegrationTests/FiscalMockFlowTests.cs @@ -0,0 +1,174 @@ +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using foodmarket.IntegrationTests.Support; +using Xunit; + +namespace foodmarket.IntegrationTests; + +/// End-to-end проверка фискализации через MockFiscalProvider: +/// в новой организации PUT /api/organization/fiscal с provider=1 (Mock), +/// создаём чек, проводим POST → ожидаем что в ответе и в последующем GET +/// FiscalNumber начинается с MOCK-. +/// +/// Используем общий — это критично, т.к. +/// xUnit в одном процессе не любит нескольких WebApplicationFactory<Program> +/// инстансов одновременно (бросает «entry point exited without ever +/// building an IHost»). Mock-провайдер активируется per-org через БД, +/// а не глобальным config-override'ом. +[Collection(ApiCollection.Name)] +public class FiscalMockFlowTests +{ + private readonly ApiFactory _factory; + public FiscalMockFlowTests(ApiFactory factory) => _factory = factory; + + [Fact] + public async Task Posting_retail_sale_with_mock_provider_sets_fiscal_number() + { + var actor = new ApiActor(_factory.CreateClient()); + await actor.SignupAndLoginAsync($"fiscal-{Guid.NewGuid():N}"); + + // Включаем Mock-провайдер на этой организации. + var putResp = await actor.Http.PutAsJsonAsync("/api/organization/fiscal", new + { + provider = 1, // Mock + newApiKey = (string?)null, + newApiSecret = (string?)null, + cashboxUniqueNumber = (string?)null, + apiBaseUrl = (string?)null, + }); + putResp.EnsureSuccessStatusCode(); + var fiscalCfg = await putResp.Content.ReadFromJsonAsync(); + fiscalCfg.GetProperty("provider").GetInt32().Should().Be(1); + fiscalCfg.GetProperty("providerName").GetString().Should().Contain("Mock"); + + // Сидим товар + приёмку, чтобы было что продавать. + var (productId, storeId, retailPointId, currencyId) = await SeedProductWithStockAsync(actor); + + // Создаём чек на 1000 ₸, paidCash=1000. + var draftResp = await actor.Http.PostAsJsonAsync("/api/sales/retail", new + { + date = DateTime.UtcNow, + storeId, retailPointId, currencyId, + payment = 0, isReturn = false, + lines = new[] { new { productId, quantity = 10m, unitPrice = 100m, discount = 0m, vatPercent = 12m } }, + subtotal = 1000m, discountTotal = 0m, total = 1000m, + paidCash = 1000m, paidCard = 0m, + }); + draftResp.EnsureSuccessStatusCode(); + var saleId = (await draftResp.Content.ReadFromJsonAsync()).GetProperty("id").GetString()!; + + // Post чек. + var postResp = await actor.Http.PostAsync($"/api/sales/retail/{saleId}/post", null); + postResp.EnsureSuccessStatusCode(); + + // Читаем чек обратно — FiscalNumber должен быть MOCK-… + var getResp = await actor.Http.GetAsync($"/api/sales/retail/{saleId}"); + getResp.EnsureSuccessStatusCode(); + var sale = await getResp.Content.ReadFromJsonAsync(); + + sale.GetProperty("fiscalNumber").GetString().Should().StartWith("MOCK-", + "MockFiscalProvider должен был отработать на post'е и сохранить FiscalNumber"); + sale.GetProperty("fiscalQrCode").GetString().Should().NotBeNullOrEmpty(); + sale.GetProperty("fiscalUrl").GetString().Should().StartWith("https://mock.ofd.local/"); + sale.GetProperty("fiscalProviderKind").GetInt32().Should().Be(1, "FiscalProviderKind.Mock = 1"); + } + + [Fact] + public async Task Test_send_with_mock_provider_returns_ok_and_fiscal_number() + { + var actor = new ApiActor(_factory.CreateClient()); + await actor.SignupAndLoginAsync($"fiscal-test-{Guid.NewGuid():N}"); + + // Выбираем Mock. + (await actor.Http.PutAsJsonAsync("/api/organization/fiscal", new + { + provider = 1, newApiKey = (string?)null, newApiSecret = (string?)null, + cashboxUniqueNumber = (string?)null, apiBaseUrl = (string?)null, + })).EnsureSuccessStatusCode(); + + var resp = await actor.Http.PostAsJsonAsync("/api/organization/fiscal/test-send", new { }); + resp.EnsureSuccessStatusCode(); + var body = await resp.Content.ReadFromJsonAsync(); + body.GetProperty("ok").GetBoolean().Should().BeTrue(); + body.GetProperty("fiscalNumber").GetString().Should().StartWith("MOCK-"); + } + + [Fact] + public async Task Default_provider_none_does_not_fiscalize() + { + // Без явной установки FiscalProvider он остаётся 0 (None) — поведение + // обратно-совместимое: чек посту, но FiscalNumber пустой. + var actor = new ApiActor(_factory.CreateClient()); + await actor.SignupAndLoginAsync($"fiscal-none-{Guid.NewGuid():N}"); + + var (productId, storeId, retailPointId, currencyId) = await SeedProductWithStockAsync(actor); + + var draft = await actor.Http.PostAsJsonAsync("/api/sales/retail", new + { + date = DateTime.UtcNow, storeId, retailPointId, currencyId, + payment = 0, isReturn = false, + lines = new[] { new { productId, quantity = 1m, unitPrice = 100m, discount = 0m, vatPercent = 0m } }, + subtotal = 100m, discountTotal = 0m, total = 100m, + paidCash = 100m, paidCard = 0m, + }); + var saleId = (await draft.Content.ReadFromJsonAsync()).GetProperty("id").GetString()!; + (await actor.Http.PostAsync($"/api/sales/retail/{saleId}/post", null)).EnsureSuccessStatusCode(); + + var sale = await actor.GetJsonAsync($"/api/sales/retail/{saleId}"); + // fiscalNumber == null означает «провайдер None / не дёрнули» + var fiscal = sale.GetProperty("fiscalNumber"); + (fiscal.ValueKind == JsonValueKind.Null || string.IsNullOrEmpty(fiscal.GetString())) + .Should().BeTrue("при Provider=None фискальный номер не записывается"); + } + + /// Аналог LoyaltyFlowTests.SeedProductAsync — копия чтобы не + /// тащить кросс-теcтовые helper'ы. Создаёт товар с приёмкой на 100 шт. + private static async Task<(string ProductId, string StoreId, string RetailPointId, string CurrencyId)> + SeedProductWithStockAsync(ApiActor actor) + { + var units = (await actor.GetJsonAsync("/api/catalog/units-of-measure?pageSize=200")) + .GetProperty("items").EnumerateArray().First(x => x.GetProperty("code").GetString() == "796"); + var groups = (await actor.GetJsonAsync("/api/catalog/product-groups")) + .GetProperty("items").EnumerateArray().First(); + var pts = (await actor.GetJsonAsync("/api/catalog/price-types")) + .GetProperty("items").EnumerateArray().First(x => x.GetProperty("isRetail").GetBoolean()); + var curs = (await actor.GetJsonAsync("/api/catalog/currencies")) + .GetProperty("items").EnumerateArray().First(x => x.GetProperty("code").GetString() == "KZT"); + var stores = (await actor.GetJsonAsync("/api/catalog/stores")) + .GetProperty("items").EnumerateArray().First(x => x.GetProperty("isMain").GetBoolean()); + var retailPoints = (await actor.GetJsonAsync("/api/catalog/retail-points")) + .GetProperty("items").EnumerateArray().First(); + + var prodResp = await actor.Http.PostAsJsonAsync("/api/catalog/products", new + { + name = "Fiscal test", article = $"FSC-{Guid.NewGuid():N}", + unitOfMeasureId = units.GetProperty("id").GetString(), + vat = 12, vatEnabled = true, + productGroupId = groups.GetProperty("id").GetString(), + packaging = 1, + prices = new[] { new { priceTypeId = pts.GetProperty("id").GetString(), amount = 100m, currencyId = curs.GetProperty("id").GetString() } }, + barcodes = new[] { new { code = $"700000{Guid.NewGuid().GetHashCode():X}".Replace("-", "").Substring(0, 12) + "0", type = 1, isPrimary = true } }, + }); + prodResp.EnsureSuccessStatusCode(); + var productId = (await prodResp.Content.ReadFromJsonAsync()).GetProperty("id").GetString()!; + + var supplier = await (await actor.Http.PostAsJsonAsync("/api/catalog/counterparties", + new { name = "Sup", type = 2 })).Content.ReadFromJsonAsync(); + var supply = await (await actor.Http.PostAsJsonAsync("/api/purchases/supplies", new + { + date = DateTime.UtcNow, + supplierId = supplier.GetProperty("id").GetString(), + storeId = stores.GetProperty("id").GetString(), + currencyId = curs.GetProperty("id").GetString(), + lines = new[] { new { productId, quantity = 100m, unitPrice = 50m } }, + })).Content.ReadFromJsonAsync(); + (await actor.Http.PostAsync($"/api/purchases/supplies/{supply.GetProperty("id").GetString()}/post", null)) + .EnsureSuccessStatusCode(); + + return (productId, + stores.GetProperty("id").GetString()!, + retailPoints.GetProperty("id").GetString()!, + curs.GetProperty("id").GetString()!); + } +} diff --git a/tests/food-market.UnitTests/FiscalMockProviderTests.cs b/tests/food-market.UnitTests/FiscalMockProviderTests.cs new file mode 100644 index 0000000..a1a0ecd --- /dev/null +++ b/tests/food-market.UnitTests/FiscalMockProviderTests.cs @@ -0,0 +1,76 @@ +using FluentAssertions; +using foodmarket.Application.Common.Fiscal; +using foodmarket.Domain.Sales; +using foodmarket.Infrastructure.Fiscal; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace foodmarket.UnitTests; + +/// Контракт MockFiscalProvider'а: возвращает префикс MOCK-, тот же +/// чек двумя вызовами даёт тот же FiscalNumber (идемпотентность), QR содержит +/// FiscalNumber, ProviderTxId не пуст. SimulatedLatency обнулена — тест +/// должен быть мгновенным. +public class FiscalMockProviderTests +{ + private static MockFiscalProvider New() => new(NullLogger.Instance) + { + SimulatedLatency = TimeSpan.Zero, + }; + + private static RetailSale SaleStub(Guid? id = null) => new() + { + Id = id ?? Guid.NewGuid(), + Number = "TST-000001", + OrganizationId = Guid.NewGuid(), + StoreId = Guid.NewGuid(), + CurrencyId = Guid.NewGuid(), + Total = 1000m, + }; + + [Fact] + public void Kind_is_Mock() + => New().Kind.Should().Be(FiscalProviderKind.Mock); + + [Fact] + public async Task Register_returns_mock_prefixed_fiscal_number() + { + var sale = SaleStub(); + var r = await New().RegisterAsync(sale, CancellationToken.None); + r.FiscalNumber.Should().StartWith("MOCK-").And.HaveLength("MOCK-".Length + 8); + r.FiscalQrCode.Should().Contain(r.FiscalNumber); + r.FiscalUrl.Should().StartWith("https://mock.ofd.local/check/"); + r.ProviderTxId.Should().NotBeNullOrEmpty().And.StartWith("mock-tx-"); + } + + [Fact] + public async Task Register_is_idempotent_per_sale_id() + { + var id = Guid.NewGuid(); + var r1 = await New().RegisterAsync(SaleStub(id), CancellationToken.None); + var r2 = await New().RegisterAsync(SaleStub(id), CancellationToken.None); + r1.FiscalNumber.Should().Be(r2.FiscalNumber); + r1.ProviderTxId.Should().Be(r2.ProviderTxId); + } + + [Fact] + public async Task Different_sales_get_different_fiscal_numbers() + { + var a = await New().RegisterAsync(SaleStub(), CancellationToken.None); + var b = await New().RegisterAsync(SaleStub(), CancellationToken.None); + a.FiscalNumber.Should().NotBe(b.FiscalNumber); + } + + [Fact] + public async Task Simulated_latency_actually_delays() + { + var prov = new MockFiscalProvider(NullLogger.Instance) + { + SimulatedLatency = TimeSpan.FromMilliseconds(150), + }; + var sw = System.Diagnostics.Stopwatch.StartNew(); + await prov.RegisterAsync(SaleStub(), CancellationToken.None); + sw.Stop(); + sw.ElapsedMilliseconds.Should().BeGreaterThanOrEqualTo(140); + } +} diff --git a/tests/food-market.UnitTests/WebkassaProviderTests.cs b/tests/food-market.UnitTests/WebkassaProviderTests.cs new file mode 100644 index 0000000..94acc91 --- /dev/null +++ b/tests/food-market.UnitTests/WebkassaProviderTests.cs @@ -0,0 +1,165 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using FluentAssertions; +using foodmarket.Application.Common.Fiscal; +using foodmarket.Domain.Catalog; +using foodmarket.Domain.Sales; +using foodmarket.Infrastructure.Fiscal; +using Xunit; + +namespace foodmarket.UnitTests; + +/// Webkassa-провайдер: чистые тесты на payload-builder (без HTTP) +/// плюс end-to-end тест внутреннего метода через MockHttpHandler. +/// +/// Реальный HTTP-flow (Authorize → Check) не покрываем сквозь полный +/// RegisterAsync — он зависит от per-organization кредов из БД (через +/// IServiceScopeFactory), что требует тестового DbContext'а. Для этого +/// есть integration-тест FiscalIntegrationTests с реальной БД. Здесь +/// проверяем чистую логику маппинга, которая не требует никакой инфры. +public class WebkassaProviderTests +{ + private static RetailSale SaleWith(decimal subtotal, decimal vat, bool isReturn = false) + { + var unit = new UnitOfMeasure { Id = Guid.NewGuid(), Name = "шт", Code = "796" }; + var p = new Product { Id = Guid.NewGuid(), Name = "Хлеб бородинский", UnitOfMeasureId = unit.Id }; + var sale = new RetailSale + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + Number = "ПР-000001", + Date = new DateTime(2026, 6, 7, 12, 0, 0, DateTimeKind.Utc), + StoreId = Guid.NewGuid(), + CurrencyId = Guid.NewGuid(), + Total = subtotal, + Subtotal = subtotal, + PaidCash = subtotal, + IsReturn = isReturn, + }; + sale.Lines.Add(new RetailSaleLine + { + ProductId = p.Id, Product = p, + Quantity = 2m, UnitPrice = subtotal / 2m, LineTotal = subtotal, + VatPercent = vat, + }); + return sale; + } + + [Fact] + public void Payload_maps_lines_payments_and_operation_type() + { + var sale = SaleWith(subtotal: 1000m, vat: 12m); + var req = WebkassaProvider.BuildCheckPayload(sale, cashboxNumber: "CB-42", token: "tok"); + + req.Token.Should().Be("tok"); + req.CashboxUniqueNumber.Should().Be("CB-42"); + req.OperationType.Should().Be(1, "продажа = 1"); + req.ExternalCheckNumber.Should().Be(sale.Number); + req.Positions.Should().HaveCount(1); + req.Positions[0].PositionName.Should().Be("Хлеб бородинский"); + req.Positions[0].Count.Should().Be(2m); + req.Positions[0].TaxPercent.Should().Be(12); + // Tax «в-ставке»: 1000 * 12 / 112 ≈ 107.14 + req.Positions[0].Tax.Should().BeApproximately(107.14m, 0.01m); + req.Payments.Should().HaveCount(1); + req.Payments[0].PaymentType.Should().Be(0); // cash + req.Payments[0].Sum.Should().Be(1000m); + } + + [Fact] + public void Payload_marks_returns_with_operation_type_2() + { + var sale = SaleWith(subtotal: 500m, vat: 0m, isReturn: true); + var req = WebkassaProvider.BuildCheckPayload(sale, "CB-1", "tok"); + req.OperationType.Should().Be(2); + } + + [Fact] + public void Payload_uses_mixed_payments_when_both_cash_and_card() + { + var sale = SaleWith(subtotal: 1000m, vat: 0m); + sale.PaidCash = 400m; + sale.PaidCard = 600m; + var req = WebkassaProvider.BuildCheckPayload(sale, "CB-1", "tok"); + req.Payments.Should().HaveCount(2); + req.Payments.Single(p => p.PaymentType == 0).Sum.Should().Be(400m); + req.Payments.Single(p => p.PaymentType == 1).Sum.Should().Be(600m); + } + + [Fact] + public void Payload_fallbacks_to_total_when_no_explicit_payment() + { + // Сценарий «оплачено бонусами»: PaidCash/PaidCard оба 0, Total > 0. + // Webkassa требует хотя бы один Payment — провайдер добавляет fallback + // на Total как наличные, иначе оператор отвергнет чек. + var sale = SaleWith(subtotal: 800m, vat: 0m); + sale.PaidCash = 0m; + sale.PaidCard = 0m; + var req = WebkassaProvider.BuildCheckPayload(sale, "CB-1", "tok"); + req.Payments.Should().HaveCount(1); + req.Payments[0].Sum.Should().Be(800m); + } + + [Fact] + public void Payload_with_zero_vat_does_not_divide_by_zero() + { + // 0% НДС → формула tax-в-ставке должна не падать. Tax == 0. + var sale = SaleWith(subtotal: 1000m, vat: 0m); + var req = WebkassaProvider.BuildCheckPayload(sale, "CB-1", "tok"); + req.Positions[0].Tax.Should().Be(0m); + } + + /// Smoke-проверка JSON-сериализации: Webkassa требует camelCase + /// поля (стандарт ASP.NET JsonSerializerDefaults.Web), проверяем что + /// сериализованный payload содержит ожидаемые ключи. + [Fact] + public void Payload_serializes_to_camelCase_json() + { + var sale = SaleWith(1000m, 12m); + var req = WebkassaProvider.BuildCheckPayload(sale, "CB-1", "tok-x"); + // UnsafeRelaxedJsonEscaping: иначе кириллица будет \uXXXX'и, и хотя + // Webkassa их корректно разберёт, тестовый assert и человеку + // ревьюить json приятнее в plain UTF-8. + var json = JsonSerializer.Serialize(req, new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }); + json.Should().Contain("\"cashboxUniqueNumber\":\"CB-1\""); + json.Should().Contain("\"operationType\":1"); + json.Should().Contain("\"externalCheckNumber\":\"ПР-000001\""); + json.Should().Contain("\"positions\":"); + json.Should().Contain("\"payments\":"); + } +} + +/// HttpMessageHandler-мок: возвращает заранее настроенные ответы +/// по URL'у. Используется и юнит-тестами провайдеров, и потенциально +/// integration-тестами (через .ConfigurePrimaryHttpMessageHandler). +internal sealed class StubHttpHandler : HttpMessageHandler +{ + public List Requests { get; } = new(); + private readonly Dictionary _responses; + + public StubHttpHandler(Dictionary responses) + { + _responses = responses; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(request); + var key = request.RequestUri!.AbsolutePath; + if (_responses.TryGetValue(key, out var pair)) + { + return Task.FromResult(new HttpResponseMessage(pair.Code) + { + Content = new StringContent(pair.Body, Encoding.UTF8, "application/json"), + }); + } + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent($"unexpected: {request.Method} {request.RequestUri}"), + }); + } +}