test/shared/accounting/ledger-tx.test.ts
Rama main · réplica de lectura del repositorio original.
/**
* `ledgerTx` — the in-transaction ledger facade — and `inOwnTx`.
*
* The leaf `…Tx` reads/writes are tested in their own modules; this locks the
* facade's own behaviour: that each `correct.X` reads its paired figure and
* posts the single adjustment that moves it onto the target, that the sign is
* right (income/revenue move WITH the target, what's owed moves AGAINST it),
* that re-submitting the same target posts nothing (the read-then-write
* idempotency the corrections rely on), and that `inOwnTx` runs a correction in
* its own transaction.
*/
import { expect } from "@std/expect";
import { describe, it as test } from "@std/testing/bdd";
import { FakeTime } from "@std/testing/time";
import { inOwnTx, ledgerTx } from "#accounting/ledger-tx.ts";
import { allTransfers } from "#accounting/queries.ts";
import { eventGroup, legReference } from "#accounting/refs.ts";
import { postTransfers } from "#accounting/store.ts";
import { type TxScope, withTransaction } from "#db/client.ts";
import { account } from "#shared/ledger/account.ts";
import { nowIso } from "#shared/now.ts";
import {
postListingSale,
postModifierLeg,
tx,
useTransactionalDb,
} from "#test-utils/ledger.ts";
const readIncome = (listingId: number): Promise<number> =>
withTransaction((scope) => ledgerTx.read.income(scope, listingId));
const readOwed = (attendeeId: number): Promise<number> =>
withTransaction((scope) => ledgerTx.read.owed(scope, attendeeId));
const readModifierRevenue = (modifierId: number): Promise<number> =>
withTransaction((scope) => ledgerTx.read.modifierRevenue(scope, modifierId));
describe("db > accounting > ledger-tx", () => {
useTransactionalDb();
test("read.income reflects sales and correct.income moves it onto the target", async () => {
await postListingSale({ attendeeId: 1, gross: 5000, listingId: 1 });
expect(await readIncome(1)).toBe(5000);
await inOwnTx(ledgerTx.correct.income)(1, 8000);
expect(await readIncome(1)).toBe(8000);
});
test("correct.income raises a figure from zero when nothing was posted yet", async () => {
// A correction from a zero baseline: the credit posted is the whole target.
await inOwnTx(ledgerTx.correct.income)(9, 700);
expect(await readIncome(9)).toBe(700);
});
test("a correction posts one adjustment leg; re-correcting to it posts none", async () => {
await postListingSale({ attendeeId: 1, gross: 5000, listingId: 1 });
const beforeCorrection = (await allTransfers()).length;
// Moving income onto a new target posts exactly one adjustment leg…
await inOwnTx(ledgerTx.correct.income)(1, 8000);
const afterFirst = (await allTransfers()).length;
expect(afterFirst).toBe(beforeCorrection + 1);
// …and re-submitting the same target computes a zero delta, so nothing else
// is posted and the figure holds.
await inOwnTx(ledgerTx.correct.income)(1, 8000);
expect((await allTransfers()).length).toBe(afterFirst);
expect(await readIncome(1)).toBe(8000);
});
/** A correction is filed under a key built from its own kind, so two kinds can
* never be taken for each other. Every expected part is spelled out and the
* key is rebuilt with the production helpers, so the check reads the same
* key a replay would. */
const expectFiledUnderKind = async (
correct: (tx: TxScope, id: number, target: number) => Promise<void>,
kind: string,
target: number,
delta: number,
): Promise<void> => {
using _time = new FakeTime(new Date("2026-06-21T00:00:00.000Z"));
await inOwnTx(correct)(4, target);
const legs = await allTransfers();
expect(legs).toHaveLength(1);
const leg = legs[0]!;
const parts = [kind, 4, delta, nowIso()];
expect(leg.reference).toBe(await legReference(parts));
expect(leg.eventGroup).toBe(await eventGroup(parts));
};
test("an income correction is filed under its own kind", async () => {
await expectFiledUnderKind(
ledgerTx.correct.income,
"income-adjust",
500,
500,
);
});
test("a modifier-revenue correction is filed under its own kind", async () => {
await expectFiledUnderKind(
ledgerTx.correct.modifierRevenue,
"modifier-revenue-adjust",
500,
500,
);
});
test("an owed correction is filed under its own kind", async () => {
// Crediting the attendee lowers what they owe, so the change is negative.
await expectFiledUnderKind(
ledgerTx.correct.owed,
"balance-adjust",
500,
-500,
);
});
test("two kinds of correction in the same millisecond both post", async () => {
// Freeze the clock so both posts share one millisecond, and use the same id
// and the same size of change, so the ONLY thing keeping the two apart is
// the kind of correction each one is. Without that, the second would hash
// identically to the first and be dropped as a replay — a modifier's
// revenue correction would silently vanish behind a listing's.
using _time = new FakeTime(new Date("2026-06-21T00:00:00.000Z"));
await inOwnTx(ledgerTx.correct.income)(4, 500);
await inOwnTx(ledgerTx.correct.modifierRevenue)(4, 500);
const legs = await allTransfers();
expect(legs.length).toBe(2);
expect(new Set(legs.map((leg) => leg.eventGroup)).size).toBe(2);
expect(new Set(legs.map((leg) => leg.reference)).size).toBe(2);
// And each figure really moved onto its own target.
expect(await readIncome(4)).toBe(500);
});
test("correct.income lowers income onto a lower target", async () => {
await postListingSale({ attendeeId: 1, gross: 5000, listingId: 1 });
// Steering down to 2000 posts a negative delta (a write-off debit) — the
// reverse direction of the raise above.
await inOwnTx(ledgerTx.correct.income)(1, 2000);
expect(await readIncome(1)).toBe(2000);
});
test("correct.owed lowers what an attendee owes onto the target", async () => {
// An unpaid sale leaves attendee 1 owing 5000.
await postTransfers([
tx({
destination: account("revenue", 1),
reference: "unpaid-sale",
source: account("attendee", 1),
}),
]);
expect(await readOwed(1)).toBe(5000);
// Owed is the account balance's NEGATION, so it moves AGAINST the target —
// steering it to 2000 credits the attendee, it does not bill them 3000 more.
await inOwnTx(ledgerTx.correct.owed)(1, 2000);
expect(await readOwed(1)).toBe(2000);
});
test("correct.owed raises what an attendee owes onto a higher target", async () => {
await postTransfers([
tx({
destination: account("revenue", 1),
reference: "unpaid-sale",
source: account("attendee", 1),
}),
]);
// Raising owed 5000 → 7000 is the reverse direction: it debits the attendee
// (owed moves against the target, so a higher target posts a negative credit).
await inOwnTx(ledgerTx.correct.owed)(1, 7000);
expect(await readOwed(1)).toBe(7000);
});
test("correct.modifierRevenue raises a modifier's net revenue onto the target", async () => {
await postModifierLeg({ delta: 1000, modifierId: 3 });
expect(await readModifierRevenue(3)).toBe(1000);
await inOwnTx(ledgerTx.correct.modifierRevenue)(3, 2500);
expect(await readModifierRevenue(3)).toBe(2500);
});
test("correct.modifierRevenue lowers a modifier's net revenue onto the target", async () => {
await postModifierLeg({ delta: 2500, modifierId: 3 });
// The reverse direction: steering revenue down to 1000 posts a negative delta.
await inOwnTx(ledgerTx.correct.modifierRevenue)(3, 1000);
expect(await readModifierRevenue(3)).toBe(1000);
});
});