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_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()!;
}
}