food-market/tests/food-market.IntegrationTests/MoneyModuleTests.cs
nns 005c4ac5bf test(money): независимый инвариант остатка счёта (нач+платежи+смены) + unit/integration
full-chain: indepBalance() считает остаток независимо (касса += факт−размен,
эквайринг += картой−возвраты) и сверяет с API /balances — а не с суммой из того же
API; шаг 8 — оплата нал ≤ остатка кассы + проверка отказа при превышении (409).
Unit: shift-net == факт−размен (MoneyMathTests). Integration: закрытие смены пишет
нал в остаток кассы, расход больше остатка → 409 (MoneyModuleTests); существующим
тестам оплаты дан достаточный остаток кассы.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:11:09 +05:00

319 lines
19 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 System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
/// <summary>Интеграционные тесты модуля «Деньги» на реальном Postgres:
/// мультитенантность, оплата приёмок (частичная/полная/переплата), долг
/// поставщика, инвариант остатка счёта, запрет продажи без открытой смены,
/// расчёт ожидаемой наличности при закрытии смены.</summary>
[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()}");
var created = await resp.Content.ReadFromJsonAsync<JsonElement>();
created.GetProperty("currentBalance").GetDecimal().Should().Be(50_000m);
// Регресс: список счетов С ДАННЫМИ не должен падать (xmin проецировался
// в памяти на AsNoTracking → 500; с пустым списком не всплывало).
using var list = await api.Http.GetAsync("/api/money/accounts?pageSize=50");
list.IsSuccessStatusCode.Should().BeTrue($"список счетов с данными должен отдаваться: {(int)list.StatusCode}");
var listed = await api.ListAsync("/api/money/accounts?pageSize=50");
listed.Select(x => x.GetProperty("id").GetString())
.Should().Contain(created.GetProperty("id").GetString());
}
[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, 1_000m);
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, 900m);
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<JsonElement>()).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, 1_000m);
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<JsonElement>();
body.GetProperty("expectedCash").GetDecimal().Should().Be(9_500m);
body.GetProperty("difference").GetDecimal().Should().Be(0m);
}
[Fact]
public async Task Closing_shift_posts_cash_sales_into_cash_account_balance()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"shbal-{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);
var shiftId = await OpenShiftAsync(api, retailPoint, cash, openingFloat: 5_000m);
var saleId = await CreateCashSaleDraftAsync(api, refs, retailPoint, product, 2m, 500m); // 1000 нал
(await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { })).EnsureSuccessStatusCode();
(await api.Http.PostAsJsonAsync($"/api/money/shifts/{shiftId}/close", new { actualCash = 6_000m, notes = (string?)null })).EnsureSuccessStatusCode();
// Остаток кассы = 0 (нач. счёта) + (6000 факт − 5000 размен) = 1000 (продажа нал).
var balances = await api.GetJsonAsync("/api/money/reports/balances");
var row = balances.GetProperty("accounts").EnumerateArray().First(x => x.GetProperty("accountId").GetString() == cash);
row.GetProperty("currentBalance").GetDecimal().Should().Be(1_000m);
}
[Fact]
public async Task Cash_expense_over_balance_is_rejected()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"neg-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var cash = await api.CreateMoneyAccountAsync("Касса", Cash, refs.CurrencyId, refs.StoreId, 5_000m);
var items = await api.ListAsync("/api/money/expense-items?pageSize=50");
var expenseItemId = items.First().GetProperty("id").GetString();
var created = await api.Http.PostAsJsonAsync("/api/money/transactions", new
{
date = DateTime.UtcNow, kind = 4, accountId = cash, currencyId = refs.CurrencyId,
amount = 10_000m, expenseItemId, purpose = "big",
});
created.EnsureSuccessStatusCode();
var txId = (await created.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
// 10 000 > остаток 5 000 → 409 (орг не разрешает минус).
var post = await api.Http.PostAsJsonAsync($"/api/money/transactions/{txId}/post", new { });
((int)post.StatusCode).Should().Be(409);
}
// ── 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<JsonElement>()).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<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/money/transactions/{txId}/post", new { })).EnsureSuccessStatusCode();
}
private static async Task<string> SupplyPaymentStatusAsync(ApiActor api, string supplyId)
=> (await api.GetJsonAsync($"/api/purchases/supplies/{supplyId}")).GetProperty("paymentStatus").GetString()!;
private static async Task<string> 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<JsonElement>()).GetProperty("id").GetString()!;
}
private static async Task<string> 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()!;
}
}