import { expect } from "@std/expect"; import { beforeEach, it as test } from "@std/testing/bdd"; import { attendeeAccount, revenueAccount, WORLD, } from "#accounting/accounts.ts"; import { backfillTransfers } from "#accounting/backfill.ts"; import { type BookingFacts, mapBooking } from "#accounting/mappers.ts"; import { accountBalance, allTransfers, transfersByAccount, } from "#accounting/queries.ts"; import { postTransfers } from "#accounting/store.ts"; import type { ListingBooking } from "#db/attendee-types.ts"; import { attendeesApi } from "#db/attendees/api.ts"; import { getDb } from "#db/client.ts"; import { enableQueryLog, getQueryLog } from "#db/query-log.ts"; import type { AccountRef, Transfer } from "#shared/ledger/types.ts"; import { expectIncludesAll } from "#test-utils/assertions.ts"; import { seedPreDropLedgerColumns, stampHistoricalPricePaid, } from "#test-utils/db/migration-test-helpers.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; import { withRequestContext } from "#test-utils/request-context.ts"; /** Flag a historical booking line refunded, the way a pre-ledger DB recorded a * provider refund before the column was projected from the ledger. */ const flagRefunded = ( attendeeId: number, listingId: number, ): Promise => getDb().execute({ args: [attendeeId, listingId], sql: "UPDATE listing_attendees SET refunded = 1 WHERE attendee_id = ? AND listing_id = ?", }); /** Create a historical paid booking with NO ledger legs (pre-dual-write). The * email is overridable so a test can seed many distinct historical attendees. */ const historicalBooking = async ( bookings: ListingBooking[], email = "a@b.c", ) => { const result = await attendeesApi.createAttendeeAtomic({ bookings, email, name: "Historical", }); if (!result.success) throw new Error(`setup failed: ${result.reason}`); const attendee = result.attendees[0]!; // A pre-ledger row carried its amount in the price_paid column — the backfill's // only source. createAttendeeAtomic no longer writes it (amounts live in the // ledger now), so stamp the restored column directly to reproduce that history. for (const booking of bookings) { await stampHistoricalPricePaid( attendee.id, booking.listingId, booking.pricePaid ?? 0, ); } return attendee; }; /** Post one live-style booking leg-set, as the dual-write path would have. */ const postLiveBooking = async ( overrides: Partial, ): Promise => { await postTransfers( await mapBooking({ amountPaid: 5000, attendeeId: 1, bookingFee: 0, eventId: "live-session", lines: [{ gross: 5000, listingId: 1 }], modifiers: [], occurredAt: "2026-06-21T00:00:00.000Z", ...overrides, }), ); }; const refundCashOf = (legs: Transfer[]): Transfer[] => legs.filter((leg) => leg.kind === "refund_cash"); const seedBooking = async (pricePaid: number = 5000) => { const listing = await createTestListing({ maxAttendees: 5 }); const attendee = await historicalBooking([ { listingId: listing.id, pricePaid }, ]); return { attendee, listing }; }; const seedTwoListingOrder = async () => { const first = await createTestListing({ maxAttendees: 5 }); const second = await createTestListing({ maxAttendees: 5 }); const attendee = await historicalBooking([ { listingId: first.id, pricePaid: 3000 }, { listingId: second.id, pricePaid: 2000 }, ]); return { attendee, first, second }; }; /** One attendee holding a paid line and a free line flagged refunded — the * free line's flag must not read as the order being refunded. */ const seedPaidLineWithFlaggedFreeLine = async () => { const listing = await createTestListing({ maxAttendees: 5 }); const free = await createTestListing({ maxAttendees: 5 }); const attendee = await historicalBooking([ { listingId: listing.id, pricePaid: 3000 }, { listingId: free.id, pricePaid: 0 }, ]); await flagRefunded(attendee.id, free.id); return { attendee, listing }; }; const expectBalances = async ( expected: ReadonlyArray, ): Promise => { for (const [account, balance] of expected) { expect(await accountBalance(account)).toBe(balance); } }; const expectRefundCash = async ( attendeeId: number, amount: number = 5000, ): Promise => { const cash = refundCashOf( await transfersByAccount(attendeeAccount(attendeeId)), ); expect(cash.length).toBe(1); expect(cash[0]!.amount).toBe(amount); return cash; }; const expectRowStampedWithSaleEventGroup = async ( attendeeId: number, sale: Transfer, ): Promise => { const row = ( await getDb().execute({ args: [attendeeId], sql: "SELECT ledger_event_group FROM listing_attendees WHERE attendee_id = ?", }) ).rows[0]!; expect(String(row.ledger_event_group)).toBe(sale.eventGroup); }; const expectBackfillWritesNothing = async (): Promise => { const before = (await allTransfers()).length; await backfillTransfers(); expect((await allTransfers()).length).toBe(before); }; describeWithEnv("accounting > backfill", { db: true }, () => { // The backfill reads listing_attendees.refunded, which a later migration drops; // restore it so each test exercises the schema the migration runs against. beforeEach(seedPreDropLedgerColumns); test("reconstructs sale + payment for a paid booking", async () => { const { listing, attendee } = await seedBooking(); expect((await allTransfers()).length).toBe(0); // no legs yet await backfillTransfers(); expect(await accountBalance(revenueAccount(listing.id))).toBe(5000); expect(await accountBalance(attendeeAccount(attendee.id))).toBe(0); // paid full expect(await accountBalance(WORLD)).toBe(-5000); // cash in }); test("walks every page when paid attendees exceed the page size", async () => { const listing = await createTestListing({ maxAttendees: 10 }); // Five historical paid bookings; with a page size of 2 the cursor must SET to // each page's last id, not accumulate — an `afterId +=` cursor over-advances // past the final page and silently skips its attendees. for (let i = 0; i < 5; i++) { await historicalBooking( [{ listingId: listing.id, pricePaid: 5000 }], `paid-${i}@b.c`, ); } await backfillTransfers(2); // 3 pages of [2, 2, 1] // Every £50 sale is recognised; a broken cursor leaves income short of £250. expect(await accountBalance(revenueAccount(listing.id))).toBe(25000); expect(await accountBalance(WORLD)).toBe(-25000); }); test("stamps each row's ledger_event_group with its booking event group", async () => { // The per-row amount-paid projection keys on ledger_event_group, so the // backfill must stamp it with the order's booking event group (the sale leg's). const { attendee } = await seedBooking(); await backfillTransfers(); const sale = (await transfersByAccount(attendeeAccount(attendee.id))).find( (leg) => leg.kind === "sale", )!; expect(sale.eventGroup).not.toBe(""); await expectRowStampedWithSaleEventGroup(attendee.id, sale); }); test("groups a multi-listing booking into one order (sales + one payment)", async () => { const { first, second, attendee } = await seedTwoListingOrder(); await backfillTransfers(); expect(await accountBalance(revenueAccount(first.id))).toBe(3000); expect(await accountBalance(revenueAccount(second.id))).toBe(2000); expect(await accountBalance(attendeeAccount(attendee.id))).toBe(0); const legs = await transfersByAccount(attendeeAccount(attendee.id)); // Two sales + one payment, all under one event group. expect(legs.length).toBe(3); expect(new Set(legs.map((leg) => leg.eventGroup)).size).toBe(1); expect(legs.filter((leg) => leg.kind === "payment").length).toBe(1); }); test("is idempotent — a second run writes nothing", async () => { await seedBooking(); await backfillTransfers(); await expectBackfillWritesNothing(); }); test("reverses a fully-refunded booking back to zero revenue", async () => { const { listing, attendee } = await seedBooking(); await flagRefunded(attendee.id, listing.id); await backfillTransfers(); await expectBalances([ [revenueAccount(listing.id), 0], // reversed [attendeeAccount(attendee.id), 0], ]); const cash = await expectRefundCash(attendee.id); expect(cash[0]!.destination).toEqual(WORLD); }); test("reverses the whole order when any line is flagged refunded", async () => { // A multi-listing order is one provider payment; refunding it returns the // whole payment, but a historical refund flagged only the listing the admin // acted on. Any flagged line therefore means the whole order was refunded. const { first, second, attendee } = await seedTwoListingOrder(); await flagRefunded(attendee.id, first.id); // one line flagged → whole order await backfillTransfers(); await expectBalances([ [revenueAccount(first.id), 0], // reversed [revenueAccount(second.id), 0], // reversed [attendeeAccount(attendee.id), 0], ]); await expectRefundCash(attendee.id); // the whole payment returned }); test("a refunded flag on a FREE line does not reverse the paid order", async () => { // The rows the backfill reads are the PAID lines: a free sibling's flag // says nothing about the order's money, so no reversal is posted. const { attendee, listing } = await seedPaidLineWithFlaggedFreeLine(); await backfillTransfers(); await expectBalances([ [revenueAccount(listing.id), 3000], // still earned [attendeeAccount(attendee.id), 0], // paid in full ]); expect( refundCashOf(await transfersByAccount(attendeeAccount(attendee.id))), ).toEqual([]); }); test("pages the historical attendees and rows in database order", async () => { // The id cursor advances to each page's last id, so unordered rows would // strand attendees behind the cursor and silently skip their ledger // backfill. The statements themselves must carry the ordering. const listing = await createTestListing({ maxAttendees: 5 }); await historicalBooking([{ listingId: listing.id, pricePaid: 5000 }]); await withRequestContext(async () => { enableQueryLog(); await backfillTransfers(); const ran = getQueryLog() .map((entry) => entry.sql) .join("\n"); expectIncludesAll(ran, [ "ORDER BY attendee.attendee_id LIMIT ?", "ORDER BY listingAttendee.attendee_id, listingAttendee.listing_id", ]); }); }); test("skips an attendee that already carries ledger legs (no double-post)", async () => { const { listing, attendee } = await seedBooking(); // The live dual-write path already recorded this booking under its own event. await postLiveBooking({ attendeeId: attendee.id, lines: [{ gross: 5000, listingId: listing.id }], }); await expectBackfillWritesNothing(); const legs = await transfersByAccount(attendeeAccount(attendee.id)); const groups = new Set(legs.map((leg) => leg.eventGroup)); expect(groups.size).toBe(1); // only the live booking group, no backfill group // Deploy-order robustness: the already-ledgered branch still stamps the // row→event link from the existing sale leg, so the per-row amount-paid // projection resolves even though no legs were re-posted. const sale = legs.find((leg) => leg.kind === "sale")!; await expectRowStampedWithSaleEventGroup(attendee.id, sale); }); test("posts a page of attendees in a bounded number of round-trips", async () => { // Regression: the backfill once issued one write batch per attendee, so a // real site's booking history blew the Bunny edge isolate's subrequest budget // mid-migration — the isolate was evicted with the migration lock still held, // turning into endless 503s. A page must cost O(1) round-trips, not O(N). const listing = await createTestListing({ maxAttendees: 50 }); const attendeeCount = 12; for (let i = 0; i < attendeeCount; i++) { await historicalBooking( [{ listingId: listing.id, pricePaid: 1000 + i }], `att${i}@b.c`, ); } await withRequestContext(async () => { enableQueryLog(); await backfillTransfers(); // Every statement in one batch shares its round-trip's start timestamp, so // distinct start times count round-trips. A page is a few reads plus one // packed write batch — far below the 12 a per-attendee batch would cost. const roundTrips = new Set(getQueryLog().map((q) => q.startedAtMs)).size; expect(roundTrips).toBeLessThanOrEqual(6); }); }); test("skips rows with no payment", async () => { await seedBooking(0); await backfillTransfers(); expect((await allTransfers()).length).toBe(0); }); test("throws on an unparseable booking time rather than guessing", async () => { const { attendee } = await seedBooking(); await getDb().execute({ args: [attendee.id], sql: "UPDATE attendees SET created = 'not a date' WHERE id = ?", }); await expect(backfillTransfers()).rejects.toThrow( "unparseable created time", ); }); });