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>
319 lines
19 KiB
C#
319 lines
19 KiB
C#
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()!;
|
||
}
|
||
}
|