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>
This commit is contained in:
nns 2026-10-08 03:11:09 +05:00
parent 1e6d7fb2ab
commit 005c4ac5bf
3 changed files with 134 additions and 22 deletions

View file

@ -41,13 +41,17 @@ interface Ctx {
supplies: SupplyRec[]
pay: PayRec[]
shift?: ShiftRec
closedShifts: ShiftContrib[]
reportFrom?: string
reportTo?: string
}
/** Вклад закрытой смены в счета (для НЕЗАВИСИМого расчёта остатка в тесте):
* касса += actual − opening; эквайринг += salesCard − returnsCard. */
interface ShiftContrib { cashAcctId: string; actual: number; opening: number; acqAcctId?: string; salesCard: number; returnsCard: number }
interface Prod { id: string; name: string; price: number; cost: number; unitId: string; unitName: string; barcode: string; weighted: boolean }
interface EmpRec { id: string; email: string; password: string; role: string; token?: string }
interface SupplyRec { id: string; number?: string; total: number; supplierId: string; lines: { productId: string; quantity: number; unitPrice: number }[]; paid: number }
interface PayRec { txId: string; supplyIdx: number; amount: number; account: string }
interface PayRec { txId: string; supplyIdx: number; amount: number; account: string; accountId: string }
interface ShiftRec {
id: string; pointId: string; cashId: string; openingFloat: number
salesCash: number; salesCard: number; returnsCash: number; drawerIn: number; drawerOut: number
@ -78,6 +82,7 @@ function initCtx(ctx: Ctx) {
ctx.emp ??= {}
ctx.supplies ??= []
ctx.pay ??= []
ctx.closedShifts ??= []
ctx.cashOpening ??= 0
ctx.bankOpening ??= 0
ctx.acqOpening ??= 0
@ -133,6 +138,17 @@ async function stockOf(api: AxiosInstance, storeId: string, productId: string):
const row = items.find((i) => i.storeId === storeId && i.productId === productId)
return row ? Number(row.quantity) : 0
}
// НЕЗАВИСИМЫЙ расчёт остатка счёта (считает сам тест из своих данных, не из
// money_transactions API): начальный − расходы теста + вклад закрытых смен
// (касса += факт − размен; эквайринг += продажи картой − возвраты картой).
function indepBalance(ctx: Ctx, accountId: string | undefined, opening: number): number {
if (!accountId) return opening
const payOut = ctx.pay.filter((p) => p.accountId === accountId).reduce((a, p) => a + p.amount, 0)
const shiftCash = ctx.closedShifts.filter((s) => s.cashAcctId === accountId).reduce((a, s) => a + (s.actual - s.opening), 0)
const shiftCard = ctx.closedShifts.filter((s) => s.acqAcctId === accountId).reduce((a, s) => a + (s.salesCard - s.returnsCard), 0)
return round2(opening - payOut + shiftCash + shiftCard)
}
// helper: sum of movements for a product+store (инвариант)
async function movementsSum(api: AxiosInstance, storeId: string, productId: string): Promise<number> {
const r = await api.get(`/api/inventory/movements?productId=${productId}&storeId=${storeId}&pageSize=500`)
@ -800,16 +816,32 @@ export async function step08_supplier_payments({ ctx, step, report }: StepCtx) {
const s1 = ctx.supplies[0] // приёмка №1 (реальный поставщик)
const s2 = ctx.supplies[1] // приёмка №2 (placeholder)
// №1 — частично наличными из кассы
const partial = round2(s1.total * 0.4)
// №1 — частично наличными из кассы. Сумма НЕ должна превышать остаток кассы
// (нач. 50000, пока без движений смены): берём ≤ 30000.
const partial = round2(Math.min(s1.total * 0.4, 30_000))
const p1 = await api.post('/api/money/transactions', {
Date: new Date().toISOString(), Kind: 4 /* CashOut */, AccountId: ctx.cash1Id,
CounterpartyId: s1.supplierId, CurrencyId: ctx.currencyId, Amount: partial,
Purpose: 'Оплата приёмки №1 (частично)', Links: [{ SupplyId: s1.id, LinkedSum: partial }],
})
let ok1 = p1.status < 400 && !!p1.data?.id
if (ok1) { const post = await api.post(`/api/money/transactions/${p1.data.id}/post`, {}); ok1 = post.status < 400; ctx.pay.push({ txId: p1.data.id, supplyIdx: 0, amount: partial, account: 'cash' }); s1.paid = partial }
rec(step, 'api', 'Оплата №1 частично наличными из кассы', ok1, '204', `${p1.status}`)
if (ok1) { const post = await api.post(`/api/money/transactions/${p1.data.id}/post`, {}); ok1 = post.status < 400; ctx.pay.push({ txId: p1.data.id, supplyIdx: 0, amount: partial, account: 'cash', accountId: ctx.cash1Id! }); s1.paid = partial }
rec(step, 'api', 'Оплата №1 частично наличными из кассы (≤ остатка)', ok1, '204', `${p1.status}`)
// Отказ при оплате больше остатка кассы (нет настройки «минус»).
const over = await api.post('/api/money/transactions', {
Date: new Date().toISOString(), Kind: 4 /* CashOut */, AccountId: ctx.cash1Id,
CounterpartyId: s1.supplierId, CurrencyId: ctx.currencyId, Amount: round2(ctx.cashOpening + 100_000),
Purpose: 'Переплата (тест отказа)', Links: [{ SupplyId: s1.id, LinkedSum: 0 }], ExpenseItemId: null,
})
if (over.data?.id) {
const overPost = await api.post(`/api/money/transactions/${over.data.id}/post`, {})
rec(step, 'api', 'Оплата больше остатка кассы → отказ (409 insufficient-funds)', overPost.status === 409, 409, overPost.status)
if (overPost.status !== 409) { await api.post(`/api/money/transactions/${over.data.id}/unpost`, {}); report.bug({ step: '08', severity: 'critical', title: 'Расход больше остатка кассы прошёл (касса в минус)', detail: `post=${overPost.status}` }) }
await api.delete(`/api/money/transactions/${over.data.id}`)
} else {
rec(step, 'api', 'Оплата больше остатка кассы → отказ', over.status === 409, 409, over.status)
}
// №2 — полностью с банковского счёта
const p2 = await api.post('/api/money/transactions', {
@ -818,7 +850,7 @@ export async function step08_supplier_payments({ ctx, step, report }: StepCtx) {
Purpose: 'Оплата приёмки №2 (полностью)', Links: [{ SupplyId: s2.id, LinkedSum: s2.total }],
})
let ok2 = p2.status < 400 && !!p2.data?.id
if (ok2) { const post = await api.post(`/api/money/transactions/${p2.data.id}/post`, {}); ok2 = post.status < 400; ctx.pay.push({ txId: p2.data.id, supplyIdx: 1, amount: s2.total, account: 'bank' }); s2.paid = s2.total }
if (ok2) { const post = await api.post(`/api/money/transactions/${p2.data.id}/post`, {}); ok2 = post.status < 400; ctx.pay.push({ txId: p2.data.id, supplyIdx: 1, amount: s2.total, account: 'bank', accountId: ctx.bankId! }); s2.paid = s2.total }
rec(step, 'api', 'Оплата №2 полностью с банковского счёта', ok2, '204', `${p2.status}`)
// статусы оплаты (PayedSum)
@ -926,6 +958,16 @@ export async function step09_cashier_shift({ ctx, step, report }: StepCtx) {
rec(step, 'api', 'Z-отчёт: нал.продажи и expectedCash сходятся', zOk, `${salesCash}/${expected}`, `${z.data?.sales?.cash}/${z.data?.expectedCash}`)
ctx.shift = { id: shiftId, pointId: ctx.point1Id, cashId: ctx.cash1Id, openingFloat, salesCash, salesCard, returnsCash: returnCash, drawerIn, drawerOut, expected, actual: actualCash }
ctx.closedShifts.push({ cashAcctId: ctx.cash1Id!, actual: actualCash, opening: openingFloat, acqAcctId: ctx.acq1Id, salesCard, returnsCard: 0 })
// Движения смены попали в остаток кассы: касса выросла на (факт − размен).
const balAfter = await api.get('/api/money/reports/balances')
const cashBal = ((balAfter.data?.accounts ?? []) as { accountId: string; currentBalance: number }[]).find((a) => a.accountId === ctx.cash1Id)?.currentBalance ?? 0
const expCashAfter = round2(ctx.cashOpening - sum(ctx.pay.filter((p) => p.accountId === ctx.cash1Id).map((p) => p.amount)) + (actualCash - openingFloat))
rec(step, 'api', 'Деньги смены в остатке кассы (касса += факт − размен)', approxEq(cashBal, expCashAfter), expCashAfter, cashBal)
if (!approxEq(cashBal, expCashAfter)) report.bug({ step: '09', severity: 'critical', title: 'Движения смены не попали в остаток кассы', detail: `ожид=${expCashAfter} факт=${cashBal}` })
const acqBal = ((balAfter.data?.accounts ?? []) as { accountId: string; currentBalance: number }[]).find((a) => a.accountId === ctx.acq1Id)?.currentBalance ?? 0
rec(step, 'api', 'Продажи картой в остатке эквайринга', approxEq(acqBal, salesCard), salesCard, acqBal)
// продажа без смены → отказ (смена уже закрыта)
const noShift = await makeSale(api, ctx, { productId: prods[1].id, qty: 1, unitPrice: prods[1].price, method: 0, pointId: ctx.point1Id })
@ -990,11 +1032,13 @@ export async function step09a_points_binding({ ctx, step, report }: StepCtx) {
const exp1 = round2(1000 + sp1.paidCash)
const c1 = await api.post(`/api/money/shifts/${shift1}/close`, { ActualCash: exp1, Notes: 't1' })
rec(step, 'api', 'Точка 1: касса = размен + её продажа (деньги не смешаны)', approxEq(Number(c1.data?.expectedCash), exp1), exp1, c1.data?.expectedCash)
ctx.closedShifts.push({ cashAcctId: ctx.cash1Id!, actual: exp1, opening: 1000, acqAcctId: ctx.acq1Id, salesCard: 0, returnsCard: 0 })
}
if (shift2) {
const exp2 = round2(2000 + sp2.paidCash)
const c2 = await api.post(`/api/money/shifts/${shift2}/close`, { ActualCash: exp2, Notes: 't2' })
rec(step, 'api', 'Точка 2: касса = размен + её продажа (деньги не смешаны)', approxEq(Number(c2.data?.expectedCash), exp2), exp2, c2.data?.expectedCash)
ctx.closedShifts.push({ cashAcctId: ctx.cash2Id!, actual: exp2, opening: 2000, acqAcctId: ctx.acq2Id, salesCard: 0, returnsCard: 0 })
}
// продажа на НЕактивной точке → отказ
@ -1145,13 +1189,16 @@ export async function step11_reports({ ctx, step, report }: StepCtx) {
// отчёт «Деньги»: остаток каждого счёта = начальный + движения
const bal = await api.get('/api/money/reports/balances')
const accs = (bal.data?.accounts ?? []) as { accountId: string; openingBalance: number; currentBalance: number }[]
// касса: opening − оплата №1 наличными (partial); банк: opening − оплата №2
const paidCash = ctx.pay.filter((p) => p.account === 'cash').reduce((a, p) => a + p.amount, 0)
const paidBank = ctx.pay.filter((p) => p.account === 'bank').reduce((a, p) => a + p.amount, 0)
// остаток счёта = начальный + платежи + ДВИЖЕНИЯ СМЕН (независимый расчёт).
const cashAcc = accs.find((a) => a.accountId === ctx.cash1Id)
rec(step, 'api', 'Деньги: касса = начальный − оплата наличными', !!cashAcc && approxEq(cashAcc.currentBalance, round2(ctx.cashOpening - paidCash)), round2(ctx.cashOpening - paidCash), cashAcc?.currentBalance)
const expCash = indepBalance(ctx, ctx.cash1Id, ctx.cashOpening)
rec(step, 'api', 'Деньги: касса = начальный − оплаты + движения смен', !!cashAcc && approxEq(cashAcc.currentBalance, expCash), expCash, cashAcc?.currentBalance)
const bankAcc = accs.find((a) => a.accountId === ctx.bankId)
rec(step, 'api', 'Деньги: банк = начальный − оплата с банка', !!bankAcc && approxEq(bankAcc.currentBalance, round2(ctx.bankOpening - paidBank)), round2(ctx.bankOpening - paidBank), bankAcc?.currentBalance)
const expBank = indepBalance(ctx, ctx.bankId, ctx.bankOpening)
rec(step, 'api', 'Деньги: банк = начальный − оплата с банка', !!bankAcc && approxEq(bankAcc.currentBalance, expBank), expBank, bankAcc?.currentBalance)
const acqAcc = accs.find((a) => a.accountId === ctx.acq1Id)
const expAcq = indepBalance(ctx, ctx.acq1Id, ctx.acqOpening)
rec(step, 'api', 'Деньги: эквайринг = продажи картой − возвраты картой', !!acqAcc && approxEq(acqAcc.currentBalance, expAcq), expAcq, acqAcc?.currentBalance)
// долги поставщикам
const debts = await api.get('/api/money/reports/supplier-debts?withDebtOnly=true')
@ -1194,16 +1241,22 @@ export async function step12_invariants({ ctx, step, report }: StepCtx) {
}
if (!stockOk) report.bug({ step: '12', severity: 'critical', title: 'Остаток ≠ сумме движений склада', detail: 'см. строки шага' })
// 2) по каждому счёту: баланс ≡ начальный + Σ проведённых операций
// 2) по каждому счёту: остаток ≡ НЕЗАВИСИМО посчитанной в тесте сумме
// (начальный + платежи + движения смен), а НЕ сумме из того же API.
const bal = await api.get('/api/money/reports/balances')
const accs = (bal.data?.accounts ?? []) as { accountId: string; name: string; openingBalance: number; currentBalance: number }[]
for (const a of accs) {
const txs = await api.get(`/api/money/transactions?accountId=${a.accountId}&status=1&pageSize=500`)
const items = (txs.data?.items ?? []) as { kind: number; amount: number }[]
const inSum = sum(items.filter((t) => [1, 3, 6].includes(t.kind)).map((t) => Number(t.amount)))
const outSum = sum(items.filter((t) => [2, 4, 5].includes(t.kind)).map((t) => Number(t.amount)))
const expected = round2(Number(a.openingBalance) + inSum - outSum)
rec(step, 'api', `Инвариант счёт≡начальный+Σопераций: ${a.name.slice(0, 16)}`, approxEq(Number(a.currentBalance), expected), expected, a.currentBalance)
const known: [string | undefined, number, string][] = [
[ctx.cash1Id, ctx.cashOpening, 'Касса'],
[ctx.bankId, ctx.bankOpening, 'Банк'],
[ctx.acq1Id, ctx.acqOpening, 'Эквайринг'],
[ctx.cash2Id, 3000, 'Касса-2'],
]
for (const [acctId, opening, label] of known) {
if (!acctId) continue
const a = accs.find((x) => x.accountId === acctId)
const expected = indepBalance(ctx, acctId, opening)
rec(step, 'api', `Инвариант ${label} ≡ независимому расчёту (нач+платежи+смены)`, !!a && approxEq(Number(a.currentBalance), expected), expected, a?.currentBalance)
if (a && !approxEq(Number(a.currentBalance), expected)) report.bug({ step: '12', severity: 'critical', title: `Остаток счёта ${label} ≠ независимой формуле`, detail: `ожид=${expected} факт=${a.currentBalance}` })
}
// 3) долг поставщика ≡ формуле (пересчёт независимо)

View file

@ -96,7 +96,7 @@ public async Task Partial_then_full_payment_moves_supply_status_unpaid_partial_p
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 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);
@ -122,7 +122,7 @@ public async Task Overpaying_supply_is_rejected()
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 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.
@ -146,7 +146,7 @@ public async Task Supplier_debt_report_reflects_supplies_minus_payments()
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 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);
@ -211,6 +211,51 @@ public async Task Closing_shift_computes_expected_cash_and_difference()
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)

View file

@ -117,4 +117,18 @@ public void RolePermissions_default_denies_money()
p.MoneyEdit.Should().BeFalse();
p.CashShiftOperate.Should().BeFalse();
}
[Fact]
public void Shift_net_cash_movement_equals_actual_minus_opening()
{
// Вклад смены в кассу = продажи нал − возвраты нал + внесения − выплаты +
// расхождение = (expected − размен) + (факт − expected) = факт − размен.
// Числа — из факта стенда (смена С-2026-000001).
decimal opening = 5_000m, salesCash = 3_775m, returnsCash = 1_500m, drawerIn = 10_000m, drawerOut = 3_000m;
var expected = MoneyMath.ExpectedCash(opening, salesCash, returnsCash, drawerIn, drawerOut);
decimal actual = 14_775m;
var diff = MoneyMath.CashDifference(actual, expected);
var net = salesCash - returnsCash + drawerIn - drawerOut + diff;
net.Should().Be(actual - opening);
}
}