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>
99 lines
5.2 KiB
C#
99 lines
5.2 KiB
C#
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);
|
||
}
|
||
}
|
||
}
|