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 ILogger<RetailSalesController> _log;
|
||||
private readonly foodmarket.Api.Realtime.INotificationsPublisher _notify;
|
||||
private readonly foodmarket.Application.Common.Fiscal.IFiscalProviderFactory _fiscal;
|
||||
|
||||
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;
|
||||
_stock = stock;
|
||||
_log = log;
|
||||
_notify = notify;
|
||||
_fiscal = fiscal;
|
||||
}
|
||||
|
||||
public record RetailSaleListRow(
|
||||
|
|
@ -63,7 +66,12 @@ public record RetailSaleDto(
|
|||
decimal LoyaltyPointsAccrued = 0m,
|
||||
Guid? PromotionId = null,
|
||||
string? PromotionCode = null,
|
||||
decimal PromotionDiscount = 0m);
|
||||
decimal PromotionDiscount = 0m,
|
||||
// Sprint 11: ОФД-снапшоты. Null до фискализации / при провайдере None.
|
||||
string? FiscalNumber = null,
|
||||
string? FiscalQrCode = null,
|
||||
string? FiscalUrl = null,
|
||||
int? FiscalProviderKind = null);
|
||||
|
||||
public record RetailSaleLineInput(
|
||||
Guid ProductId,
|
||||
|
|
@ -594,6 +602,14 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
|||
"RetailSale posted: {SaleNumber} store={StoreId} payment={Payment} lines={LinesCount} total={Total}",
|
||||
sale.Number, sale.StoreId, sale.Payment, sale.Lines.Count, sale.Total);
|
||||
|
||||
// ── Sprint 11: фискализация чека у ОФД-оператора ─────────────────
|
||||
// Делаем ПОСЛЕ commit'а stock-транзакции — фискальный RPC может
|
||||
// занимать секунды (Webkassa в час пик), удерживать всю серию
|
||||
// блокировок ради этого нельзя. Если оператор недоступен, чек
|
||||
// остаётся проведённым (Posted=true), а фискальный номер просто
|
||||
// пуст — это допустимо (можно перепровести post вручную).
|
||||
await TryFiscalizeAsync(sale, ct);
|
||||
|
||||
// SignalR-уведомление в группу org. Кассирa берём из CashierId если
|
||||
// есть Employee.Name по UserId, иначе из User.Email (короткая часть до @).
|
||||
try
|
||||
|
|
@ -652,6 +668,63 @@ private async Task NotifyLowStockAfterSaleAsync(RetailSale sale, CancellationTok
|
|||
}
|
||||
}
|
||||
|
||||
/// <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")]
|
||||
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
|
||||
{
|
||||
|
|
@ -999,6 +1072,8 @@ orderby l.SortOrder
|
|||
lines,
|
||||
// Sprint 9
|
||||
row.s.LoyaltyCardId, row.s.LoyaltyBonusApplied, row.s.LoyaltyPointsAccrued,
|
||||
row.s.PromotionId, row.s.PromotionCode, row.s.PromotionDiscount);
|
||||
row.s.PromotionId, row.s.PromotionCode, row.s.PromotionDiscount,
|
||||
// Sprint 11
|
||||
row.s.FiscalNumber, row.s.FiscalQrCode, row.s.FiscalUrl, row.s.FiscalProviderKind);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -165,6 +165,29 @@
|
|||
// на каждой отправке без рестарта приложения.
|
||||
builder.Services.AddSingleton<foodmarket.Application.Common.Email.IEmailSender,
|
||||
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}} —
|
||||
// см. Resources/EmailTemplates/*.html. Singleton с in-memory cache.
|
||||
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. Описания ведут единицы магазинов; обычно текстовая колонка
|
||||
/// просто захламляет карточку.</summary>
|
||||
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 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
|
||||
|
|
|
|||
|
|
@ -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.PromotionDiscount).HasPrecision(18, 4);
|
||||
e.Property(x => x.PromotionCode).HasMaxLength(40);
|
||||
|
||||
// Sprint 11: фискальные снапшоты от ОФД-оператора.
|
||||
e.Property(x => x.FiscalNumber).HasMaxLength(100);
|
||||
e.Property(x => x.FiscalQrCode).HasMaxLength(2000);
|
||||
e.Property(x => x.FiscalUrl).HasMaxLength(2000);
|
||||
e.Property(x => x.FiscalProviderTxId).HasMaxLength(200);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Save, Sparkles } from 'lucide-react'
|
||||
import { Save, Sparkles, Receipt, AlertTriangle, CheckCircle2, Send } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { Button } from '@/components/Button'
|
||||
|
|
@ -205,6 +205,7 @@ export function OrganizationSettingsPage() {
|
|||
{save.error && <span className="text-sm text-red-600">{(save.error as Error).message}</span>}
|
||||
</div>
|
||||
|
||||
<FiscalSection />
|
||||
<TelegramSection />
|
||||
<DemoSeedSection />
|
||||
</div>
|
||||
|
|
@ -380,3 +381,220 @@ function DemoSeedSection() {
|
|||
</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