using System.Net.Http.Json; using System.Text.Json; using FluentAssertions; using foodmarket.IntegrationTests.Support; using Xunit; namespace foodmarket.IntegrationTests; /// Интеграционные тесты модуля «Деньги» на реальном Postgres: /// мультитенантность, оплата приёмок (частичная/полная/переплата), долг /// поставщика, инвариант остатка счёта, запрет продажи без открытой смены, /// расчёт ожидаемой наличности при закрытии смены. [Collection(ApiCollection.Name)] public class MoneyModuleTests { private readonly ApiFactory _factory; public MoneyModuleTests(ApiFactory factory) => _factory = factory; private static string RandomBarcode() => string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10))); // Cash=1, Safe=2, Bank=3, Acquiring=4 ; CashIn=3, CashOut=4 private const int Cash = 1, Bank = 3; [Fact] public async Task Tenant_B_cannot_see_tenant_A_money_accounts() { var a = new ApiActor(_factory.CreateClient()); await a.SignupAndLoginAsync($"money-a-{Guid.NewGuid():N}"); var refsA = await a.LoadRefsAsync(); var accA = await a.CreateMoneyAccountAsync("Касса А", Cash, refsA.CurrencyId, refsA.StoreId, 10_000m); var b = new ApiActor(_factory.CreateClient()); await b.SignupAndLoginAsync($"money-b-{Guid.NewGuid():N}"); var listB = await b.ListAsync("/api/money/accounts?pageSize=200"); listB.Select(x => x.GetProperty("id").GetString()).Should().NotContain(accA, "орг Б не должна видеть счета орг А (query-filter по OrganizationId)"); // Прямой GET чужого счёта — 404 (фильтр прячет как «нет такого»). using var direct = await b.Http.GetAsync($"/api/money/accounts/{accA}"); ((int)direct.StatusCode).Should().Be(404); } [Fact] public async Task Account_create_accepts_date_only_opening_date_from_ui() { // UI (DateField) шлёт openingDate как "YYYY-MM-DD" (Kind=Unspecified). // Регресс: такой DateTime Npgsql не писал в timestamptz → 500. Проверяем // что контроллер нормализует к UTC (MoneyMath.AsUtc) и создаёт счёт. var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"dateonly-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); using var resp = await api.Http.PostAsJsonAsync("/api/money/accounts", new { name = "Касса (date-only)", kind = Cash, bankName = (string?)null, accountNumber = (string?)null, storeId = refs.StoreId, currencyId = refs.CurrencyId, openingBalance = 50_000m, openingDate = "2026-10-07", // ← строго date-only isActive = true, isDefault = false, }); resp.IsSuccessStatusCode.Should().BeTrue($"date-only openingDate должен приниматься: {(int)resp.StatusCode} {await resp.Content.ReadAsStringAsync()}"); (await resp.Content.ReadFromJsonAsync()).GetProperty("currentBalance").GetDecimal().Should().Be(50_000m); } [Fact] public async Task Account_balance_invariant_opening_plus_posted_movements() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"bal-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); var acc = await api.CreateMoneyAccountAsync("Сейф", 2, refs.CurrencyId, null, 100_000m); // CashIn 30 000 (проведён) + CashOut 12 000 (проведён) → остаток 118 000. await PostTxAsync(api, kind: 3, accountId: acc, amount: 30_000m, refs.CurrencyId); await PostTxAsync(api, kind: 4, accountId: acc, amount: 12_000m, refs.CurrencyId, needExpenseItem: true, api2: api); var balances = await api.GetJsonAsync("/api/money/reports/balances"); var row = balances.GetProperty("accounts").EnumerateArray().First(x => x.GetProperty("accountId").GetString() == acc); row.GetProperty("currentBalance").GetDecimal().Should().Be(118_000m); } [Fact] public async Task Partial_then_full_payment_moves_supply_status_unpaid_partial_paid() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"pay-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); var supplier = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}"); var product = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 150m, RandomBarcode()); var cash = await api.CreateMoneyAccountAsync("Касса", Cash, refs.CurrencyId, refs.StoreId, 0m); var (supplyId, total) = await api.CreatePostedSupplyAsync(refs, supplier, product, qty: 10m, unitPrice: 100m); total.Should().Be(1000m); (await SupplyPaymentStatusAsync(api, supplyId)).Should().Be("unpaid"); // Частичная оплата 400 из кассы (CashOut, привязка к приёмке). await PaySupplyAsync(api, cash, refs.CurrencyId, supplyId, 400m); (await SupplyPaymentStatusAsync(api, supplyId)).Should().Be("partial"); // Добивка 600 → полностью оплачено. await PaySupplyAsync(api, cash, refs.CurrencyId, supplyId, 600m); var paid = await api.GetJsonAsync($"/api/purchases/supplies/{supplyId}"); paid.GetProperty("paymentStatus").GetString().Should().Be("paid"); paid.GetProperty("payedSum").GetDecimal().Should().Be(1000m); } [Fact] public async Task Overpaying_supply_is_rejected() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"over-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); var supplier = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}"); var product = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 150m, RandomBarcode()); var cash = await api.CreateMoneyAccountAsync("Касса", Cash, refs.CurrencyId, refs.StoreId, 0m); var (supplyId, _) = await api.CreatePostedSupplyAsync(refs, supplier, product, 5m, 100m); // total 500 // Платёж на 800 с привязкой 800 → при проведении переплата → 409. var created = await api.Http.PostAsJsonAsync("/api/money/transactions", new { date = DateTime.UtcNow, kind = 4, accountId = cash, currencyId = refs.CurrencyId, amount = 800m, purpose = "overpay", links = new[] { new { supplyId, linkedSum = 800m } }, }); created.EnsureSuccessStatusCode(); var txId = (await created.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); using var post = await api.Http.PostAsJsonAsync($"/api/money/transactions/{txId}/post", new { }); ((int)post.StatusCode).Should().Be(409, "переплата приёмки должна блокироваться"); } [Fact] public async Task Supplier_debt_report_reflects_supplies_minus_payments() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"debt-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); var supplier = await api.CreateCounterpartyAsync($"Долговой-{Guid.NewGuid():N}"); var product = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 150m, RandomBarcode()); var cash = await api.CreateMoneyAccountAsync("Касса", Cash, refs.CurrencyId, refs.StoreId, 0m); var (supplyId, _) = await api.CreatePostedSupplyAsync(refs, supplier, product, 10m, 100m); // 1000 await PaySupplyAsync(api, cash, refs.CurrencyId, supplyId, 300m); var report = await api.GetJsonAsync("/api/money/reports/supplier-debts?withDebtOnly=false"); var row = report.GetProperty("rows").EnumerateArray().First(x => x.GetProperty("supplierId").GetString() == supplier); row.GetProperty("debt").GetDecimal().Should().Be(700m, "1000 приёмка − 300 оплачено"); } [Fact] public async Task Sale_without_open_shift_is_rejected_then_allowed_after_open() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"shift-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); var supplier = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}"); var product = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 500m, RandomBarcode()); await api.CreatePostedSupplyAsync(refs, supplier, product, 10m, 100m); // даём остаток var retailPoint = await api.CreateRetailPointAsync(refs.StoreId, "Точка 1"); var cash = await api.CreateMoneyAccountAsync("Касса точки", Cash, refs.CurrencyId, refs.StoreId, 0m); // Продажа на точке БЕЗ смены → 409 cash-shift-required. var saleId = await CreateCashSaleDraftAsync(api, refs, retailPoint, product, qty: 1m, price: 500m); using (var noShift = await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { })) { ((int)noShift.StatusCode).Should().Be(409); (await noShift.Content.ReadAsStringAsync()).Should().Contain("cash-shift-required"); } // Открываем смену и повторяем — проходит. await OpenShiftAsync(api, retailPoint, cash, openingFloat: 0m); using var ok = await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { }); ok.IsSuccessStatusCode.Should().BeTrue($"после открытия смены продажа должна провестись: {(int)ok.StatusCode} {await ok.Content.ReadAsStringAsync()}"); } [Fact] public async Task Closing_shift_computes_expected_cash_and_difference() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"close-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); var supplier = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}"); var product = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 500m, RandomBarcode()); await api.CreatePostedSupplyAsync(refs, supplier, product, 10m, 100m); var retailPoint = await api.CreateRetailPointAsync(refs.StoreId, "Точка 2"); var cash = await api.CreateMoneyAccountAsync("Касса точки", Cash, refs.CurrencyId, refs.StoreId, 0m); var shiftId = await OpenShiftAsync(api, retailPoint, cash, openingFloat: 10_000m); // Продажа 1 шт за 500 наличными. var saleId = await CreateCashSaleDraftAsync(api, refs, retailPoint, product, 1m, 500m); (await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { })).EnsureSuccessStatusCode(); // Внесение 1 000 + выплата (инкассация) 2 000. (await api.Http.PostAsJsonAsync($"/api/money/shifts/{shiftId}/drawer", new { direction = 1, amount = 1_000m, reason = "Размен" })).EnsureSuccessStatusCode(); (await api.Http.PostAsJsonAsync($"/api/money/shifts/{shiftId}/drawer", new { direction = 2, amount = 2_000m, reason = "Инкассация" })).EnsureSuccessStatusCode(); // expected = 10 000 + 500 − 0 + 1 000 − 2 000 = 9 500. Факт 9 500 → diff 0. using var close = await api.Http.PostAsJsonAsync($"/api/money/shifts/{shiftId}/close", new { actualCash = 9_500m, notes = (string?)null }); close.EnsureSuccessStatusCode(); var body = await close.Content.ReadFromJsonAsync(); body.GetProperty("expectedCash").GetDecimal().Should().Be(9_500m); body.GetProperty("difference").GetDecimal().Should().Be(0m); } // ── helpers ───────────────────────────────────────────────────────────── private static async Task PostTxAsync(ApiActor api, int kind, string accountId, decimal amount, string currencyId, bool needExpenseItem = false, ApiActor? api2 = null) { string? expenseItemId = null; if (needExpenseItem) { var items = await api.ListAsync("/api/money/expense-items?pageSize=50"); expenseItemId = items.First().GetProperty("id").GetString(); } var created = await api.Http.PostAsJsonAsync("/api/money/transactions", new { date = DateTime.UtcNow, kind, accountId, currencyId, amount, expenseItemId, purpose = "test", }); created.EnsureSuccessStatusCode(); var txId = (await created.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); (await api.Http.PostAsJsonAsync($"/api/money/transactions/{txId}/post", new { })).EnsureSuccessStatusCode(); } private static async Task PaySupplyAsync(ApiActor api, string accountId, string currencyId, string supplyId, decimal amount) { var created = await api.Http.PostAsJsonAsync("/api/money/transactions", new { date = DateTime.UtcNow, kind = 4, accountId, currencyId, amount, purpose = "Оплата приёмки", links = new[] { new { supplyId, linkedSum = amount } }, }); created.EnsureSuccessStatusCode(); var txId = (await created.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); (await api.Http.PostAsJsonAsync($"/api/money/transactions/{txId}/post", new { })).EnsureSuccessStatusCode(); } private static async Task SupplyPaymentStatusAsync(ApiActor api, string supplyId) => (await api.GetJsonAsync($"/api/purchases/supplies/{supplyId}")).GetProperty("paymentStatus").GetString()!; private static async Task CreateCashSaleDraftAsync(ApiActor api, ApiActor.Refs refs, string retailPointId, string productId, decimal qty, decimal price) { var draft = await api.Http.PostAsJsonAsync("/api/sales/retail", new { date = DateTime.UtcNow, storeId = refs.StoreId, retailPointId, customerId = (string?)null, currencyId = refs.CurrencyId, payment = 0, paidCash = qty * price, paidCard = 0m, notes = "shift-test", lines = new[] { new { productId, quantity = qty, unitPrice = price, discount = 0m, vatPercent = 0m } }, }); draft.EnsureSuccessStatusCode(); return (await draft.Content.ReadFromJsonAsync()).GetProperty("id").GetString()!; } private static async Task OpenShiftAsync(ApiActor api, string retailPointId, string cashAccountId, decimal openingFloat) { var resp = await api.Http.PostAsJsonAsync("/api/money/shifts/open", new { retailPointId, cashAccountId, acquiringAccountId = (string?)null, openingFloat, }); var body = await resp.Content.ReadAsStringAsync(); if (!resp.IsSuccessStatusCode) throw new InvalidOperationException($"OpenShift failed {(int)resp.StatusCode}: {body}"); return JsonDocument.Parse(body).RootElement.GetProperty("id").GetString()!; } }