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:
nns 2026-06-07 02:27:17 +05:00
parent 786dacb081
commit 0d3ef81f72
19 changed files with 2129 additions and 4 deletions

184
docs/ofd-integration.md Normal file
View 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
View 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).

View file

@ -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();
}

View file

@ -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);
} }
} }

View file

@ -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>();

View 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);
}

View file

@ -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; }
} }

View file

@ -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

View file

@ -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&lt;IFiscalProvider&gt;</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;
}
}

View 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);
}
}
}

View 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));
}
}

View 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);
}
}
}

View 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; }
}
}

View file

@ -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);
}); });
} }
} }

View file

@ -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");
}
}
}

View file

@ -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>
)
}

View 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&lt;Program&gt;</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 — копия чтобы не
/// тащить кросс-теовые 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()!);
}
}

View 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);
}
}

View 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}"),
});
}
}