food-market/src/food-market.infrastructure/Fiscal/Kassa24Provider.cs
nns 0d3ef81f72 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>
2026-06-07 02:27:17 +05:00

99 lines
5.2 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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