feat(s11): ОФД-scaffolding — IFiscalProvider + 4 провайдера + UI/тесты
Sprint 11 — каркас для интеграции с операторами фискальных данных РК.
Реальные ApiKey'и появятся у user'а позже; задача — построить такой
фрейм, чтобы подключение оператора сводилось к вписыванию кредов в UI
без правок кода/деплоя.
Что сделано:
- IFiscalProvider (Application/Common/Fiscal) + FiscalResult,
FiscalProviderKind (None/Mock/Webkassa/Kassa24/OfdSolo),
IFiscalProviderFactory, FiscalNotConfiguredException.
- 4 реализации в Infrastructure/Fiscal:
• MockFiscalProvider — фейк MOCK-<8hex> через 300мс, идемпотентный
по Sale.Id (используется dev/stage и интеграционными тестами);
• WebkassaProvider — полный HTTP-pipeline Authorize→Check, парсинг
JSON-ответа, NDS-в-ставке, retry-safe через ExternalCheckNumber;
• Kassa24Provider / OfdSoloProvider — скелет с тем же контрактом,
RegisterAsync бросает FiscalNotConfiguredException (нужны
спецификации API от user'а, NDA-only).
- Миграция Phase11a: 5 колонок в retail_sales (FiscalNumber, QrCode,
Url, ProviderTxId, ProviderKind) + 5 в organizations (FiscalProvider
NOT NULL default 0, ApiKey/Secret encrypted, CashboxUniqueNumber,
ApiBaseUrl). Default 0 = обратная совместимость, существующие чеки
и продажи без фискализации работают как раньше.
- RetailSalesController.Post — TryFiscalizeAsync после commit'а
stock-транзакции. Best-effort: сетевые/HTTP-ошибки логируются, чек
остаётся проведённым. Идемпотентность по IsNullOrEmpty(FiscalNumber).
- OrgFiscalSettingsController: GET/PUT настройки + GET /providers
(опции для select'а) + POST /test-send (фейк-чек к выбранному
провайдеру, не сохраняет в БД).
- UI: FiscalSection в OrganizationSettingsPage с password-input'ами
для ApiKey/Secret (шифруются DataProtection.purpose=foodmarket.fiscal,
в GET — только has-* флаги), спец-значение "__clear__" для снятия,
кнопка «Тестовая отправка».
- Тесты: 11 unit (Mock 5 + Webkassa payload 6) + 3 integration
(Mock сохраняет FiscalNumber, test-send даёт MOCK-номер, None
не фискализует).
- docs/ofd-integration.md — гид с архитектурой, шагами подключения
Webkassa (полный pap), TODO для Касса24/ОФД-Соло, безопасностью
кредов, retry-сценариями.
Все 68 unit + 8 integration в Fiscal/Loyalty/RetailOversell — зелёные.
Web vite build — зелёный.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
786dacb081
commit
0d3ef81f72
184
docs/ofd-integration.md
Normal file
184
docs/ofd-integration.md
Normal file
|
|
@ -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/<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 за день).
|
||||||
98
docs/sprint11-progress.md
Normal file
98
docs/sprint11-progress.md
Normal file
|
|
@ -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).
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>Sprint 11: настройки ОФД-провайдера на уровне организации.
|
||||||
|
/// Аналогично PlatformSettings/SMTP, но per-tenant: каждая фирма выбирает
|
||||||
|
/// своего оператора и хранит свои креды. ApiKey/ApiSecret шифруются через
|
||||||
|
/// DataProtection (purpose=`foodmarket.fiscal`) и НИКОГДА не отдаются
|
||||||
|
/// в открытом виде — только has-* флаги.</summary>
|
||||||
|
[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<IFiscalProvider> _providers;
|
||||||
|
private readonly ILogger<OrgFiscalSettingsController> _log;
|
||||||
|
|
||||||
|
public OrgFiscalSettingsController(
|
||||||
|
AppDbContext db,
|
||||||
|
ITenantContext tenant,
|
||||||
|
IDataProtectionProvider dpProvider,
|
||||||
|
IEnumerable<IFiscalProvider> providers,
|
||||||
|
ILogger<OrgFiscalSettingsController> 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);
|
||||||
|
|
||||||
|
/// <summary>Доступные значения провайдера для select'а в UI. Возвращаем
|
||||||
|
/// массив, потому что enum-значения мы НЕ хотим публиковать через
|
||||||
|
/// generic schema-export — кодом проще держать локализованные имена.</summary>
|
||||||
|
[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<ActionResult<FiscalSettingsDto>> 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<ActionResult<FiscalSettingsDto>> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Тестовая отправка: создаёт «фейк-чек» (in-memory, не в БД)
|
||||||
|
/// и отправляет через выбранного провайдера. Не сохраняет результат —
|
||||||
|
/// просто показывает админу что креды валидны и оператор отвечает.
|
||||||
|
/// Webkassa/Касса24/ОФД-Соло в skeleton'е бросают FiscalNotConfiguredException,
|
||||||
|
/// что UI отрендерит как «не реализовано»; Mock возвращает MOCK-fingerprint
|
||||||
|
/// и показывает что pipeline жив.</summary>
|
||||||
|
[HttpPost("test-send"), Authorize(Roles = "Admin")]
|
||||||
|
public async Task<ActionResult<TestSendResponse>> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Маленький фикс — Organization.CashboxUniqueNumber изначально
|
||||||
|
/// называлось <c>FiscalCashboxUniqueNumber</c>. Чтобы код контроллера
|
||||||
|
/// не запутался с длинным именем, локальный extension'ом сахарим.</summary>
|
||||||
|
internal static class _OrgFiscalExt
|
||||||
|
{
|
||||||
|
public static void CashboxUniqueNumber(this foodmarket.Domain.Organizations.Organization o, string? v)
|
||||||
|
=> o.FiscalCashboxUniqueNumber = string.IsNullOrWhiteSpace(v) ? null : v.Trim();
|
||||||
|
}
|
||||||
|
|
@ -20,14 +20,17 @@ public class RetailSalesController : ControllerBase
|
||||||
private readonly IStockService _stock;
|
private readonly IStockService _stock;
|
||||||
private readonly ILogger<RetailSalesController> _log;
|
private readonly ILogger<RetailSalesController> _log;
|
||||||
private readonly foodmarket.Api.Realtime.INotificationsPublisher _notify;
|
private readonly foodmarket.Api.Realtime.INotificationsPublisher _notify;
|
||||||
|
private readonly foodmarket.Application.Common.Fiscal.IFiscalProviderFactory _fiscal;
|
||||||
|
|
||||||
public RetailSalesController(AppDbContext db, IStockService stock, ILogger<RetailSalesController> log,
|
public RetailSalesController(AppDbContext db, IStockService stock, ILogger<RetailSalesController> log,
|
||||||
foodmarket.Api.Realtime.INotificationsPublisher notify)
|
foodmarket.Api.Realtime.INotificationsPublisher notify,
|
||||||
|
foodmarket.Application.Common.Fiscal.IFiscalProviderFactory fiscal)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_stock = stock;
|
_stock = stock;
|
||||||
_log = log;
|
_log = log;
|
||||||
_notify = notify;
|
_notify = notify;
|
||||||
|
_fiscal = fiscal;
|
||||||
}
|
}
|
||||||
|
|
||||||
public record RetailSaleListRow(
|
public record RetailSaleListRow(
|
||||||
|
|
@ -63,7 +66,12 @@ public record RetailSaleDto(
|
||||||
decimal LoyaltyPointsAccrued = 0m,
|
decimal LoyaltyPointsAccrued = 0m,
|
||||||
Guid? PromotionId = null,
|
Guid? PromotionId = null,
|
||||||
string? PromotionCode = 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(
|
public record RetailSaleLineInput(
|
||||||
Guid ProductId,
|
Guid ProductId,
|
||||||
|
|
@ -594,6 +602,14 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
||||||
"RetailSale posted: {SaleNumber} store={StoreId} payment={Payment} lines={LinesCount} total={Total}",
|
"RetailSale posted: {SaleNumber} store={StoreId} payment={Payment} lines={LinesCount} total={Total}",
|
||||||
sale.Number, sale.StoreId, sale.Payment, sale.Lines.Count, sale.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 если
|
// SignalR-уведомление в группу org. Кассирa берём из CashierId если
|
||||||
// есть Employee.Name по UserId, иначе из User.Email (короткая часть до @).
|
// есть Employee.Name по UserId, иначе из User.Email (короткая часть до @).
|
||||||
try
|
try
|
||||||
|
|
@ -652,6 +668,63 @@ private async Task NotifyLowStockAfterSaleAsync(RetailSale sale, CancellationTok
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Sprint 11: вызвать ОФД-провайдера (если выбран) и сохранить
|
||||||
|
/// фискальный номер/QR на чек. Best-effort: любая ошибка проглатывается
|
||||||
|
/// и логируется — чек остаётся проведённым даже без фискализации
|
||||||
|
/// (оператор может быть временно недоступен, retry — отдельная история).
|
||||||
|
///
|
||||||
|
/// Идемпотентность: если на чеке уже есть FiscalNumber, повторно не
|
||||||
|
/// зовём. Это покрывает случай ручного re-post'а через unpost→post.</summary>
|
||||||
|
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")]
|
[HttpPost("{id:guid}/unpost"), RequiresPermission("RetailSalesRefund")]
|
||||||
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|
@ -999,6 +1072,8 @@ orderby l.SortOrder
|
||||||
lines,
|
lines,
|
||||||
// Sprint 9
|
// Sprint 9
|
||||||
row.s.LoyaltyCardId, row.s.LoyaltyBonusApplied, row.s.LoyaltyPointsAccrued,
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,29 @@
|
||||||
// на каждой отправке без рестарта приложения.
|
// на каждой отправке без рестарта приложения.
|
||||||
builder.Services.AddSingleton<foodmarket.Application.Common.Email.IEmailSender,
|
builder.Services.AddSingleton<foodmarket.Application.Common.Email.IEmailSender,
|
||||||
foodmarket.Infrastructure.Email.MailKitEmailSender>();
|
foodmarket.Infrastructure.Email.MailKitEmailSender>();
|
||||||
|
|
||||||
|
// ─ Sprint 11: ОФД (фискализация чеков в РК) ────────────────────────
|
||||||
|
// Все провайдеры регистрируются одновременно — фабрика выбирает по
|
||||||
|
// FiscalProvider в настройках организации. Default — None: чеки
|
||||||
|
// проводятся без фискализации, поведение «как до Sprint 11».
|
||||||
|
//
|
||||||
|
// Mock = Transient (без HTTP); остальные через AddHttpClient — это
|
||||||
|
// даёт автоматический pooled HttpMessageHandler + интеграцию с
|
||||||
|
// IHttpClientFactory (вынос в Polly / переопределение handler'а
|
||||||
|
// в тестах через .AddHttpMessageHandler).
|
||||||
|
builder.Services.AddTransient<foodmarket.Application.Common.Fiscal.IFiscalProvider,
|
||||||
|
foodmarket.Infrastructure.Fiscal.MockFiscalProvider>();
|
||||||
|
builder.Services.AddHttpClient<foodmarket.Infrastructure.Fiscal.WebkassaProvider>();
|
||||||
|
builder.Services.AddTransient<foodmarket.Application.Common.Fiscal.IFiscalProvider>(sp =>
|
||||||
|
sp.GetRequiredService<foodmarket.Infrastructure.Fiscal.WebkassaProvider>());
|
||||||
|
builder.Services.AddHttpClient<foodmarket.Infrastructure.Fiscal.Kassa24Provider>();
|
||||||
|
builder.Services.AddTransient<foodmarket.Application.Common.Fiscal.IFiscalProvider>(sp =>
|
||||||
|
sp.GetRequiredService<foodmarket.Infrastructure.Fiscal.Kassa24Provider>());
|
||||||
|
builder.Services.AddHttpClient<foodmarket.Infrastructure.Fiscal.OfdSoloProvider>();
|
||||||
|
builder.Services.AddTransient<foodmarket.Application.Common.Fiscal.IFiscalProvider>(sp =>
|
||||||
|
sp.GetRequiredService<foodmarket.Infrastructure.Fiscal.OfdSoloProvider>());
|
||||||
|
builder.Services.AddScoped<foodmarket.Application.Common.Fiscal.IFiscalProviderFactory,
|
||||||
|
foodmarket.Infrastructure.Fiscal.FiscalProviderFactory>();
|
||||||
// EmailTemplates загружает embedded HTML и подставляет {{key}} —
|
// EmailTemplates загружает embedded HTML и подставляет {{key}} —
|
||||||
// см. Resources/EmailTemplates/*.html. Singleton с in-memory cache.
|
// см. Resources/EmailTemplates/*.html. Singleton с in-memory cache.
|
||||||
builder.Services.AddSingleton<foodmarket.Api.Infrastructure.Email.EmailTemplates>();
|
builder.Services.AddSingleton<foodmarket.Api.Infrastructure.Email.EmailTemplates>();
|
||||||
|
|
|
||||||
108
src/food-market.application/Common/Fiscal/IFiscalProvider.cs
Normal file
108
src/food-market.application/Common/Fiscal/IFiscalProvider.cs
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
using foodmarket.Domain.Sales;
|
||||||
|
|
||||||
|
namespace foodmarket.Application.Common.Fiscal;
|
||||||
|
|
||||||
|
/// <summary>Абстракция оператора фискальных данных (ОФД) для Казахстана.
|
||||||
|
/// Каждый чек после успешного <c>RetailSale.Post</c> регистрируется в
|
||||||
|
/// налоговой через одного из аккредитованных операторов (Webkassa / Касса24 /
|
||||||
|
/// ОФД-Соло / …). Оператор возвращает фискальный номер, QR-код и
|
||||||
|
/// URL-предъявителя — мы сохраняем их на чеке и печатаем на квитанции.
|
||||||
|
///
|
||||||
|
/// Контракт умышленно тонкий: всё, что нужно вызывающему слою — отдать
|
||||||
|
/// чек, получить четыре поля. Шифрование ApiKey'ев, выбор реализации,
|
||||||
|
/// idempotency-ключи живут на инфра-стороне (DI-привязка в Program.cs +
|
||||||
|
/// конфигурация на уровне Organization).</summary>
|
||||||
|
public interface IFiscalProvider
|
||||||
|
{
|
||||||
|
/// <summary>Какой это провайдер (для диагностики / маршрутизации
|
||||||
|
/// фабрики). Должно совпадать со значением <see cref="FiscalProviderKind"/>
|
||||||
|
/// в настройках организации.</summary>
|
||||||
|
FiscalProviderKind Kind { get; }
|
||||||
|
|
||||||
|
/// <summary>Зарегистрировать чек у оператора. Вызывается ВНУТРИ
|
||||||
|
/// уже сохранённой (Posted) транзакции — реализация не должна
|
||||||
|
/// модифицировать stocks/прочие сущности, только сходить во внешний
|
||||||
|
/// API и вернуть результат.
|
||||||
|
///
|
||||||
|
/// Реализация обязана быть идемпотентной по <see cref="RetailSale.Id"/>
|
||||||
|
/// (повторный вызов с тем же чеком должен вернуть тот же FiscalNumber,
|
||||||
|
/// а не создать дубль) — детали реализации зависят от оператора:
|
||||||
|
/// у кого-то есть нативный idempotency-key, у кого-то приходится
|
||||||
|
/// проверять по нашему номеру.</summary>
|
||||||
|
Task<FiscalResult> RegisterAsync(RetailSale sale, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Тип ОФД-оператора. Хранится в БД (Organization.FiscalProvider),
|
||||||
|
/// конфигурации (Fiscal:Provider) и в логах. Порядок и числовые значения
|
||||||
|
/// фиксированы (БД-колонка int) — НЕ переставлять.</summary>
|
||||||
|
public enum FiscalProviderKind
|
||||||
|
{
|
||||||
|
/// <summary>Никто. Чеки проводятся без фискализации (поведение «как
|
||||||
|
/// до Sprint 11»). Дефолт — гарантия обратной совместимости.</summary>
|
||||||
|
None = 0,
|
||||||
|
|
||||||
|
/// <summary>Mock-провайдер для разработки и тестов. Возвращает
|
||||||
|
/// детерминированный фейк через 300мс (имитация сетевой задержки).</summary>
|
||||||
|
Mock = 1,
|
||||||
|
|
||||||
|
/// <summary>Webkassa (https://webkassa.kz). Самый распространённый ОФД
|
||||||
|
/// в РК, REST-API на https://api.webkassa.kz/.</summary>
|
||||||
|
Webkassa = 2,
|
||||||
|
|
||||||
|
/// <summary>Касса24 (https://kassa24.kz). Принадлежит Kaspi-группе,
|
||||||
|
/// тесная интеграция с QR-эквайрингом Kaspi Pay.</summary>
|
||||||
|
Kassa24 = 3,
|
||||||
|
|
||||||
|
/// <summary>ОФД-Соло (https://ofd-solo.kz).</summary>
|
||||||
|
OfdSolo = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Результат фискализации одного чека. Все поля — снапшот,
|
||||||
|
/// сохраняются на <see cref="RetailSale"/> и больше не меняются.</summary>
|
||||||
|
/// <param name="FiscalNumber">Фискальный номер чека от оператора (например,
|
||||||
|
/// для Webkassa — <c>checkNumber</c>). Печатается на квитанции. Не пуст.</param>
|
||||||
|
/// <param name="FiscalQrCode">Содержимое QR-кода (как правило — URL для
|
||||||
|
/// проверки чека в кабинете налоговой). POS-программа рендерит QR на бумаге.</param>
|
||||||
|
/// <param name="FiscalUrl">Человекочитаемый URL для перехода (часто
|
||||||
|
/// совпадает с QR, но не всегда — иногда оператор отдаёт «короткий» qr
|
||||||
|
/// и отдельно полный URL).</param>
|
||||||
|
/// <param name="ProviderTxId">Внутренний id транзакции у оператора. Нужен
|
||||||
|
/// если попросят support — по нему оператор найдёт запрос в своих логах.
|
||||||
|
/// Может быть null если оператор не возвращает.</param>
|
||||||
|
public record FiscalResult(
|
||||||
|
string FiscalNumber,
|
||||||
|
string FiscalQrCode,
|
||||||
|
string FiscalUrl,
|
||||||
|
string? ProviderTxId);
|
||||||
|
|
||||||
|
/// <summary>Бросается когда ОФД-провайдер пытаются позвать, но конфиг
|
||||||
|
/// неполный — например, выбран Webkassa, а ApiKey не задан. Контроллер
|
||||||
|
/// RetailSales должен поймать и вернуть понятный 400/503 (не падать в 500).
|
||||||
|
///
|
||||||
|
/// Использование: только из реализаций <see cref="IFiscalProvider"/> и
|
||||||
|
/// фабрики. В application-слое не ловим — пусть всплывает выше.</summary>
|
||||||
|
public class FiscalNotConfiguredException : Exception
|
||||||
|
{
|
||||||
|
public FiscalNotConfiguredException(string message) : base(message) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Бросается при сетевой/API-ошибке оператора (5xx, таймаут,
|
||||||
|
/// невалидный JSON). Чек НЕ помечается фискализованным — оператор будет
|
||||||
|
/// перезван при следующем POST на этот чек (idempotency).</summary>
|
||||||
|
public class FiscalProviderException : Exception
|
||||||
|
{
|
||||||
|
public FiscalProviderException(string message) : base(message) { }
|
||||||
|
public FiscalProviderException(string message, Exception inner) : base(message, inner) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Фабрика — отдаёт провайдер по <see cref="FiscalProviderKind"/>
|
||||||
|
/// из текущей организации. Регистрируется в DI как Scoped (Organization
|
||||||
|
/// читается из tenant-контекста). Возвращает null если выбран
|
||||||
|
/// <see cref="FiscalProviderKind.None"/>.</summary>
|
||||||
|
public interface IFiscalProviderFactory
|
||||||
|
{
|
||||||
|
/// <summary>Получить провайдер для текущей организации (или null, если
|
||||||
|
/// в её настройках выбран None). НЕ кидает — выбор «не фискализовать»
|
||||||
|
/// нормальный сценарий.</summary>
|
||||||
|
Task<IFiscalProvider?> ResolveAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
@ -86,4 +86,32 @@ public class Organization : Entity
|
||||||
/// false. Описания ведут единицы магазинов; обычно текстовая колонка
|
/// false. Описания ведут единицы магазинов; обычно текстовая колонка
|
||||||
/// просто захламляет карточку.</summary>
|
/// просто захламляет карточку.</summary>
|
||||||
public bool ShowDescriptionOnProduct { get; set; }
|
public bool ShowDescriptionOnProduct { get; set; }
|
||||||
|
|
||||||
|
// ─ Sprint 11: ОФД (фискализация чеков) ────────────────────────────
|
||||||
|
// Каждая организация выбирает СВОЕГО ОФД-оператора. Креды per-tenant:
|
||||||
|
// у одной фирмы может быть Webkassa, у другой — Касса24. ApiKey/Secret
|
||||||
|
// шифруются через DataProtection (purpose=`foodmarket.fiscal`) — в
|
||||||
|
// открытом виде в API-ответах не возвращаются (только has-* флаги).
|
||||||
|
|
||||||
|
/// <summary>Тип ОФД-провайдера. 0=None (не фискализировать — дефолт),
|
||||||
|
/// 1=Mock, 2=Webkassa, 3=Kassa24, 4=OfdSolo. Значения — см.
|
||||||
|
/// <c>FiscalProviderKind</c> в application-слое.</summary>
|
||||||
|
public int FiscalProvider { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Зашифрованный ApiKey оператора (base64 через DataProtection,
|
||||||
|
/// purpose=`foodmarket.fiscal`). Никогда не отдаётся в API-ответах.</summary>
|
||||||
|
public string? FiscalApiKeyEncrypted { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Зашифрованный ApiSecret оператора (если требуется — у
|
||||||
|
/// Webkassa, например, токен-логин, у Касса24 — отдельный secret).</summary>
|
||||||
|
public string? FiscalApiSecretEncrypted { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Уникальный номер кассы у оператора. У Webkassa называется
|
||||||
|
/// <c>CashboxUniqueNumber</c>, передаётся в каждом чеке.</summary>
|
||||||
|
public string? FiscalCashboxUniqueNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Опциональный override URL'а оператора (для тестового
|
||||||
|
/// контура / sandbox'а). null → используется production URL из
|
||||||
|
/// реализации провайдера.</summary>
|
||||||
|
public string? FiscalApiBaseUrl { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,32 @@ public class RetailSale : TenantEntity, IVersionedEntity
|
||||||
public RetailSale? ReferenceSale { get; set; }
|
public RetailSale? ReferenceSale { get; set; }
|
||||||
|
|
||||||
public ICollection<RetailSaleLine> Lines { get; set; } = new List<RetailSaleLine>();
|
public ICollection<RetailSaleLine> Lines { get; set; } = new List<RetailSaleLine>();
|
||||||
|
|
||||||
|
// ─ Sprint 11: ОФД-фискализация ──────────────────────────────────────
|
||||||
|
// Снапшоты ответа оператора. Заполняются после успешной
|
||||||
|
// регистрации чека (см. IFiscalProvider). Null = чек не
|
||||||
|
// фискализован (или провайдер None/Mock без записи в БД).
|
||||||
|
|
||||||
|
/// <summary>Фискальный номер чека от оператора (например, Webkassa
|
||||||
|
/// возвращает <c>checkNumber</c>). Печатается на квитанции для
|
||||||
|
/// покупателя. Null до проведения / если провайдер None.</summary>
|
||||||
|
public string? FiscalNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Содержимое QR-кода для печати на бумаге. Обычно — URL
|
||||||
|
/// для проверки чека в кабинете налоговой РК.</summary>
|
||||||
|
public string? FiscalQrCode { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Человекочитаемый URL чека (для перехода вручную, если
|
||||||
|
/// QR недоступен).</summary>
|
||||||
|
public string? FiscalUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Внутренний id транзакции у оператора — для support'а.</summary>
|
||||||
|
public string? FiscalProviderTxId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Какой оператор зарегистрировал этот чек. Снапшот: даже
|
||||||
|
/// если в настройках организации потом поменять провайдера, старые
|
||||||
|
/// чеки помнят, кто их регистрировал.</summary>
|
||||||
|
public int? FiscalProviderKind { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class RetailSaleLine : TenantEntity
|
public class RetailSaleLine : TenantEntity
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>Default-фабрика провайдеров: читает <c>Organization.FiscalProvider</c>
|
||||||
|
/// из БД и возвращает зарегистрированную в DI реализацию. Если в БД
|
||||||
|
/// <c>None</c> (или организация не найдена) — возвращает null, контроллер
|
||||||
|
/// расценивает это как «фискализацию не делаем, чек проводим как есть».
|
||||||
|
///
|
||||||
|
/// <para>Реализации провайдеров получены через <c>IEnumerable<IFiscalProvider></c> —
|
||||||
|
/// в DI они регистрируются все одновременно, фабрика выбирает по
|
||||||
|
/// <see cref="IFiscalProvider.Kind"/>. Это позволяет добавлять новых
|
||||||
|
/// операторов одной строкой в Program.cs.</para>
|
||||||
|
///
|
||||||
|
/// <para>Конфигурация <c>Fiscal:Provider</c> в appsettings — глобальный
|
||||||
|
/// override на уровне приложения. Если задан — он перебивает per-organization
|
||||||
|
/// настройку. Используется главным образом в integration-тестах
|
||||||
|
/// (форсим Mock для всех тестов, не трогая БД-настройку каждой созданной
|
||||||
|
/// орг).</para></summary>
|
||||||
|
public class FiscalProviderFactory : IFiscalProviderFactory
|
||||||
|
{
|
||||||
|
private readonly ITenantContext _tenant;
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly IEnumerable<IFiscalProvider> _providers;
|
||||||
|
private readonly FiscalProviderKind? _globalOverride;
|
||||||
|
|
||||||
|
public FiscalProviderFactory(
|
||||||
|
ITenantContext tenant,
|
||||||
|
AppDbContext db,
|
||||||
|
IEnumerable<IFiscalProvider> providers,
|
||||||
|
Microsoft.Extensions.Configuration.IConfiguration cfg)
|
||||||
|
{
|
||||||
|
_tenant = tenant;
|
||||||
|
_db = db;
|
||||||
|
_providers = providers;
|
||||||
|
// Парсим один раз в конструкторе. Невалидные значения трактуем как
|
||||||
|
// «override не задан» — лучше упасть на старте если опечатка, но
|
||||||
|
// мы намеренно мягко относимся к чужим config'ам.
|
||||||
|
var raw = cfg["Fiscal:Provider"];
|
||||||
|
if (Enum.TryParse<FiscalProviderKind>(raw, ignoreCase: true, out var parsed))
|
||||||
|
_globalOverride = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IFiscalProvider?> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
98
src/food-market.infrastructure/Fiscal/Kassa24Provider.cs
Normal file
98
src/food-market.infrastructure/Fiscal/Kassa24Provider.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>Касса24 (https://kassa24.kz) — облачная касса от Kaspi-группы.
|
||||||
|
/// Тесно интегрирована с QR-эквайрингом Kaspi Pay (платёж и фискализация
|
||||||
|
/// проходят одним flow).
|
||||||
|
///
|
||||||
|
/// <para><b>Skeleton, TODO:</b> публичная документация Касса24 на момент
|
||||||
|
/// scaffolding'а недоступна (NDA-only после подписания договора). Поэтому
|
||||||
|
/// этот файл — каркас с тем же контрактом, что Webkassa, но реальный POST
|
||||||
|
/// заменён на <c>throw new FiscalNotConfiguredException</c>: пока user
|
||||||
|
/// не получит ApiKey + спецификацию endpoints, провайдер просит
|
||||||
|
/// переключиться на Mock или Webkassa.</para>
|
||||||
|
///
|
||||||
|
/// <para>Когда документация появится, нужно реализовать:
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>Аутентификацию (предположительно — HMAC-SHA256 подпись запроса
|
||||||
|
/// ApiSecret'ом, как у Kaspi merchant API).</item>
|
||||||
|
/// <item>POST <c>/v1/check</c> (рабочее название — уточнить в доке).</item>
|
||||||
|
/// <item>Маппинг RetailSale.Lines → их формат позиций.</item>
|
||||||
|
/// <item>Парсинг ответа: fiscalNumber, qrCode, ticketUrl, transactionId.</item>
|
||||||
|
/// </list></para></summary>
|
||||||
|
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<Kassa24Provider> _log;
|
||||||
|
|
||||||
|
public Kassa24Provider(
|
||||||
|
HttpClient http,
|
||||||
|
IServiceScopeFactory scopes,
|
||||||
|
IDataProtectionProvider dpProvider,
|
||||||
|
ILogger<Kassa24Provider> log)
|
||||||
|
{
|
||||||
|
_http = http;
|
||||||
|
_scopes = scopes;
|
||||||
|
_dpProvider = dpProvider;
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FiscalProviderKind Kind => FiscalProviderKind.Kassa24;
|
||||||
|
|
||||||
|
public async Task<FiscalResult> 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<AppDbContext>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/food-market.infrastructure/Fiscal/MockFiscalProvider.cs
Normal file
59
src/food-market.infrastructure/Fiscal/MockFiscalProvider.cs
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
using foodmarket.Application.Common.Fiscal;
|
||||||
|
using foodmarket.Domain.Sales;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Fiscal;
|
||||||
|
|
||||||
|
/// <summary>Mock-ОФД для разработки и интеграционных тестов. Имитирует
|
||||||
|
/// сетевую задержку (~300мс — типичный latency RPC до ОФД-облака), затем
|
||||||
|
/// возвращает детерминированный фейк, привязанный к <see cref="RetailSale.Id"/>:
|
||||||
|
/// тот же чек двумя вызовами даёт тот же FiscalNumber (идемпотентность).
|
||||||
|
///
|
||||||
|
/// Полезно: <list type="bullet">
|
||||||
|
/// <item>интеграционный тест «RetailSale.Post с Provider=Mock сохраняет
|
||||||
|
/// FiscalNumber=MOCK-…» (см. FiscalMockTests).</item>
|
||||||
|
/// <item>демо-стэйдж — UI рендерит QR/номер на квитанции без реального
|
||||||
|
/// аккаунта ОФД.</item>
|
||||||
|
/// <item>отладка пути «фискализация-провалилась» — заменяется тестовой
|
||||||
|
/// реализацией, которая бросает исключение.</item>
|
||||||
|
/// </list></summary>
|
||||||
|
public class MockFiscalProvider : IFiscalProvider
|
||||||
|
{
|
||||||
|
private readonly ILogger<MockFiscalProvider> _log;
|
||||||
|
|
||||||
|
/// <summary>Сколько ждать перед ответом. По умолчанию 300мс — близко к
|
||||||
|
/// реальной задержке Webkassa из РК-каналов. В тестах переопределяется
|
||||||
|
/// (через DI-фабрику с TimeSpan.Zero), чтобы не тормозить прогон.</summary>
|
||||||
|
public TimeSpan SimulatedLatency { get; set; } = TimeSpan.FromMilliseconds(300);
|
||||||
|
|
||||||
|
public MockFiscalProvider(ILogger<MockFiscalProvider> log)
|
||||||
|
{
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FiscalProviderKind Kind => FiscalProviderKind.Mock;
|
||||||
|
|
||||||
|
public async Task<FiscalResult> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/food-market.infrastructure/Fiscal/OfdSoloProvider.cs
Normal file
89
src/food-market.infrastructure/Fiscal/OfdSoloProvider.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>ОФД-Соло (https://ofd-solo.kz) — третий по распространённости
|
||||||
|
/// ОФД-оператор РК. API на https://api.ofd-solo.kz/ (требует подписания
|
||||||
|
/// контракта для доступа к спецификации).
|
||||||
|
///
|
||||||
|
/// <para><b>Skeleton, TODO:</b> аналогично Касса24, реальная интеграция
|
||||||
|
/// ждёт получения ApiKey и спецификации. Контракт повторяет Webkassa —
|
||||||
|
/// когда документация будет, основная работа сведётся к маппингу JSON-полей.</para>
|
||||||
|
///
|
||||||
|
/// <para>Особенности ОФД-Соло (из публичных источников):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>SOAP-based legacy + REST-обёртка (REST моложе, рекомендуется).</item>
|
||||||
|
/// <item>Аутентификация по token-логину (как Webkassa).</item>
|
||||||
|
/// <item>Чек регистрируется одним вызовом, без двухшагового создания/post'а.</item>
|
||||||
|
/// </list></para></summary>
|
||||||
|
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<OfdSoloProvider> _log;
|
||||||
|
|
||||||
|
public OfdSoloProvider(
|
||||||
|
HttpClient http,
|
||||||
|
IServiceScopeFactory scopes,
|
||||||
|
IDataProtectionProvider dpProvider,
|
||||||
|
ILogger<OfdSoloProvider> log)
|
||||||
|
{
|
||||||
|
_http = http;
|
||||||
|
_scopes = scopes;
|
||||||
|
_dpProvider = dpProvider;
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FiscalProviderKind Kind => FiscalProviderKind.OfdSolo;
|
||||||
|
|
||||||
|
public async Task<FiscalResult> 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<AppDbContext>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
291
src/food-market.infrastructure/Fiscal/WebkassaProvider.cs
Normal file
291
src/food-market.infrastructure/Fiscal/WebkassaProvider.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>Webkassa (https://webkassa.kz) — крупнейший ОФД-оператор РК.
|
||||||
|
/// REST-API на https://api.webkassa.kz/, документация:
|
||||||
|
/// https://app.swaggerhub.com/apis-docs/webkassa/oblachnaya-kassa/1.0.0.
|
||||||
|
///
|
||||||
|
/// <para>Этот провайдер — <b>skeleton</b>: реализован полный путь сериализации,
|
||||||
|
/// HTTP-запроса и парсинга ответа, но без живого аккаунта тестировать
|
||||||
|
/// можно только моком HttpMessageHandler'а (см. WebkassaProviderTests).
|
||||||
|
/// Когда user заведёт реальный кабинет и впишет ApiKey+CashboxNumber в
|
||||||
|
/// настройках организации — провайдер заработает без правок кода.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Поток вызова:</b> <c>POST /api/Authorize</c> для получения Token →
|
||||||
|
/// <c>POST /api/Check</c> с этим токеном и payload'ом чека. Токен короткоживущий
|
||||||
|
/// (TTL ~3 часа); кешируем in-memory на инстанс scope'а (одна продажа = один
|
||||||
|
/// scope = один токен, без кеша между запросами — простота важнее микро-perf).</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Идемпотентность:</b> Webkassa умеет дедупить по своему полю
|
||||||
|
/// <c>ExternalCheckNumber</c> (мы передаём <see cref="RetailSale.Number"/>).
|
||||||
|
/// Повторный POST с тем же номером возвращает оригинальный чек, не создавая
|
||||||
|
/// дубль — это и обеспечивает «retry safe» поведение в нашем контроллере.</para></summary>
|
||||||
|
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<WebkassaProvider> _log;
|
||||||
|
|
||||||
|
public WebkassaProvider(
|
||||||
|
HttpClient http,
|
||||||
|
IServiceScopeFactory scopes,
|
||||||
|
IDataProtectionProvider dpProvider,
|
||||||
|
ILogger<WebkassaProvider> log)
|
||||||
|
{
|
||||||
|
_http = http;
|
||||||
|
_scopes = scopes;
|
||||||
|
_dpProvider = dpProvider;
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FiscalProviderKind Kind => FiscalProviderKind.Webkassa;
|
||||||
|
|
||||||
|
public async Task<FiscalResult> 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<WebkassaCheckResponse>(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<string> 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<WebkassaAuthResponse>(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 ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Собирает payload для POST /api/Check. Минимальный набор:
|
||||||
|
/// CashboxUniqueNumber, OperationType (1=продажа, 2=возврат), Positions[],
|
||||||
|
/// Payments[]. Webkassa требует чтобы сумма Payments совпадала с суммой
|
||||||
|
/// Positions с точностью до копейки; контроллер проверяет это ранее.
|
||||||
|
/// public — чтобы юнит-тесты могли проверить маппинг без HTTP.</summary>
|
||||||
|
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<WebkassaPayment>();
|
||||||
|
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<WebkassaConfig> LoadConfigAsync(Guid organizationId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var scope = _scopes.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
// 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; } = "";
|
||||||
|
/// <summary>1=продажа, 2=возврат продажи.</summary>
|
||||||
|
public int OperationType { get; set; }
|
||||||
|
public string ExternalCheckNumber { get; set; } = "";
|
||||||
|
public int RoundType { get; set; }
|
||||||
|
public List<WebkassaPosition> Positions { get; set; } = new();
|
||||||
|
public List<WebkassaPayment> 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; }
|
||||||
|
/// <summary>0=наличные, 1=карта, 2=мобильные деньги, 4=кредит.</summary>
|
||||||
|
public int PaymentType { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WebkassaAuthResponse
|
||||||
|
{
|
||||||
|
public WebkassaAuthData? Data { get; set; }
|
||||||
|
public List<WebkassaError>? Errors { get; set; }
|
||||||
|
}
|
||||||
|
public class WebkassaAuthData
|
||||||
|
{
|
||||||
|
public string? Token { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WebkassaCheckResponse
|
||||||
|
{
|
||||||
|
public WebkassaCheckData? Data { get; set; }
|
||||||
|
public List<WebkassaError>? Errors { get; set; }
|
||||||
|
}
|
||||||
|
public class WebkassaCheckData
|
||||||
|
{
|
||||||
|
/// <summary>Фискальный номер чека (печатается на квитанции).</summary>
|
||||||
|
public string? CheckNumber { get; set; }
|
||||||
|
/// <summary>Уникальный идентификатор операции у Webkassa.</summary>
|
||||||
|
public string? UniqueNumber { get; set; }
|
||||||
|
/// <summary>URL для рендера QR-кода (содержит ссылку на проверку чека).</summary>
|
||||||
|
public string? QrCode { get; set; }
|
||||||
|
/// <summary>Прямая ссылка на чек в кабинете налоговой.</summary>
|
||||||
|
public string? TicketUrl { get; set; }
|
||||||
|
}
|
||||||
|
public class WebkassaError
|
||||||
|
{
|
||||||
|
public int Code { get; set; }
|
||||||
|
public string? Text { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -170,6 +170,12 @@ public static void ConfigureSales(this ModelBuilder b)
|
||||||
e.Property(x => x.LoyaltyPointsAccrued).HasPrecision(18, 4);
|
e.Property(x => x.LoyaltyPointsAccrued).HasPrecision(18, 4);
|
||||||
e.Property(x => x.PromotionDiscount).HasPrecision(18, 4);
|
e.Property(x => x.PromotionDiscount).HasPrecision(18, 4);
|
||||||
e.Property(x => x.PromotionCode).HasMaxLength(40);
|
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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>Phase11a — ОФД-scaffolding (фискализация чеков для РК).
|
||||||
|
///
|
||||||
|
/// <para>retail_sales: пять снапшот-колонок, заполняются после
|
||||||
|
/// успешной регистрации чека у оператора (Webkassa/Касса24/ОФД-Соло/Mock).
|
||||||
|
/// Все nullable — старые чеки и чеки без фискализации остаются
|
||||||
|
/// валидными (NULL = провайдер None или зарегистрировать не удалось).</para>
|
||||||
|
///
|
||||||
|
/// <para>organizations: пять колонок per-tenant конфига ОФД. ApiKey/Secret
|
||||||
|
/// шифруются через DataProtection (purpose="foodmarket.fiscal") —
|
||||||
|
/// в БД лежат base64 protected blob'ы, в API не возвращаются.</para>
|
||||||
|
///
|
||||||
|
/// Дефолтное значение <c>FiscalProvider=0</c> (None) — поведение API
|
||||||
|
/// «как до Sprint 11»: чеки не фискализируются, FiscalNumber пустой.
|
||||||
|
/// Включение требует явного выбора провайдера + ввода кредов в UI.</summary>
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260607100000_Phase11a_FiscalScaffolding")]
|
||||||
|
public partial class Phase11a_FiscalScaffolding : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder b)
|
||||||
|
{
|
||||||
|
// ── retail_sales: фискальные снапшоты ───────────────────────────
|
||||||
|
b.AddColumn<string>(
|
||||||
|
name: "FiscalNumber",
|
||||||
|
schema: "public",
|
||||||
|
table: "retail_sales",
|
||||||
|
type: "character varying(100)",
|
||||||
|
maxLength: 100,
|
||||||
|
nullable: true);
|
||||||
|
b.AddColumn<string>(
|
||||||
|
name: "FiscalQrCode",
|
||||||
|
schema: "public",
|
||||||
|
table: "retail_sales",
|
||||||
|
type: "character varying(2000)",
|
||||||
|
maxLength: 2000,
|
||||||
|
nullable: true);
|
||||||
|
b.AddColumn<string>(
|
||||||
|
name: "FiscalUrl",
|
||||||
|
schema: "public",
|
||||||
|
table: "retail_sales",
|
||||||
|
type: "character varying(2000)",
|
||||||
|
maxLength: 2000,
|
||||||
|
nullable: true);
|
||||||
|
b.AddColumn<string>(
|
||||||
|
name: "FiscalProviderTxId",
|
||||||
|
schema: "public",
|
||||||
|
table: "retail_sales",
|
||||||
|
type: "character varying(200)",
|
||||||
|
maxLength: 200,
|
||||||
|
nullable: true);
|
||||||
|
b.AddColumn<int>(
|
||||||
|
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<int>(
|
||||||
|
name: "FiscalProvider",
|
||||||
|
schema: "public",
|
||||||
|
table: "organizations",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
b.AddColumn<string>(
|
||||||
|
name: "FiscalApiKeyEncrypted",
|
||||||
|
schema: "public",
|
||||||
|
table: "organizations",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
b.AddColumn<string>(
|
||||||
|
name: "FiscalApiSecretEncrypted",
|
||||||
|
schema: "public",
|
||||||
|
table: "organizations",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
b.AddColumn<string>(
|
||||||
|
name: "FiscalCashboxUniqueNumber",
|
||||||
|
schema: "public",
|
||||||
|
table: "organizations",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
b.AddColumn<string>(
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
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 { api } from '@/lib/api'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
import { Button } from '@/components/Button'
|
import { Button } from '@/components/Button'
|
||||||
|
|
@ -205,6 +205,7 @@ export function OrganizationSettingsPage() {
|
||||||
{save.error && <span className="text-sm text-red-600">{(save.error as Error).message}</span>}
|
{save.error && <span className="text-sm text-red-600">{(save.error as Error).message}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<FiscalSection />
|
||||||
<TelegramSection />
|
<TelegramSection />
|
||||||
<DemoSeedSection />
|
<DemoSeedSection />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -380,3 +381,220 @@ function DemoSeedSection() {
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<FiscalSettingsDto>({
|
||||||
|
queryKey: ['/api/organization/fiscal'],
|
||||||
|
queryFn: async () => (await api.get<FiscalSettingsDto>('/api/organization/fiscal')).data,
|
||||||
|
})
|
||||||
|
const providers = useQuery<FiscalProviderOption[]>({
|
||||||
|
queryKey: ['/api/organization/fiscal/providers'],
|
||||||
|
queryFn: async () => (await api.get<FiscalProviderOption[]>('/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<FiscalSettingsDto>('/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<FiscalTestResponse>('/api/organization/fiscal/test-send', {})).data,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!form) {
|
||||||
|
return (
|
||||||
|
<section className="border-t border-slate-200 dark:border-slate-800 pt-6 mt-6">
|
||||||
|
<h2 className="text-base font-semibold flex items-center gap-2">
|
||||||
|
<Receipt className="w-4 h-4 text-emerald-600" /> ОФД (фискализация чеков)
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">Загружаю настройки…</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNone = form.provider === 0
|
||||||
|
const isMock = form.provider === 1
|
||||||
|
const needsCreds = !isNone && !isMock
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="border-t border-slate-200 dark:border-slate-800 pt-6 mt-6">
|
||||||
|
<h2 className="text-base font-semibold flex items-center gap-2">
|
||||||
|
<Receipt className="w-4 h-4 text-emerald-600" /> ОФД (фискализация чеков)
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||||
|
Передавать ли чеки оператору фискальных данных (Webkassa / Касса24 / ОФД-Соло).
|
||||||
|
Креды хранятся per-tenant и шифруются на сервере. Если фискализация
|
||||||
|
не нужна — оставьте «Без фискализации» (поведение по умолчанию).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<Field label="Провайдер">
|
||||||
|
<Select
|
||||||
|
value={String(form.provider)}
|
||||||
|
onChange={(e) => setForm({ ...form, provider: Number(e.target.value) })}
|
||||||
|
>
|
||||||
|
{providers.data?.map((p) => (
|
||||||
|
<option key={p.value} value={p.value}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
{providers.data?.find((p) => p.value === form.provider)?.description && (
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||||||
|
{providers.data.find((p) => p.value === form.provider)?.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
{needsCreds && (
|
||||||
|
<Field label="Номер кассы у оператора (CashboxUniqueNumber)">
|
||||||
|
<TextInput
|
||||||
|
value={form.cashboxUniqueNumber}
|
||||||
|
onChange={(e) => setForm({ ...form, cashboxUniqueNumber: e.target.value })}
|
||||||
|
placeholder="например, SWK00000001"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{needsCreds && (
|
||||||
|
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<Field label="ApiKey / Логин">
|
||||||
|
<TextInput
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
value={form.newApiKey}
|
||||||
|
onChange={(e) => setForm({ ...form, newApiKey: e.target.value })}
|
||||||
|
placeholder={current.data?.hasApiKey ? '•••••••• (без изменений)' : 'Введите ApiKey оператора'}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
{current.data?.hasApiKey ? 'Сохранён, шифрован DataProtection. Оставьте поле пустым — ключ не изменится.' : 'Не задан.'}
|
||||||
|
</p>
|
||||||
|
</Field>
|
||||||
|
<Field label="ApiSecret / Пароль">
|
||||||
|
<TextInput
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
value={form.newApiSecret}
|
||||||
|
onChange={(e) => setForm({ ...form, newApiSecret: e.target.value })}
|
||||||
|
placeholder={current.data?.hasApiSecret ? '•••••••• (без изменений)' : 'Введите ApiSecret оператора'}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
{current.data?.hasApiSecret ? 'Сохранён, шифрован DataProtection. Оставьте поле пустым — пароль не изменится.' : 'Не задан.'}
|
||||||
|
</p>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{needsCreds && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<Field label="Альтернативный URL API (sandbox / dev)">
|
||||||
|
<TextInput
|
||||||
|
value={form.apiBaseUrl}
|
||||||
|
onChange={(e) => setForm({ ...form, apiBaseUrl: e.target.value })}
|
||||||
|
placeholder="оставьте пустым для production"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
По умолчанию используется боевой URL оператора. Заполняй только если тебе выдали тестовый контур.
|
||||||
|
</p>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center gap-3">
|
||||||
|
<Button onClick={() => save.mutate()} disabled={save.isPending}>
|
||||||
|
<Save className="w-4 h-4" /> {save.isPending ? 'Сохраняю…' : 'Сохранить ОФД-настройки'}
|
||||||
|
</Button>
|
||||||
|
{!isNone && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => testSend.mutate()}
|
||||||
|
disabled={testSend.isPending}
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4" /> {testSend.isPending ? 'Отправляю…' : 'Тестовая отправка'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{save.isSuccess && <span className="text-sm text-emerald-600">Сохранено</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{testSend.data && (
|
||||||
|
<div className={
|
||||||
|
testSend.data.ok
|
||||||
|
? 'mt-3 rounded-md bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300 inline-flex items-start gap-2'
|
||||||
|
: 'mt-3 rounded-md bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 px-3 py-2 text-sm text-amber-700 dark:text-amber-300 inline-flex items-start gap-2'
|
||||||
|
}>
|
||||||
|
{testSend.data.ok
|
||||||
|
? <CheckCircle2 className="w-4 h-4 mt-0.5" />
|
||||||
|
: <AlertTriangle className="w-4 h-4 mt-0.5" />}
|
||||||
|
<div>
|
||||||
|
<div>{testSend.data.message}</div>
|
||||||
|
{testSend.data.ok && testSend.data.fiscalNumber && (
|
||||||
|
<div className="mt-1 text-xs">
|
||||||
|
FiscalNumber: <code>{testSend.data.fiscalNumber}</code>
|
||||||
|
{testSend.data.fiscalUrl && (
|
||||||
|
<> · <a href={testSend.data.fiscalUrl} target="_blank" rel="noreferrer" className="underline">проверить чек</a></>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
174
tests/food-market.IntegrationTests/FiscalMockFlowTests.cs
Normal file
174
tests/food-market.IntegrationTests/FiscalMockFlowTests.cs
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
using foodmarket.IntegrationTests.Support;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace foodmarket.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>End-to-end проверка фискализации через MockFiscalProvider:
|
||||||
|
/// в новой организации PUT /api/organization/fiscal с provider=1 (Mock),
|
||||||
|
/// создаём чек, проводим POST → ожидаем что в ответе и в последующем GET
|
||||||
|
/// FiscalNumber начинается с <c>MOCK-</c>.
|
||||||
|
///
|
||||||
|
/// Используем общий <see cref="ApiCollection"/> — это критично, т.к.
|
||||||
|
/// xUnit в одном процессе не любит нескольких <c>WebApplicationFactory<Program></c>
|
||||||
|
/// инстансов одновременно (бросает «entry point exited without ever
|
||||||
|
/// building an IHost»). Mock-провайдер активируется per-org через БД,
|
||||||
|
/// а не глобальным config-override'ом.</summary>
|
||||||
|
[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<JsonElement>();
|
||||||
|
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<JsonElement>()).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<JsonElement>();
|
||||||
|
|
||||||
|
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<JsonElement>();
|
||||||
|
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<JsonElement>()).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 фискальный номер не записывается");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Аналог LoyaltyFlowTests.SeedProductAsync — копия чтобы не
|
||||||
|
/// тащить кросс-теcтовые helper'ы. Создаёт товар с приёмкой на 100 шт.</summary>
|
||||||
|
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<JsonElement>()).GetProperty("id").GetString()!;
|
||||||
|
|
||||||
|
var supplier = await (await actor.Http.PostAsJsonAsync("/api/catalog/counterparties",
|
||||||
|
new { name = "Sup", type = 2 })).Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
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<JsonElement>();
|
||||||
|
(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()!);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
tests/food-market.UnitTests/FiscalMockProviderTests.cs
Normal file
76
tests/food-market.UnitTests/FiscalMockProviderTests.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>Контракт MockFiscalProvider'а: возвращает префикс MOCK-, тот же
|
||||||
|
/// чек двумя вызовами даёт тот же FiscalNumber (идемпотентность), QR содержит
|
||||||
|
/// FiscalNumber, ProviderTxId не пуст. SimulatedLatency обнулена — тест
|
||||||
|
/// должен быть мгновенным.</summary>
|
||||||
|
public class FiscalMockProviderTests
|
||||||
|
{
|
||||||
|
private static MockFiscalProvider New() => new(NullLogger<MockFiscalProvider>.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<MockFiscalProvider>.Instance)
|
||||||
|
{
|
||||||
|
SimulatedLatency = TimeSpan.FromMilliseconds(150),
|
||||||
|
};
|
||||||
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
await prov.RegisterAsync(SaleStub(), CancellationToken.None);
|
||||||
|
sw.Stop();
|
||||||
|
sw.ElapsedMilliseconds.Should().BeGreaterThanOrEqualTo(140);
|
||||||
|
}
|
||||||
|
}
|
||||||
165
tests/food-market.UnitTests/WebkassaProviderTests.cs
Normal file
165
tests/food-market.UnitTests/WebkassaProviderTests.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>Webkassa-провайдер: чистые тесты на payload-builder (без HTTP)
|
||||||
|
/// плюс end-to-end тест внутреннего метода через MockHttpHandler.
|
||||||
|
///
|
||||||
|
/// Реальный HTTP-flow (Authorize → Check) не покрываем сквозь полный
|
||||||
|
/// RegisterAsync — он зависит от per-organization кредов из БД (через
|
||||||
|
/// IServiceScopeFactory), что требует тестового DbContext'а. Для этого
|
||||||
|
/// есть integration-тест FiscalIntegrationTests с реальной БД. Здесь
|
||||||
|
/// проверяем чистую логику маппинга, которая не требует никакой инфры.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Smoke-проверка JSON-сериализации: Webkassa требует camelCase
|
||||||
|
/// поля (стандарт ASP.NET JsonSerializerDefaults.Web), проверяем что
|
||||||
|
/// сериализованный payload содержит ожидаемые ключи.</summary>
|
||||||
|
[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\":");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>HttpMessageHandler-мок: возвращает заранее настроенные ответы
|
||||||
|
/// по URL'у. Используется и юнит-тестами провайдеров, и потенциально
|
||||||
|
/// integration-тестами (через .ConfigurePrimaryHttpMessageHandler).</summary>
|
||||||
|
internal sealed class StubHttpHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
public List<HttpRequestMessage> Requests { get; } = new();
|
||||||
|
private readonly Dictionary<string, (HttpStatusCode Code, string Body)> _responses;
|
||||||
|
|
||||||
|
public StubHttpHandler(Dictionary<string, (HttpStatusCode Code, string Body)> responses)
|
||||||
|
{
|
||||||
|
_responses = responses;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> 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}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue