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>
166 lines
7.3 KiB
C#
166 lines
7.3 KiB
C#
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}"),
|
||
});
|
||
}
|
||
}
|