test/shared/accounting/mappers.test.ts
Rama main · réplica de lectura del repositorio original.
import { assertStringIncludes } from "@std/assert";
import { expect } from "@std/expect";
import { it as test } from "@std/testing/bdd";
import {
attendeeAccount,
BOOKING_FEE_INCOME,
modifierAccount,
revenueAccount,
TAX_VAT,
WORLD,
} from "#accounting/accounts.ts";
import { mapBooking } from "#accounting/mappers.ts";
import { balanceOf } from "#shared/ledger/project.ts";
import type { Transfer, TransferInput } from "#shared/ledger/types.ts";
import {
asTransfer,
facts,
paidBookingNettingToZero,
soleLegOf,
} from "#test/shared/accounting/mappers/fixtures.ts";
import { rejectionMessage } from "#test-utils/assertions.ts";
import { describeWithEnv } from "#test-utils/db.ts";
describeWithEnv("accounting > mapBooking", { encryptionKey: true }, () => {
const theModifierLeg = soleLegOf("modifier");
/** Asserts a single-listing VAT booking's common shape: the ex-VAT sale, the
* vat leg amount, and the attendee's outstanding balance including the tax. */
const expectVatBooking = (
legs: Transfer[],
vatAmount: number,
attendeeOwed: number,
): void => {
expect(soleLegOf("sale")(legs).amount).toBe(10000);
expect(soleLegOf("vat")(legs).amount).toBe(vatAmount);
expect(balanceOf(attendeeAccount(3))(legs)).toBe(attendeeOwed);
};
test("books gross, modifiers, fee and payment; a paid booking nets to zero", async () => {
const legs = (await mapBooking(facts(paidBookingNettingToZero))).map(
asTransfer,
);
// 8000 gross + 200 surcharge + 150 fee − 500 discount − 7850 paid = 0
expect(balanceOf(attendeeAccount(3))(legs)).toBe(0);
expect(balanceOf(revenueAccount(1))(legs)).toBe(5000);
expect(balanceOf(revenueAccount(2))(legs)).toBe(3000);
expect(balanceOf(modifierAccount(10))(legs)).toBe(-500); // contra (discount)
expect(balanceOf(modifierAccount(11))(legs)).toBe(200); // surcharge revenue
expect(balanceOf(BOOKING_FEE_INCOME)(legs)).toBe(150);
expect(balanceOf(WORLD)(legs)).toBe(-7850);
});
test("posts a surcharge as a positive attendee→modifier leg, a discount the other way", async () => {
// balanceOf can't tell the two branches apart — flipping the ends and
// negating the amount nets the same — so assert the leg's direction and its
// positive amount directly.
const surcharge = theModifierLeg(
await mapBooking(facts({ modifiers: [{ delta: 200, modifierId: 11 }] })),
);
expect(surcharge.amount).toBe(200);
expect(surcharge.source).toEqual(attendeeAccount(3));
expect(surcharge.destination).toEqual(modifierAccount(11));
const discount = theModifierLeg(
await mapBooking(
facts({
lines: [{ gross: 1000, listingId: 1 }],
modifiers: [{ delta: -500, modifierId: 10 }],
}),
),
);
expect(discount.amount).toBe(500);
expect(discount.source).toEqual(modifierAccount(10));
expect(discount.destination).toEqual(attendeeAccount(3));
});
test("leaves a deposit booking owing the remainder", async () => {
const legs = (
await mapBooking(
facts({ amountPaid: 2000, lines: [{ gross: 10000, listingId: 1 }] }),
)
).map(asTransfer);
expect(balanceOf(attendeeAccount(3))(legs)).toBe(-8000); // owes £80
});
test("shares one event group and emits a distinct reference per leg", async () => {
const legs = await mapBooking(
facts({ amountPaid: 5000, lines: [{ gross: 5000, listingId: 1 }] }),
);
expect(new Set(legs.map((l) => l.eventGroup)).size).toBe(1);
expect(new Set(legs.map((l) => l.reference)).size).toBe(legs.length);
});
test("is deterministic across calls (idempotent references)", async () => {
const input = facts({
amountPaid: 5000,
lines: [{ gross: 5000, listingId: 1 }],
});
const first = await mapBooking(input);
const second = await mapBooking(input);
expect(first.map((l) => l.reference)).toEqual(
second.map((l) => l.reference),
);
});
test("aggregates multiple lines for one listing into a single sale leg", async () => {
const legs = await mapBooking(
facts({
amountPaid: 5000,
lines: [
{ gross: 3000, listingId: 1 },
{ gross: 2000, listingId: 1 }, // discount split — same listing
],
}),
);
const sales = legs.filter((l) => l.kind === "sale");
expect(sales.length).toBe(1);
expect(sales[0]!.amount).toBe(5000);
expect(new Set(legs.map((l) => l.reference)).size).toBe(legs.length);
});
test("rejects a negative line gross", async () => {
assertStringIncludes(
await rejectionMessage(
mapBooking(facts({ lines: [{ gross: -100, listingId: 1 }] })),
),
"negative listing 1 gross",
);
});
test("rejects a negative booking fee", async () => {
assertStringIncludes(
await rejectionMessage(mapBooking(facts({ bookingFee: -10 }))),
"negative bookingFee",
);
});
test("rejects a negative amount paid", async () => {
assertStringIncludes(
await rejectionMessage(mapBooking(facts({ amountPaid: -50 }))),
"negative amountPaid",
);
});
test("rejects a non-finite (NaN) gross", async () => {
assertStringIncludes(
await rejectionMessage(
mapBooking(facts({ lines: [{ gross: Number.NaN, listingId: 1 }] })),
),
"non-finite listing 1 gross",
);
});
test("rejects a fractional line gross (minor units must be integers)", async () => {
assertStringIncludes(
await rejectionMessage(
mapBooking(facts({ lines: [{ gross: 10.5, listingId: 1 }] })),
),
"non-integer listing 1 gross",
);
});
test("rejects a fractional modifier delta", async () => {
assertStringIncludes(
await rejectionMessage(
mapBooking(facts({ modifiers: [{ delta: -2.5, modifierId: 7 }] })),
),
"non-integer modifier 7 delta",
);
});
test("rejects a non-finite modifier delta", async () => {
assertStringIncludes(
await rejectionMessage(
mapBooking(
facts({ modifiers: [{ delta: Number.NaN, modifierId: 7 }] }),
),
),
"non-finite modifier 7 delta",
);
});
test("rejects an empty event id", async () => {
assertStringIncludes(
await rejectionMessage(mapBooking(facts({ eventId: "" }))),
"empty eventId",
);
});
test("rejects a whitespace-only event id", async () => {
assertStringIncludes(
await rejectionMessage(mapBooking(facts({ eventId: " " }))),
"empty eventId",
);
});
test("drops zero-amount legs (a free booking posts nothing)", async () => {
const legs = await mapBooking(
facts({
lines: [{ gross: 0, listingId: 1 }],
modifiers: [{ delta: 0, modifierId: 10 }],
}),
);
expect(legs).toEqual([]);
});
test("posts every one-unit leg — the smallest money there is", async () => {
// A gross, fee and payment of exactly 1 minor unit must survive the
// zero-drop guard, not fall through it.
const legs: readonly TransferInput[] = await mapBooking(
facts({
amountPaid: 1,
bookingFee: 1,
lines: [{ gross: 1, listingId: 1 }],
}),
);
expect(soleLegOf("sale")(legs).amount).toBe(1);
expect(soleLegOf("fee")(legs).amount).toBe(1);
expect(soleLegOf("payment")(legs).amount).toBe(1);
});
test("posts a one-unit surcharge and discount at their smallest size", async () => {
const surcharge = theModifierLeg(
await mapBooking(facts({ modifiers: [{ delta: 1, modifierId: 11 }] })),
);
expect(surcharge.amount).toBe(1);
expect(surcharge.source).toEqual(attendeeAccount(3));
const discount = theModifierLeg(
await mapBooking(
facts({
lines: [{ gross: 2, listingId: 1 }],
modifiers: [{ delta: -1, modifierId: 10 }],
}),
),
);
expect(discount.amount).toBe(1);
expect(discount.destination).toEqual(attendeeAccount(3));
});
test("joins every invalid fact into one report, comma-separated", async () => {
expect(
await rejectionMessage(
mapBooking(facts({ amountPaid: -50, bookingFee: -10, eventId: "" })),
),
).toBe(
"mapBooking: invalid facts (empty eventId, negative bookingFee, negative amountPaid)",
);
});
test("adds a vat leg on top in add-to-total mode: sale stays ex-VAT, total includes the tax", async () => {
const legs = (
await mapBooking(
facts({
amountPaid: 0,
lines: [{ gross: 10000, listingId: 1 }],
vat: { mode: "add_to_total", rateBasisPoints: 2000 },
}),
)
).map(asTransfer);
expectVatBooking(legs, 2000, -12000);
const vat = soleLegOf("vat")(legs);
expect(vat.source).toEqual(attendeeAccount(3));
expect(vat.destination).toEqual(TAX_VAT);
expect(balanceOf(revenueAccount(1))(legs)).toBe(10000);
expect(balanceOf(TAX_VAT)(legs)).toBe(2000);
});
test("splits VAT out of an inclusive total so income stays ex-VAT", async () => {
const legs = (
await mapBooking(
facts({
amountPaid: 0,
lines: [{ gross: 12000, listingId: 1 }],
vat: { mode: "calculate_from_total", rateBasisPoints: 2000 },
}),
)
).map(asTransfer);
// 12000 includes 20% VAT: 10000 ex-VAT sale + 2000 tax.
expectVatBooking(legs, 2000, -12000);
expect(balanceOf(revenueAccount(1))(legs)).toBe(10000);
expect(balanceOf(TAX_VAT)(legs)).toBe(2000);
});
test("splits an inclusive VAT total across several sale legs by largest remainder", async () => {
const legs = (
await mapBooking(
facts({
amountPaid: 0,
lines: [
{ gross: 6000, listingId: 1 },
{ gross: 6000, listingId: 2 },
],
vat: { mode: "calculate_from_total", rateBasisPoints: 2000 },
}),
)
).map(asTransfer);
expect(soleLegOf("vat")(legs).amount).toBe(2000);
const saleAmounts = legs
.filter((leg) => leg.kind === "sale")
.map((leg) => leg.amount)
.sort((a, b) => a - b);
expect(saleAmounts).toEqual([5000, 5000]);
expect(balanceOf(attendeeAccount(3))(legs)).toBe(-12000);
});
test("keeps discount legs whole when splitting an inclusive total", async () => {
const legs = (
await mapBooking(
facts({
amountPaid: 0,
lines: [{ gross: 12000, listingId: 1 }],
modifiers: [
{ delta: -1000, modifierId: 10 }, // discount
{ delta: 200, modifierId: 11 }, // surcharge
],
vat: { mode: "calculate_from_total", rateBasisPoints: 2000 },
}),
)
).map(asTransfer);
// Taxable base = 12000 − 1000 + 200 = 11200 → 1867 VAT at 20% inclusive.
expect(soleLegOf("vat")(legs).amount).toBe(1867);
expect(soleLegOf("sale")(legs).amount).toBe(10164);
// The discount leg stays whole; the surcharge leg is reduced by its share.
expect(balanceOf(modifierAccount(10))(legs)).toBe(-1000);
expect(balanceOf(modifierAccount(11))(legs)).toBe(169);
expect(balanceOf(attendeeAccount(3))(legs)).toBe(-11200);
});
test("charges VAT on the net modifier base, not the booking fee", async () => {
const legs = (
await mapBooking(
facts({
amountPaid: 12640,
bookingFee: 1000,
lines: [{ gross: 10000, listingId: 1 }],
modifiers: [
{ delta: -500, modifierId: 10 },
{ delta: 200, modifierId: 11 },
],
vat: { mode: "add_to_total", rateBasisPoints: 2000 },
}),
)
).map(asTransfer);
// Taxable base = 10000 + 200 − 500 = 9700 → 1940 VAT at 20%.
expect(soleLegOf("vat")(legs).amount).toBe(1940);
expect(balanceOf(revenueAccount(1))(legs)).toBe(10000);
expect(balanceOf(TAX_VAT)(legs)).toBe(1940);
// A fully-paid VAT booking nets the attendee to zero: tickets 9700
// + 1000 fee + 1940 VAT, all paid.
expect(balanceOf(attendeeAccount(3))(legs)).toBe(0);
});
test("posts no vat leg when a discount wipes out the taxable base", async () => {
const legs = await mapBooking(
facts({
amountPaid: 0,
lines: [{ gross: 10000, listingId: 1 }],
modifiers: [{ delta: -10000, modifierId: 10 }],
vat: { mode: "add_to_total", rateBasisPoints: 2000 },
}),
);
expect(legs.some((leg) => leg.kind === "vat")).toBe(false);
});
test("rejects a negative VAT rate", async () => {
assertStringIncludes(
await rejectionMessage(
mapBooking(
facts({
lines: [{ gross: 10000, listingId: 1 }],
vat: { mode: "add_to_total", rateBasisPoints: -1 },
}),
),
),
"invalid tax policy",
);
});
});