test(money): unit (MoneyMath 25) + integration (7 на Postgres) + MoneyMath refactor
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions

- MoneyMath: вынесены чистые формулы (статус оплаты, ожидаемая наличность,
  разница смены, остаток счёта, долг поставщика, защита от переплаты);
  сервис/SuppliesController/MoneyReportsController теперь зовут их.
- UnitTests/MoneyMathTests: 25 кейсов на формулы + money-флаги RolePermissions.
- IntegrationTests/MoneyModuleTests (реальный Postgres, 7 [Fact]):
  мультитенантность (Б не видит счета А), инвариант остатка, частичная→полная
  оплата приёмки, переплата→409, долг поставщика, продажа без смены→409 +
  после open→ok, расчёт expected/difference при закрытии смены.
- ApiActor: helpers CreateRetailPoint/CreateMoneyAccount/CreatePostedSupply.
- tests/e2e/scrape-moysklad.ts — скрейпер для docs/money-moysklad-reference.md.

Прогон: UnitTests 25/25 ✓, IntegrationTests money 7/7 ✓ (33s, Testcontainers).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-07 22:11:19 +05:00
parent 830cd73ee0
commit deef65d819
8 changed files with 544 additions and 14 deletions

View file

@ -1,5 +1,6 @@
using foodmarket.Api.Infrastructure.Authorization;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Application.Money;
using foodmarket.Domain.Money;
using foodmarket.Domain.Purchases;
using foodmarket.Infrastructure.Persistence;
@ -57,7 +58,7 @@ public async Task<ActionResult<BalancesReport>> Balances(CancellationToken ct)
|| m.Kind == MoneyTransactionKind.TransferOut)).Sum(m => m.Sum);
return new AccountBalanceRow(a.Id, a.Name, a.Kind,
currencies.TryGetValue(a.CurrencyId, out var code) ? code : "",
a.OpeningBalance, a.OpeningBalance + inSum - outSum, a.IsActive);
a.OpeningBalance, MoneyMath.AccountBalance(a.OpeningBalance, inSum, outSum), a.IsActive);
}).ToList();
return new BalancesReport(rows, rows.Sum(r => r.CurrentBalance));
@ -169,10 +170,11 @@ public record SupplierDebtsReport(IReadOnlyList<SupplierDebtRow> Rows, decimal T
var r = returns.FirstOrDefault(x => x.SupplierId == sup.Id);
var a = adjustments.FirstOrDefault(x => x.CounterpartyId == sup.Id);
// Долг = (приёмки − оплаченные приёмки) − (возвраты − полученные обратно деньги) + (корр: мы должны − они должны).
var debt = (s?.Total ?? 0) - (s?.Paid ?? 0)
- ((r?.Total ?? 0) - (r?.Paid ?? 0))
+ (a?.WeOwe ?? 0) - (a?.TheyOwe ?? 0);
if (withDebtOnly && Math.Abs(debt) < 0.0001m) continue;
var debt = MoneyMath.SupplierDebt(
s?.Total ?? 0, s?.Paid ?? 0,
r?.Total ?? 0, r?.Paid ?? 0,
a?.WeOwe ?? 0, a?.TheyOwe ?? 0);
if (withDebtOnly && Math.Abs(debt) < MoneyMath.Eps) continue;
rows.Add(new SupplierDebtRow(
sup.Id, sup.Name, sup.Bin, sup.Phone,
s?.Total ?? 0, r?.Total ?? 0,

View file

@ -43,11 +43,7 @@ public record SupplyListRow(
/// "unpaid" (0), "partial" (0 &lt; payed &lt; total), "paid" (payed ≥ total).
/// Фронт показывает цветной бейдж. Расчёт здесь — простейший порог.</summary>
private static string ComputePaymentStatus(decimal total, decimal payed)
{
if (total <= 0) return "paid";
if (payed <= 0) return "unpaid";
return payed + 0.0001m >= total ? "paid" : "partial";
}
=> foodmarket.Application.Money.MoneyMath.PaymentStatus(total, payed);
public record SupplyLineDto(
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle,

View file

@ -0,0 +1,54 @@
namespace foodmarket.Application.Money;
/// <summary>Чистые денежные формулы модуля «Деньги» — вынесены отдельно, чтобы
/// (а) посчитать одинаково в сервисе, контроллерах и отчётах и (б) покрыть
/// unit-тестами без БД. Все суммы — decimal, порог сравнения 0.0001 (мы храним
/// NUMERIC(18,4)).</summary>
public static class MoneyMath
{
/// <summary>Порог «копеечной» погрешности для сравнений сумм.</summary>
public const decimal Eps = 0.0001m;
/// <summary>Агрегатный статус оплаты документа (приёмки/возврата):
/// <c>"unpaid"</c> | <c>"partial"</c> | <c>"paid"</c>. Нулевой/отрицательный
/// total считаем оплаченным (нечего платить).</summary>
public static string PaymentStatus(decimal total, decimal payed)
{
if (total <= 0) return "paid";
if (payed <= 0) return "unpaid";
return payed + Eps >= total ? "paid" : "partial";
}
/// <summary>Ожидаемая наличность в кассе при закрытии смены:
/// разменник + продажи наличными − возвраты наличными + внесения − выплаты.</summary>
public static decimal ExpectedCash(decimal openingFloat, decimal salesCash,
decimal returnsCash, decimal drawerIn, decimal drawerOut)
=> openingFloat + salesCash - returnsCash + drawerIn - drawerOut;
/// <summary>Разница при закрытии смены: факт − ожидание. &lt;0 — недостача,
/// &gt;0 — излишек.</summary>
public static decimal CashDifference(decimal actualCash, decimal expectedCash)
=> actualCash - expectedCash;
/// <summary>Текущий остаток счёта = начальный + Σ приходов − Σ расходов
/// (только проведённые движения; переводы учитываются как обычные in/out).</summary>
public static decimal AccountBalance(decimal openingBalance, decimal inSum, decimal outSum)
=> openingBalance + inSum - outSum;
/// <summary>Долг перед поставщиком:
/// (приёмки − оплаченные приёмки) − (возвраты − возвращённые деньги)
/// + (корректировки: «мы должны» − «они должны»).
/// Плюс — мы должны поставщику; минус — переплата/он должен нам.</summary>
public static decimal SupplierDebt(
decimal suppliesTotal, decimal suppliesPaid,
decimal returnsTotal, decimal returnsPaid,
decimal adjustmentsWeOwe, decimal adjustmentsTheyOwe)
=> (suppliesTotal - suppliesPaid)
- (returnsTotal - returnsPaid)
+ (adjustmentsWeOwe - adjustmentsTheyOwe);
/// <summary>Можно ли довнести <paramref name="addSum"/> к документу, не
/// переплатив (payed + add ≤ total + ε).</summary>
public static bool CanApplyPayment(decimal currentPayed, decimal addSum, decimal total)
=> currentPayed + addSum <= total + Eps;
}

View file

@ -71,7 +71,7 @@ public async Task<MoneyServiceResult> PostAsync(Guid transactionId, Guid current
{
var supply = await _db.Supplies.FirstAsync(s => s.Id == sId, ct);
var newPayed = supply.PayedSum + link.LinkedSum;
if (newPayed > supply.Total + 0.0001m)
if (!MoneyMath.CanApplyPayment(supply.PayedSum, link.LinkedSum, supply.Total))
return MoneyServiceResult.Fail(MoneyServiceStatus.OverpaidDocument, "supply-overpaid",
$"Приёмка {supply.Number} была бы переплачена: {newPayed:0.##} > {supply.Total:0.##}.");
supply.PayedSum = newPayed;
@ -81,7 +81,7 @@ public async Task<MoneyServiceResult> PostAsync(Guid transactionId, Guid current
{
var sr = await _db.SupplierReturns.FirstAsync(s => s.Id == srId, ct);
var newPayed = sr.PayedSum + link.LinkedSum;
if (newPayed > sr.Total + 0.0001m)
if (!MoneyMath.CanApplyPayment(sr.PayedSum, link.LinkedSum, sr.Total))
return MoneyServiceResult.Fail(MoneyServiceStatus.OverpaidDocument, "supplier-return-overpaid",
$"Возврат поставщику {sr.Number} был бы переплачен: {newPayed:0.##} > {sr.Total:0.##}.");
sr.PayedSum = newPayed;
@ -252,7 +252,7 @@ public async Task<MoneyServiceResult> UnpostAsync(Guid transactionId, Guid curre
var returnsCard = sales.Where(s => s.IsReturn).Sum(s => s.PaidCard);
var drawerIn = shift.DrawerOperations.Where(o => o.Direction == CashDrawerDirection.In).Sum(o => o.Amount);
var drawerOut = shift.DrawerOperations.Where(o => o.Direction == CashDrawerDirection.Out).Sum(o => o.Amount);
var expected = shift.OpeningFloat + salesCash - returnsCash + drawerIn - drawerOut;
var expected = MoneyMath.ExpectedCash(shift.OpeningFloat, salesCash, returnsCash, drawerIn, drawerOut);
shift.SalesCash = salesCash;
shift.SalesCard = salesCard;
@ -262,7 +262,7 @@ public async Task<MoneyServiceResult> UnpostAsync(Guid transactionId, Guid curre
shift.DrawerOut = drawerOut;
shift.ExpectedCash = expected;
shift.ActualCash = actualCash;
shift.CashDifference = actualCash - expected;
shift.CashDifference = MoneyMath.CashDifference(actualCash, expected);
shift.Status = CashShiftStatus.Closed;
shift.ClosedAt = DateTime.UtcNow;
shift.ClosedByUserId = closedByUserId;

View file

@ -0,0 +1,74 @@
// Scrape MoySklad REST API docs for sections relevant to the money module.
// Headless-Chromium нужен потому что doc SPA рендерит разделы JS-ом.
// Запуск: cd tests/e2e && npx tsx scrape-moysklad.ts
import { chromium } from "playwright";
import { writeFile, mkdir } from "node:fs/promises";
import { dirname } from "node:path";
const targets: { slug: string; label: string }[] = [
{ slug: "#/documents/payment-in", label: "Входящий платёж (безнал)" },
{ slug: "#/documents/payment-out", label: "Исходящий платёж (безнал)" },
{ slug: "#/documents/cashin", label: "Приходный ордер (наличные)" },
{ slug: "#/documents/cashout", label: "Расходный ордер (наличные)" },
{ slug: "#/documents/retaildrawercashin", label: "Внесение денег в кассу" },
{ slug: "#/documents/retaildrawercashout", label: "Выплата денег из кассы" },
{ slug: "#/documents/retailshift", label: "Розничная смена" },
{ slug: "#/documents/counterpartyadjustment", label: "Корректировка взаиморасчётов" },
{ slug: "#/reports/report-money", label: "Отчёт «Деньги»" },
{ slug: "#/reports/report-counterparty", label: "Показатели контрагентов" },
{ slug: "#/dictionaries/organization", label: "Юрлицо / счета юрлица" },
];
const BASE = "https://dev.moysklad.ru/doc/api/remap/1.2/";
const OUT = "/tmp/moysklad-dump";
async function dumpOne(page: import("playwright").Page, slug: string, label: string): Promise<string> {
const url = BASE + slug;
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45_000 });
// SPA рендер — ждём, пока в DOM появятся заголовки раздела.
await page.waitForTimeout(2500);
// Снимаем контент самой длинной «статьи» (у них разметка меняется от раздела).
const text = await page.evaluate(() => {
// Пытаемся найти самую большую семантическую зону — main/article или просто body.
const candidates = Array.from(document.querySelectorAll("main, article, section, .markdown-section, .content, body"));
let best: Element = document.body;
let bestLen = 0;
for (const el of candidates) {
const len = (el as HTMLElement).innerText?.length ?? 0;
if (len > bestLen) {
bestLen = len;
best = el;
}
}
return (best as HTMLElement).innerText;
});
return `=== ${label} (${slug}) ===\nURL: ${url}\n\n${text}\n\n`;
}
async function main() {
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 }, locale: "ru-RU" });
const page = await ctx.newPage();
await mkdir(OUT, { recursive: true });
for (const t of targets) {
const safe = t.slug.replace(/[^a-z0-9]+/gi, "_");
console.log("→", t.slug);
try {
const dump = await dumpOne(page, t.slug, t.label);
const path = `${OUT}/${safe}.txt`;
await mkdir(dirname(path), { recursive: true });
await writeFile(path, dump, "utf8");
console.log(" wrote", path, dump.length, "chars");
} catch (e: any) {
console.error(" FAILED", t.slug, e?.message);
}
}
await browser.close();
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View file

@ -0,0 +1,244 @@
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_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<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, 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<JsonElement>();
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<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()!;
}
}

View file

@ -139,6 +139,46 @@ public async Task<string> CreateProductAsync(Refs refs, string name, decimal ret
return JsonDocument.Parse(body).RootElement.GetProperty("id").GetString()!;
}
// ── Money module helpers (Sprint 33) ───────────────────────────────────
public async Task<string> CreateRetailPointAsync(string storeId, string name)
{
var resp = await Http.PostAsJsonAsync("/api/catalog/retail-points", new { name, code = (string?)null, storeId });
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode) throw new InvalidOperationException($"CreateRetailPoint failed {(int)resp.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.GetProperty("id").GetString()!;
}
/// <summary>Создаёт счёт/кассу. kind: 1=Cash,2=Safe,3=Bank,4=Acquiring.</summary>
public async Task<string> CreateMoneyAccountAsync(string name, int kind, string currencyId,
string? storeId = null, decimal openingBalance = 0m, string? accountNumber = null)
{
var resp = await Http.PostAsJsonAsync("/api/money/accounts", new
{
name, kind, bankName = (string?)null, accountNumber,
storeId, currencyId, openingBalance,
openingDate = DateTime.UtcNow, isActive = true, isDefault = false,
});
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode) throw new InvalidOperationException($"CreateMoneyAccount failed {(int)resp.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.GetProperty("id").GetString()!;
}
/// <summary>Создаёт и проводит приёмку, возвращает (supplyId, total).</summary>
public async Task<(string Id, decimal Total)> CreatePostedSupplyAsync(Refs refs, string supplierId,
string productId, decimal qty, decimal unitPrice)
{
var draft = await Http.PostAsJsonAsync("/api/purchases/supplies", new
{
date = DateTime.UtcNow, supplierId, storeId = refs.StoreId, currencyId = refs.CurrencyId,
notes = (string?)null, lines = new[] { new { productId, quantity = qty, unitPrice } },
});
draft.EnsureSuccessStatusCode();
var supplyId = (await draft.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
using var post = await Http.PostAsJsonAsync($"/api/purchases/supplies/{supplyId}/post", new { });
post.EnsureSuccessStatusCode();
return (supplyId, qty * unitPrice);
}
public async Task<decimal> StockOfAsync(string storeId, string productId)
{
var list = await ListAsync($"/api/inventory/stock?productId={productId}&pageSize=200");

View file

@ -0,0 +1,120 @@
using FluentAssertions;
using foodmarket.Application.Money;
using foodmarket.Domain.Organizations;
using Xunit;
namespace foodmarket.UnitTests;
/// <summary>Чистые денежные формулы модуля «Деньги» (<see cref="MoneyMath"/>):
/// статус оплаты приёмки, ожидаемая наличность смены, инвариант остатка счёта,
/// долг поставщика, защита от переплаты. Эти же формулы использует сервис,
/// контроллеры и отчёты — тест фиксирует их поведение без БД.</summary>
public class MoneyMathTests
{
// ── Статус оплаты приёмки ────────────────────────────────────────────
[Theory]
[InlineData(1000, 0, "unpaid")]
[InlineData(1000, 400, "partial")]
[InlineData(1000, 999, "partial")]
[InlineData(1000, 999.9999, "paid")] // в пределах ε считаем оплаченным
[InlineData(1000, 1000, "paid")]
[InlineData(1000, 1200, "paid")] // переплату отображаем как «оплачено»
[InlineData(0, 0, "paid")] // платить нечего
[InlineData(-5, 0, "paid")]
public void PaymentStatus_classifies(decimal total, decimal payed, string expected)
=> MoneyMath.PaymentStatus(total, payed).Should().Be(expected);
// ── Ожидаемая наличность смены ───────────────────────────────────────
[Fact]
public void ExpectedCash_is_float_plus_cash_sales_minus_returns_plus_in_minus_out()
{
// разменник 20 000 + продажи нал 50 000 − возвраты нал 3 000
// + внесения 1 000 − выплаты (инкассация) 40 000 = 28 000
MoneyMath.ExpectedCash(20_000m, 50_000m, 3_000m, 1_000m, 40_000m)
.Should().Be(28_000m);
}
[Fact]
public void ExpectedCash_empty_shift_equals_opening_float()
=> MoneyMath.ExpectedCash(15_000m, 0, 0, 0, 0).Should().Be(15_000m);
[Theory]
[InlineData(10_000, 10_000, 0)] // ровно сошлось
[InlineData(9_500, 10_000, -500)] // недостача
[InlineData(10_300, 10_000, 300)] // излишек
public void CashDifference_is_actual_minus_expected(decimal actual, decimal expected, decimal diff)
=> MoneyMath.CashDifference(actual, expected).Should().Be(diff);
// ── Инвариант остатка счёта ──────────────────────────────────────────
[Fact]
public void AccountBalance_is_opening_plus_in_minus_out()
=> MoneyMath.AccountBalance(100_000m, 75_000m, 40_000m).Should().Be(135_000m);
[Fact]
public void AccountBalance_can_go_negative()
=> MoneyMath.AccountBalance(0m, 0m, 5_000m).Should().Be(-5_000m);
// ── Долг поставщика ──────────────────────────────────────────────────
[Fact]
public void SupplierDebt_supplies_minus_paid()
{
// 3 приёмки на 300 000, оплачено 100 000, возвратов/корректировок нет.
MoneyMath.SupplierDebt(300_000m, 100_000m, 0, 0, 0, 0)
.Should().Be(200_000m);
}
[Fact]
public void SupplierDebt_returns_reduce_debt()
{
// приёмки 300 000 (оплачено 300 000), возврат на 50 000 (деньги назад не получили)
// → поставщик должен нам 50 000 → долг −50 000.
MoneyMath.SupplierDebt(300_000m, 300_000m, 50_000m, 0m, 0, 0)
.Should().Be(-50_000m);
}
[Fact]
public void SupplierDebt_starting_adjustment_adds_debt()
{
// стартовый долг «мы должны» 500 000 при переходе на систему.
MoneyMath.SupplierDebt(0, 0, 0, 0, 500_000m, 0)
.Should().Be(500_000m);
}
[Fact]
public void SupplierDebt_full_formula()
{
// приёмки 1 000 000 (оплачено 600 000) − возвраты 100 000 (вернули деньги 100 000)
// + корр.(мы должны 50 000 − они должны 20 000)
// = 400 000 − 0 + 30 000 = 430 000
MoneyMath.SupplierDebt(1_000_000m, 600_000m, 100_000m, 100_000m, 50_000m, 20_000m)
.Should().Be(430_000m);
}
// ── Защита от переплаты ──────────────────────────────────────────────
[Theory]
[InlineData(0, 1000, 1000, true)] // первая полная оплата
[InlineData(400, 600, 1000, true)] // добивка ровно до total
[InlineData(400, 601, 1000, false)] // чуть больше остатка — переплата
[InlineData(1000, 0.00005, 1000, true)] // в пределах ε
public void CanApplyPayment_guards_overpay(decimal payed, decimal add, decimal total, bool ok)
=> MoneyMath.CanApplyPayment(payed, add, total).Should().Be(ok);
// ── Права роли ───────────────────────────────────────────────────────
[Fact]
public void RolePermissions_All_includes_money_flags()
{
var p = RolePermissions.All();
p.MoneyView.Should().BeTrue();
p.MoneyEdit.Should().BeTrue();
p.CashShiftOperate.Should().BeTrue();
}
[Fact]
public void RolePermissions_default_denies_money()
{
var p = new RolePermissions();
p.MoneyView.Should().BeFalse();
p.MoneyEdit.Should().BeFalse();
p.CashShiftOperate.Should().BeFalse();
}
}