From 3002bdeb5117e45ccd373397bf039d26ca6e9ece Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 12:20:43 +0000 Subject: [PATCH 01/16] =?UTF-8?q?WIP:=20move=20the=20money=20journeys=20to?= =?UTF-8?q?=20Cucumber=20=E2=80=94=20coverage=20not=20yet=20checked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten money tests become two stories: refunding a booking (8 scenarios) and free bookings with repeated actions (5 scenarios). The shared money drivers move out of the e2e folder into test-utils/money, since the stories and the remaining direct tests both use them. Coverage and mutation runs still to come. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ga3qdj1PzXyZTcjmXZRqz7 --- .../payments/free-and-repeated-money.feature | 65 +++ specs/payments/refunding-a-booking.feature | 102 ++++ test/e2e/accounting/_shared.ts | 12 - .../accounting/bulk-merges-and-pwyw.test.ts | 10 +- .../accounting/free-and-idempotency.test.ts | 230 --------- .../accounting/income-and-corrections.test.ts | 10 +- .../refunds-and-conservation.test.ts | 322 ------------ test/specs/steps/money-actions.ts | 236 +++++++++ test/specs/steps/refunds.ts | 476 ++++++++++++++++++ test/specs/support/money.ts | 101 ++++ test/specs/support/world.ts | 2 + .../money}/drivers.ts | 0 .../money/reads.ts} | 14 +- 13 files changed, 1005 insertions(+), 575 deletions(-) create mode 100644 specs/payments/free-and-repeated-money.feature create mode 100644 specs/payments/refunding-a-booking.feature delete mode 100644 test/e2e/accounting/_shared.ts delete mode 100644 test/e2e/accounting/free-and-idempotency.test.ts delete mode 100644 test/e2e/accounting/refunds-and-conservation.test.ts create mode 100644 test/specs/steps/money-actions.ts create mode 100644 test/specs/steps/refunds.ts create mode 100644 test/specs/support/money.ts rename test/{e2e/accounting => test-utils/money}/drivers.ts (100%) rename test/{e2e/accounting/ledger-helpers.ts => test-utils/money/reads.ts} (93%) diff --git a/specs/payments/free-and-repeated-money.feature b/specs/payments/free-and-repeated-money.feature new file mode 100644 index 0000000000..a61979f5e3 --- /dev/null +++ b/specs/payments/free-and-repeated-money.feature @@ -0,0 +1,65 @@ +@story:payments.free-and-repeated-money +@owner:payments @risk:high +@actor:organiser @actor:customer +@edition:managed @edition:self-hosted +Feature: Free bookings and repeated money actions + A free booking must never invent money, a refund the provider turns down must + never pretend to have happened, and doing the same thing twice must never + count it twice. The organiser can also see how the two income figures on a + listing are worked out. + + @rule:payments.a-free-booking-records-no-money + @surface:admin + Rule: A free booking records no money at all + Even with a booking fee set up, nothing is charged and nothing is recorded. + + @case:payment.free-booking-records-no-money + Scenario: A customer books a free place while a booking fee is set up + Given the site adds a 10 percent booking fee + When a customer books a free Free Meetup place + Then no money is recorded for the booking + And no booking fee is recorded + + @rule:payments.a-turned-down-refund-changes-nothing + @surface:admin + Rule: A refund the provider turns down changes nothing + The organiser is told it failed, and the books are exactly as they were. + + @case:payment.declined-refund-changes-nothing + Scenario: The payment provider turns down the refund + Given a customer paid 45.00 for a Show place + When the organiser asks for a refund and the provider turns it down + Then the organiser is told the refund failed + And the Show has still earned 45.00 and no money was handed back + + @rule:payments.doing-it-twice-counts-once + @surface:admin + Rule: Doing the same thing twice counts it only once + A repeated payment message makes no second booking, and re-saving the same + income figure makes no second correction. + + @case:payment.replayed-payment-counts-once + Scenario: The same payment message arrives again + Given a customer paid 60.00 for a Repeat place + When the same payment message arrives again + Then there is still one booking and one sale + + @case:payment.repeated-correction-counts-once + Scenario: The organiser saves the same income figure twice + Given a customer paid 60.00 for a Repeat place + When the organiser sets the Repeat income to 40.00 twice + Then the Repeat has earned 40.00 from a single correction + + @rule:payments.the-income-figures-are-explained + @surface:admin + Rule: The listing page explains how its income figures are worked out + Sales, corrections and refunds are each listed with their own sign, so the + two income figures can never quietly disagree. + + @case:payment.income-breakdown-explains-the-figures + Scenario: The organiser reads the money breakdown after a correction and a refund + Given a customer paid 50.00 for a Reconciled place + And the organiser corrected the Reconciled income to 40.00 + When the organiser refunds the booking + Then the Reconciled page breaks the money down line by line + And the breakdown links to the Reconciled money record diff --git a/specs/payments/refunding-a-booking.feature b/specs/payments/refunding-a-booking.feature new file mode 100644 index 0000000000..6e595a4d32 --- /dev/null +++ b/specs/payments/refunding-a-booking.feature @@ -0,0 +1,102 @@ +@story:payments.refunding-a-booking +@owner:payments @risk:high +@actor:organiser @actor:customer +@edition:managed @edition:self-hosted +Feature: An organiser refunds a booking + When an organiser refunds a booking, the customer gets back everything they + paid — the ticket, any booking fee, and any service charge. The organiser's + own figures must agree with the money that actually moved. + + @rule:payments.refund-hands-the-money-back + @surface:admin + Rule: A refund hands back the money and undoes the sale + The listing stops counting the sale, the customer owes nothing, and the + money goes back where it came from. + + @case:payment.refund-undoes-the-sale + Scenario: The organiser refunds a paid place + Given a customer paid 45.00 for a Concert place + When the organiser refunds the booking + Then the customer is handed back 45.00 once + And the Concert has earned nothing and the customer owes nothing + And the booking page says the booking is fully paid + + @rule:payments.a-booking-is-refunded-once-only + @surface:admin + Rule: A booking can only be refunded once + A second attempt is refused before any money moves, so nobody is paid twice. + + @case:payment.second-refund-is-refused + Scenario: The organiser tries to refund the same booking twice + Given a customer's paid Concert place was already refunded + When the organiser tries to refund it again + Then the organiser is told it was already refunded + And the payment provider is not asked again + And the customer was handed money back only once + + @rule:payments.a-booking-fee-is-its-own-income + @surface:admin + Rule: A booking fee is counted apart from the ticket, and comes back too + The fee the site charges is its own income, not the listing's, and a refund + returns it with the ticket. + + @case:payment.booking-fee-is-counted-apart + Scenario: A customer pays a booking fee + Given the site adds a 10 percent booking fee + When a customer pays 55.00 for a 50.00 Fee Day place + Then the Fee Day place has earned 50.00 and the booking fee has earned 5.00 + And the customer owes nothing + + @case:payment.booking-fee-comes-back + Scenario: The organiser refunds a booking that paid a booking fee + Given a customer paid a 10 percent booking fee on a 50.00 Fee Day place + When the organiser refunds the booking + Then the Fee Day place and the booking fee have both earned nothing + And the site is holding none of the customer's money + + @rule:payments.a-service-charge-earns-and-returns + @surface:admin + Rule: A service charge earns its own money, and comes back on a refund + An extra charge added at checkout is tracked on its own, and the organiser + can see what it earned. + + @case:payment.service-charge-earns-its-own-money + Scenario: A customer pays a service charge + Given a Talk place costs 50.00 and adds a 10 percent Service charge + When a customer pays for one Talk place + Then the Service charge has earned 5.00 + And the organiser's pages show the Service charge earnings + + @case:payment.service-charge-comes-back + Scenario: The organiser refunds a booking that paid a service charge + Given a customer paid a 10 percent Service charge on a 50.00 Talk place + When the organiser refunds the booking + Then the Service charge has earned nothing + And no money is left unaccounted for + + @rule:payments.one-payment-pays-each-listing-its-share + @surface:admin + Rule: One payment covering two listings pays each its own share + A customer buying places on two listings at once pays once, and each + listing earns only its own part. + + @case:payment.one-payment-two-listings + Scenario: A customer pays once for a place on each of two listings + Given Part One costs 30.00 and Part Two costs 20.00 + When a customer pays 50.00 for one place on each + Then Part One has earned 30.00 and Part Two has earned 20.00 + And both places belong to the same order + And each listing's page shows its own earnings + + @rule:payments.money-is-never-created-or-destroyed + @surface:admin + Rule: Money is never created or destroyed + However many sales, corrections and refunds happen, the books still add up. + + @case:payment.mixed-sequence-still-adds-up + Scenario: A sale, a correction and a refund in turn + Given two customers each paid 70.00 for a Festival place + And the organiser corrected the Festival income to 100.00 + When the organiser refunds the first customer + Then no money is left unaccounted for + And the Festival earnings and the refunded customer's balance agree diff --git a/test/e2e/accounting/_shared.ts b/test/e2e/accounting/_shared.ts deleted file mode 100644 index ef9f260120..0000000000 --- a/test/e2e/accounting/_shared.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { attendeeAccount } from "#shared/accounting/accounts.ts"; -import { transfersByAccount } from "#shared/accounting/queries.ts"; -import type { Transfer } from "#shared/ledger/types.ts"; -import { legsOfKind } from "./ledger-helpers.ts"; - -/** The legs of one kind posted to an attendee's own ledger account — e.g. the - * `refund_cash` legs (the money handed back) on their account. */ -export const attendeeLegsOfKind = async ( - attendeeId: number, - kind: string, -): Promise => - legsOfKind(await transfersByAccount(attendeeAccount(attendeeId)), kind); diff --git a/test/e2e/accounting/bulk-merges-and-pwyw.test.ts b/test/e2e/accounting/bulk-merges-and-pwyw.test.ts index e9c0e228f2..dd434d5d8c 100644 --- a/test/e2e/accounting/bulk-merges-and-pwyw.test.ts +++ b/test/e2e/accounting/bulk-merges-and-pwyw.test.ts @@ -10,9 +10,6 @@ import { createPaidTestAttendee } from "#test-utils/db-helpers/attendee-payments import { createTestAttendeeDirect } from "#test-utils/db-helpers/attendees.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; import { singleItem } from "#test-utils/factories.ts"; -import { adminFormPost, adminGet } from "#test-utils/session.ts"; -import { setupStripe } from "#test-utils/settings.ts"; -import { attendeeLegsOfKind } from "./_shared.ts"; import { completePaidOrder, describeAccounting, @@ -23,16 +20,19 @@ import { submitRefund, twoPaidDuplicates, withRefundMock, -} from "./drivers.ts"; +} from "#test-utils/money/drivers.ts"; import { adminPageHtml, assertRenderedIncome, + attendeeLegsOfKind, incomeOf, norm, owedBy, sumOfAllBalances, worldBalance, -} from "./ledger-helpers.ts"; +} from "#test-utils/money/reads.ts"; +import { adminFormPost, adminGet } from "#test-utils/session.ts"; +import { setupStripe } from "#test-utils/settings.ts"; /** Post a keep-target merge that discards the source duplicate under one money * decision (credit the over-payment back, or write it off). */ diff --git a/test/e2e/accounting/free-and-idempotency.test.ts b/test/e2e/accounting/free-and-idempotency.test.ts deleted file mode 100644 index 34dcdc7c87..0000000000 --- a/test/e2e/accounting/free-and-idempotency.test.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { expect } from "@std/expect"; -import { it as test } from "@std/testing/bdd"; -import { - attendeeAccount, - BOOKING_FEE_INCOME, - revenueAccount, -} from "#shared/accounting/accounts.ts"; -import { - accountBalance, - transfersByAccount, -} from "#shared/accounting/queries.ts"; -import { formatCurrency } from "#shared/currency.ts"; -import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; -import { settings } from "#shared/db/settings.ts"; -import { expectFlashRedirect, expectRedirect } from "#test-utils/assertions.ts"; -import { createTestAttendee } from "#test-utils/db-helpers/attendees.ts"; -import { createTestListing } from "#test-utils/db-helpers/listings.ts"; -import { singleItem } from "#test-utils/factories.ts"; -import { adminFormPost } from "#test-utils/session.ts"; -import { setupStripe } from "#test-utils/settings.ts"; -import { - completePaidOrder, - describeAccounting, - submitRefund, - withRefundMock, - withStripeSuccess, -} from "./drivers.ts"; -import { - adminPageHtml, - incomeLedgerArticle, - incomeOf, - kindsOf, - legsOfKind, - owedBy, - signedCurrency, - sumOfAllBalances, - worldBalance, -} from "./ledger-helpers.ts"; - -describeAccounting(() => { - test("a free booking records no money even with a booking fee configured", async () => { - await settings.update.bookingFee("10"); - const listing = await createTestListing({ - maxAttendees: 50, - name: "Free Meetup", - unitPrice: 0, - }); - const attendee = await createTestAttendee( - listing.id, - listing.slug, - "Free Guest", - "free@example.com", - ); - - // Nothing owed, and the booking posted no ledger legs whatsoever. - expect(await owedBy(attendee.id)).toBe(0); - expect( - (await transfersByAccount(attendeeAccount(attendee.id))).length, - ).toBe(0); - expect(await incomeOf(listing.id)).toBe(0); - // No phantom booking-fee income, and no phantom cash moved through the world. - expect(await accountBalance(BOOKING_FEE_INCOME)).toBe(0); - expect(await worldBalance()).toBe(0); - expect(await sumOfAllBalances()).toBe(0); - }); - - // 12. When the payment provider DECLINES the refund, nothing is reversed: the - // income and owed figures are unchanged and no refund legs are posted. - test("a failed provider refund reverses nothing in the ledger", async () => { - await setupStripe(); - const listing = await createTestListing({ - maxAttendees: 50, - name: "Show", - unitPrice: 4500, - }); - const attendeeId = await completePaidOrder( - listing.id, - "No Refund", - "norefund@example.com", - 4500, - "cs_fail", - "pi_fail", - ); - expect(await incomeOf(listing.id)).toBe(4500); - - await withRefundMock(false, async (mockRefund) => { - const response = await submitRefund(attendeeId, "No Refund"); - // The route surfaces the failure as a flash error and does NOT issue it. - await expectFlashRedirect( - `/admin/attendees/${attendeeId}/refund`, - expect.stringContaining("Refund failed"), - false, - )(response); - expect(mockRefund.calls.length).toBe(1); - }); - - // The ledger is exactly as it was: full income, nothing owed back, no refund. - expect(await incomeOf(listing.id)).toBe(4500); - expect(await owedBy(attendeeId)).toBe(0); - const legs = await transfersByAccount(attendeeAccount(attendeeId)); - expect(legsOfKind(legs, "refund_cash").length).toBe(0); - expect(kindsOf(legs)).toEqual(["payment", "sale"]); - expect(await sumOfAllBalances()).toBe(0); - }); - - // 13. Corrections and webhook deliveries are idempotent. Replaying the identical - // Stripe success creates no second booking or duplicate legs, and - // re-submitting the same income target posts no second adjustment. - test("re-submitting a correction and replaying a success are both no-ops", async () => { - await setupStripe(); - const listing = await createTestListing({ - maxAttendees: 50, - name: "Repeat", - unitPrice: 6000, - }); - const attendeeId = await completePaidOrder( - listing.id, - "Repeat Buyer", - "repeat@example.com", - 6000, - "cs_repeat", - "pi_repeat", - ); - - // Replaying the identical success must not create a second attendee or - // duplicate the booking's legs — an already-processed session is a no-op, - // so the route just re-renders (a 200) rather than redirecting afresh. - await withStripeSuccess( - { - email: "repeat@example.com", - items: singleItem(listing.id, 1, 6000), - name: "Repeat Buyer", - paymentIntent: "pi_repeat", - sessionId: "cs_repeat", - total: 6000, - }, - async (replay) => { - await replay.body?.cancel(); - }, - ); - expect((await getAttendeesRaw(listing.id)).length).toBe(1); - expect( - kindsOf(await transfersByAccount(attendeeAccount(attendeeId))), - ).toEqual(["payment", "sale"]); - - // Adjust income to £40, then submit the SAME target again — the second submit - // computes a zero delta and posts no second adjustment. - const adjustIncome = async (): Promise => - ( - await adminFormPost(`/admin/listing/${listing.id}/income`, { - income: "40.00", - }) - ).response; - const expectAdjusted = expectFlashRedirect( - `/admin/listing/${listing.id}/edit`, - "Listing income corrected.", - ); - await expectAdjusted(await adjustIncome()); - await expectAdjusted(await adjustIncome()); - - expect(await incomeOf(listing.id)).toBe(4000); - const adjustments = legsOfKind( - await transfersByAccount(revenueAccount(listing.id)), - "adjustment", - ); - expect(adjustments.length).toBe(1); - }); - - // 14. The listing detail page renders the "Income & ledger" reconciliation that - // EXPLAINS the two income figures: gross sales (+) and manual adjustments - // (±) make up recognised income, and refunds (−) take it down to the net - // ledger balance — so the two never silently disagree (transparency). - test("the listing page renders an income/ledger breakdown reconciling the figures", async () => { - await setupStripe(); - const listing = await createTestListing({ - maxAttendees: 50, - name: "Reconciled", - unitPrice: 5000, - }); - const attendeeId = await completePaidOrder( - listing.id, - "Recon Buyer", - "recon@example.com", - 5000, - "cs_recon", - "pi_recon", - ); - // Write recognised income down £50 → £40 (a manual adjustment), then refund - // the booking (which nets the ledger balance but not recognised income) so - // all five reconciliation rows carry distinct, non-zero figures. - await expectFlashRedirect( - `/admin/listing/${listing.id}/edit`, - "Listing income corrected.", - )( - ( - await adminFormPost(`/admin/listing/${listing.id}/income`, { - income: "40.00", - }) - ).response, - ); - await withRefundMock(true, async (mockRefund) => { - const refund = await submitRefund(attendeeId, "Recon Buyer"); - expectRedirect(refund, /\/admin\/attendees\/\d+\/actions/); - expect(mockRefund.calls.length).toBe(1); - }); - - const article = incomeLedgerArticle( - await adminPageHtml(`/admin/listing/${listing.id}`), - ); - // Every reconciliation row is labelled and signed, so the difference between - // the two income figures is self-evident on the page. - expect(article).toContain("Money in and out"); - expect(article).toContain("Gross ticket sales"); - expect(article).toContain(signedCurrency(5000)); // +£50 gross sales - expect(article).toContain("Income corrections"); - expect(article).toContain(signedCurrency(-1000)); // −£10 write-down - expect(article).toContain("Total income earned"); - expect(article).toContain(formatCurrency(4000)); // £40 recognised - expect(article).toContain("Refunds"); - expect(article).toContain(signedCurrency(-5000)); // −£50 refunded - expect(article).toContain("Net after refunds and costs"); - expect(article).toContain(signedCurrency(-1000)); // −£10 net - // And it links through to the listing-scoped ledger view. - expect(article).toContain(`/admin/ledger?listing=${listing.id}`); - }); - - // 15. A bulk refund is resilient: when the provider declines ONE payment, the - // others are still refunded and recorded, the declined one is left intact, - // and conservation holds across the partial batch. -}); diff --git a/test/e2e/accounting/income-and-corrections.test.ts b/test/e2e/accounting/income-and-corrections.test.ts index c3b884fa00..c7f3e14a00 100644 --- a/test/e2e/accounting/income-and-corrections.test.ts +++ b/test/e2e/accounting/income-and-corrections.test.ts @@ -21,16 +21,13 @@ import { createTestAttendee } from "#test-utils/db-helpers/attendees.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; import { postListingSale, postModifierLeg } from "#test-utils/ledger.ts"; import { insertModifier } from "#test-utils/modifiers.ts"; -import { adminFormPost } from "#test-utils/session.ts"; -import { setupStripe } from "#test-utils/settings.ts"; -import { attendeeLegsOfKind } from "./_shared.ts"; import { completePaidOrder, describeAccounting, postAttendeeBalanceEntry, submitRefund, withRefundMock, -} from "./drivers.ts"; +} from "#test-utils/money/drivers.ts"; import { adminPageHtml, assertEditPageIncome, @@ -38,6 +35,7 @@ import { assertRenderedModifierRevenue, assertRenderedOwed, assertStatementBalance, + attendeeLegsOfKind, incomeOf, kindsOf, legsOfKind, @@ -45,7 +43,9 @@ import { owedBy, sumOfAllBalances, worldBalance, -} from "./ledger-helpers.ts"; +} from "#test-utils/money/reads.ts"; +import { adminFormPost } from "#test-utils/session.ts"; +import { setupStripe } from "#test-utils/settings.ts"; describeAccounting(() => { test("a real public paid order recognises income shown on the admin pages", async () => { diff --git a/test/e2e/accounting/refunds-and-conservation.test.ts b/test/e2e/accounting/refunds-and-conservation.test.ts deleted file mode 100644 index a0620af327..0000000000 --- a/test/e2e/accounting/refunds-and-conservation.test.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { expect } from "@std/expect"; -import { it as test } from "@std/testing/bdd"; -import { - attendeeAccount, - BOOKING_FEE_INCOME, - modifierAccount, - WORLD, -} from "#shared/accounting/accounts.ts"; -import { - accountBalance, - transfersByAccount, - transfersByEventGroup, -} from "#shared/accounting/queries.ts"; -import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; -import { settings } from "#shared/db/settings.ts"; -import { expectFlashRedirect, expectRedirect } from "#test-utils/assertions.ts"; -import { createPaidTestAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; -import { createTestListing } from "#test-utils/db-helpers/listings.ts"; -import { singleItem } from "#test-utils/factories.ts"; -import { insertModifier } from "#test-utils/modifiers.ts"; -import { adminFormPost } from "#test-utils/session.ts"; -import { setupStripe } from "#test-utils/settings.ts"; -import { attendeeLegsOfKind } from "./_shared.ts"; -import { - completePaidOrder, - describeAccounting, - runStripeSuccess, - submitRefund, - withRefundMock, -} from "./drivers.ts"; -import { - adminPageHtml, - assertEditPageIncome, - assertRenderedModifierRevenue, - assertStatementBalance, - incomeOf, - kindsOf, - legsOfKind, - modifierRevenueOf, - owedBy, - sumOfAllBalances, - worldBalance, -} from "./ledger-helpers.ts"; - -describeAccounting(() => { - test("refunding a paid order returns revenue and owed to zero with conservation", async () => { - await setupStripe(); - const listing = await createTestListing({ - maxAttendees: 50, - name: "Concert", - unitPrice: 4500, - }); - const attendeeId = await completePaidOrder( - listing.id, - "Refundee", - "refundee@example.com", - 4500, - "cs_concert", - "pi_concert", - ); - expect(await incomeOf(listing.id)).toBe(4500); - - await withRefundMock(true, async (mockRefund) => { - const response = await submitRefund(attendeeId, "Refundee"); - await expectFlashRedirect( - `/admin/attendees/${attendeeId}/actions`, - "Refund issued", - )(response); - expect(mockRefund.calls.length).toBe(1); - }); - - // Ledger truth: revenue and owed both back to zero. - expect(await incomeOf(listing.id)).toBe(0); - expect(await owedBy(attendeeId)).toBe(0); - - // A single full refund_cash leg of the whole payment, returned to the world. - const refundCash = await attendeeLegsOfKind(attendeeId, "refund_cash"); - expect(refundCash.length).toBe(1); - expect(refundCash[0]!.amount).toBe(4500); - expect(refundCash[0]!.destination).toEqual(WORLD); - - // Conservation across every touched account. - expect(await sumOfAllBalances()).toBe(0); - - // The two income surfaces legitimately DIVERGE after a refund: the ledger - // statement nets the refund (`Balance: £0`), while the edit page reports - // gross-minus-write-offs and so still shows the £45 sale (an ordinary refund - // doesn't reduce recognised income — only a manual write-off does). Assert - // each surface against its own contract rather than forcing them to agree. - await assertStatementBalance(listing.id, 0); - await assertEditPageIncome(listing.id, 4500); - // The admin attendee balance page shows the booking fully settled. - const balancePage = await adminPageHtml( - `/admin/attendees/${attendeeId}/ledger`, - ); - expect(balancePage).toContain("This booking is fully paid"); - }); - - // 7. Conservation sweep over a MIXED sequence: a paid order, a manual income - // write-off, and a refund. The signed sum of balances across every touched - // account must be exactly 0. - test("conservation holds across a mixed order + correction + refund sequence", async () => { - await setupStripe(); - const listing = await createTestListing({ - maxAttendees: 50, - name: "Festival", - unitPrice: 7000, - }); - - // Order #1: a paid order that we will later write down and refund. - const refundedId = await completePaidOrder( - listing.id, - "Mixed One", - "mixed1@example.com", - 7000, - "cs_mix1", - "pi_mix1", - ); - // Order #2 stays on the books, so the sweep spans accounts left non-zero. - await createPaidTestAttendee( - listing.id, - "Mixed Two", - "mixed2@example.com", - "pi_mix2", - 7000, - ); - - // A manual income write-down (£70 → £40 across the two £70 sales = £140 - // recognised; drop to £100). - expect(await incomeOf(listing.id)).toBe(14000); - const writeDown = ( - await adminFormPost(`/admin/listing/${listing.id}/income`, { - income: "100.00", - }) - ).response; - await expectFlashRedirect( - `/admin/listing/${listing.id}/edit`, - "Listing income corrected.", - )(writeDown); - expect(await incomeOf(listing.id)).toBe(10000); - - // Refund order #1. - await withRefundMock(true, async (mockRefund) => { - const response = await submitRefund(refundedId, "Mixed One"); - expectRedirect(response, /\/admin\/attendees\/\d+\/actions/); - expect(mockRefund.calls.length).toBe(1); - }); - - // Conservation must hold after the whole mixed sequence. - expect(await sumOfAllBalances()).toBe(0); - - // And the surviving figures are individually coherent: order #2 still owes - // nothing, the refunded buyer owes nothing. - expect(await owedBy(refundedId)).toBe(0); - // The raw revenue balance (what the ledger statement shows) nets everything: - // £140 gross − £40 write-down − £70 refunded sale = £30. - expect(await incomeOf(listing.id)).toBe(3000); - await assertStatementBalance(listing.id, 3000); - // The edit page reports gross-minus-write-offs and ignores the refund, so it - // still shows £140 − £40 = £100 — the documented divergence after a refund. - await assertEditPageIncome(listing.id, 10000); - - // The refund leg group is distinct from the booking group it reverses. - const refundCash = await attendeeLegsOfKind(refundedId, "refund_cash"); - expect(refundCash.length).toBe(1); - const bookingGroups = new Set( - (await transfersByEventGroup(refundCash[0]!.eventGroup)).map( - (leg) => leg.eventGroup, - ), - ); - expect(bookingGroups.size).toBe(1); - }); - - // 8. A configured booking fee posts a separate `fee` leg to fee-income (not the - // listing's revenue), leaves the buyer owing nothing, and a refund reverses - // that fee alongside the sale and the cash. - test("a configured booking fee posts fee-income and is reversed on refund", async () => { - await setupStripe(); - await settings.update.bookingFee("10"); // 10% booking fee. - const listing = await createTestListing({ - maxAttendees: 50, - name: "Fee Day", - unitPrice: 5000, - }); - // £50 ticket + 10% booking fee = £55 charged. - await runStripeSuccess({ - email: "fee@example.com", - items: singleItem(listing.id, 1, 5000), - name: "Fee Payer", - paymentIntent: "pi_fee", - sessionId: "cs_fee", - total: 5500, - }); - const attendeeId = (await getAttendeesRaw(listing.id))[0]!.id; - - // Recognised income is the gross ticket sale; the fee is its own income line. - expect(await incomeOf(listing.id)).toBe(5000); - expect(await owedBy(attendeeId)).toBe(0); - expect(await accountBalance(BOOKING_FEE_INCOME)).toBe(500); - expect(await worldBalance()).toBe(-5500); - - const legs = await transfersByAccount(attendeeAccount(attendeeId)); - expect(kindsOf(legs)).toEqual(["fee", "payment", "sale"]); - const fee = legsOfKind(legs, "fee")[0]!; - expect(fee.amount).toBe(500); - expect(fee.destination).toEqual(BOOKING_FEE_INCOME); - - // Refunding reverses sale + fee + payment; fee income returns to zero. - await withRefundMock(true, async (mockRefund) => { - const refund = await submitRefund(attendeeId, "Fee Payer"); - expectRedirect(refund, /\/admin\/attendees\/\d+\/actions/); - expect(mockRefund.calls.length).toBe(1); - }); - expect(await incomeOf(listing.id)).toBe(0); - expect(await accountBalance(BOOKING_FEE_INCOME)).toBe(0); - expect(await owedBy(attendeeId)).toBe(0); - expect(await worldBalance()).toBe(0); - expect(await sumOfAllBalances()).toBe(0); - const refundFee = await attendeeLegsOfKind(attendeeId, "refund_fee"); - expect(refundFee.length).toBe(1); - expect(refundFee[0]!.amount).toBe(500); - }); - - // 9. A surcharge modifier applied during a REAL paid checkout posts a `modifier` - // leg whose balance is the modifier's revenue (rendered on the admin pages), - // and a refund reverses it with a `refund_modifier` leg. - test("a surcharge modifier in a real checkout earns revenue and reverses on refund", async () => { - await setupStripe(); - const listing = await createTestListing({ - maxAttendees: 50, - name: "Talk", - unitPrice: 5000, - }); - const modifier = await insertModifier({ - calcKind: "percent", - calcValue: 10, - name: "Service charge", - }); - // £50 ticket + 10% service charge = £55 charged. - await runStripeSuccess({ - email: "svc@example.com", - items: singleItem(listing.id, 1, 5000), - modifiers: [{ i: modifier.id, q: 1 }], - name: "Svc Buyer", - paymentIntent: "pi_svc", - sessionId: "cs_svc", - total: 5500, - }); - const attendeeId = (await getAttendeesRaw(listing.id))[0]!.id; - - expect(await incomeOf(listing.id)).toBe(5000); - expect(await modifierRevenueOf(modifier.id)).toBe(500); - expect(await owedBy(attendeeId)).toBe(0); - - const legs = await transfersByAccount(attendeeAccount(attendeeId)); - expect(kindsOf(legs)).toEqual(["modifier", "payment", "sale"]); - const mod = legsOfKind(legs, "modifier")[0]!; - expect(mod.amount).toBe(500); - expect(mod.destination).toEqual(modifierAccount(modifier.id)); - - // The earned revenue renders on the modifier edit page and the list. - await assertRenderedModifierRevenue(modifier.id, 500); - - // Refund reverses the modifier leg too, returning its revenue to zero. - await withRefundMock(true, async (mockRefund) => { - const refund = await submitRefund(attendeeId, "Svc Buyer"); - expectRedirect(refund, /\/admin\/attendees\/\d+\/actions/); - expect(mockRefund.calls.length).toBe(1); - }); - expect(await modifierRevenueOf(modifier.id)).toBe(0); - expect(await sumOfAllBalances()).toBe(0); - const refundMod = await attendeeLegsOfKind(attendeeId, "refund_modifier"); - expect(refundMod.length).toBe(1); - expect(refundMod[0]!.amount).toBe(500); - }); - - // 10. One payment spanning two listings splits the recognised income across each - // listing's own revenue account under a single event group, and the buyer - // (one attendee) owes nothing. - test("a multi-line order splits income across each listing's revenue account", async () => { - await setupStripe(); - const first = await createTestListing({ - maxAttendees: 50, - name: "Part One", - unitPrice: 3000, - }); - const second = await createTestListing({ - maxAttendees: 50, - name: "Part Two", - unitPrice: 2000, - }); - // One £50 payment: £30 to the first listing, £20 to the second. - await runStripeSuccess({ - email: "both@example.com", - items: JSON.stringify([ - { e: first.id, p: 3000, q: 1 }, - { e: second.id, p: 2000, q: 1 }, - ]), - name: "Both Buyer", - paymentIntent: "pi_multi", - sessionId: "cs_multi", - total: 5000, - }); - - // Each listing's revenue account holds its own line; the buyer owes nothing. - expect(await incomeOf(first.id)).toBe(3000); - expect(await incomeOf(second.id)).toBe(2000); - const attendeeId = (await getAttendeesRaw(first.id))[0]!.id; - expect(await owedBy(attendeeId)).toBe(0); - expect(await worldBalance()).toBe(-5000); - expect(await sumOfAllBalances()).toBe(0); - - // Both sale legs and the single payment share ONE booking event group. - const legs = await transfersByAccount(attendeeAccount(attendeeId)); - expect(legsOfKind(legs, "sale").length).toBe(2); - expect(new Set(legs.map((leg) => leg.eventGroup)).size).toBe(1); - - // Each listing's income renders on its own edit page. - await assertEditPageIncome(first.id, 3000); - await assertEditPageIncome(second.id, 2000); - }); -}); diff --git a/test/specs/steps/money-actions.ts b/test/specs/steps/money-actions.ts new file mode 100644 index 0000000000..657c868cba --- /dev/null +++ b/test/specs/steps/money-actions.ts @@ -0,0 +1,236 @@ +// jscpd:ignore-start + +import { Given, Then, When } from "@cucumber/cucumber"; +import { expect } from "@std/expect"; +import { + attendeeAccount, + BOOKING_FEE_INCOME, + revenueAccount, +} from "#shared/accounting/accounts.ts"; +import { + accountBalance, + transfersByAccount, +} from "#shared/accounting/queries.ts"; +import { formatCurrency } from "#shared/currency.ts"; +import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; +import { + askForRefund, + bookingId, + buyOnePlace, + expectRefundMessage, + listingIdFor, + sellPlacesAt, + timesProviderWasAsked, +} from "#test/specs/support/money.ts"; +import type { TicketsWorld } from "#test/specs/support/world.ts"; +import { createTestAttendee } from "#test-utils/db-helpers/attendees.ts"; +import { singleItem } from "#test-utils/factories.ts"; +import { withStripeSuccess } from "#test-utils/money/drivers.ts"; +import { + adminPageHtml, + incomeLedgerArticle, + incomeOf, + kindsOf, + legsOfKind, + owedBy, + signedCurrency, + sumOfAllBalances, + worldBalance, +} from "#test-utils/money/reads.ts"; +import { adminFormPost } from "#test-utils/session.ts"; + +// jscpd:ignore-end + +const FREE_MEETUP = "Free Meetup"; +const SHOW = "Show"; +const REPEAT = "Repeat"; +const RECONCILED = "Reconciled"; + +/** Set a listing's income to the given figure through the real correction form. */ +const correctIncomeTo = async ( + listingId: number, + pounds: string, +): Promise => { + const { response } = await adminFormPost( + `/admin/listing/${listingId}/income`, + { income: pounds }, + ); + expect(response.status).toBe(302); +}; + +When( + "a customer books a free Free Meetup place", + async function (this: TicketsWorld): Promise { + const listing = await sellPlacesAt(this, FREE_MEETUP, "0.00"); + const attendee = await createTestAttendee( + listing.id, + listing.slug, + "Free Guest", + "free@example.com", + ); + this.attendeeId = attendee.id; + this.attendeeName = "Free Guest"; + }, +); + +Then( + "no money is recorded for the booking", + async function (this: TicketsWorld): Promise { + expect(await owedBy(bookingId(this))).toBe(0); + expect( + (await transfersByAccount(attendeeAccount(bookingId(this)))).length, + ).toBe(0); + expect(await incomeOf(listingIdFor(this, FREE_MEETUP))).toBe(0); + }, +); + +Then("no booking fee is recorded", async (): Promise => { + expect(await accountBalance(BOOKING_FEE_INCOME)).toBe(0); + expect(await worldBalance()).toBe(0); + expect(await sumOfAllBalances()).toBe(0); +}); + +Given( + "a customer paid 45.00 for a Show place", + async function (this: TicketsWorld): Promise { + await buyOnePlace(this, SHOW, "45.00", "No Refund"); + expect(await incomeOf(listingIdFor(this, SHOW))).toBe(4500); + }, +); + +When( + "the organiser asks for a refund and the provider turns it down", + function (this: TicketsWorld): Promise { + return askForRefund(this, false); + }, +); + +Then( + "the organiser is told the refund failed", + async function (this: TicketsWorld): Promise { + await expectRefundMessage( + this, + `/admin/attendees/${bookingId(this)}/refund`, + "Refund failed", + false, + ); + expect(timesProviderWasAsked(this)).toBe(1); + }, +); + +Then( + "the Show has still earned 45.00 and no money was handed back", + async function (this: TicketsWorld): Promise { + expect(await incomeOf(listingIdFor(this, SHOW))).toBe(4500); + expect(await owedBy(bookingId(this))).toBe(0); + const legs = await transfersByAccount(attendeeAccount(bookingId(this))); + expect(legsOfKind(legs, "refund_cash").length).toBe(0); + expect(kindsOf(legs)).toEqual(["payment", "sale"]); + expect(await sumOfAllBalances()).toBe(0); + }, +); + +Given( + "a customer paid 60.00 for a Repeat place", + async function (this: TicketsWorld): Promise { + await buyOnePlace(this, REPEAT, "60.00", "Repeat Buyer"); + }, +); + +When( + "the same payment message arrives again", + async function (this: TicketsWorld): Promise { + const listingId = listingIdFor(this, REPEAT); + // An already-handled payment is a no-op, so the page just renders again. + await withStripeSuccess( + { + email: "repeat.buyer@example.com", + items: singleItem(listingId, 1, 6000), + name: "Repeat Buyer", + paymentIntent: "pi_repeat", + sessionId: "cs_repeat", + total: 6000, + }, + async (replay) => { + await replay.body?.cancel(); + }, + ); + }, +); + +Then( + "there is still one booking and one sale", + async function (this: TicketsWorld): Promise { + expect((await getAttendeesRaw(listingIdFor(this, REPEAT))).length).toBe(1); + expect( + kindsOf(await transfersByAccount(attendeeAccount(bookingId(this)))), + ).toEqual(["payment", "sale"]); + expect(await sumOfAllBalances()).toBe(0); + }, +); + +When( + "the organiser sets the Repeat income to 40.00 twice", + async function (this: TicketsWorld): Promise { + const listingId = listingIdFor(this, REPEAT); + await correctIncomeTo(listingId, "40.00"); + await correctIncomeTo(listingId, "40.00"); + }, +); + +Then( + "the Repeat has earned 40.00 from a single correction", + async function (this: TicketsWorld): Promise { + const listingId = listingIdFor(this, REPEAT); + expect(await incomeOf(listingId)).toBe(4000); + // The second save works out a change of nothing, so it records nothing. + const corrections = legsOfKind( + await transfersByAccount(revenueAccount(listingId)), + "adjustment", + ); + expect(corrections.length).toBe(1); + }, +); + +Given( + "a customer paid 50.00 for a Reconciled place", + async function (this: TicketsWorld): Promise { + await buyOnePlace(this, RECONCILED, "50.00", "Recon Buyer"); + }, +); + +Given( + "the organiser corrected the Reconciled income to 40.00", + function (this: TicketsWorld): Promise { + return correctIncomeTo(listingIdFor(this, RECONCILED), "40.00"); + }, +); + +Then( + "the Reconciled page breaks the money down line by line", + async function (this: TicketsWorld): Promise { + const breakdown = incomeLedgerArticle( + await adminPageHtml(`/admin/listing/${listingIdFor(this, RECONCILED)}`), + ); + expect(breakdown).toContain("Money in and out"); + expect(breakdown).toContain("Gross ticket sales"); + expect(breakdown).toContain(signedCurrency(5000)); + expect(breakdown).toContain("Income corrections"); + expect(breakdown).toContain(signedCurrency(-1000)); + expect(breakdown).toContain("Total income earned"); + expect(breakdown).toContain(formatCurrency(4000)); + expect(breakdown).toContain("Refunds"); + expect(breakdown).toContain(signedCurrency(-5000)); + expect(breakdown).toContain("Net after refunds and costs"); + }, +); + +Then( + "the breakdown links to the Reconciled money record", + async function (this: TicketsWorld): Promise { + const listingId = listingIdFor(this, RECONCILED); + expect( + incomeLedgerArticle(await adminPageHtml(`/admin/listing/${listingId}`)), + ).toContain(`/admin/ledger?listing=${listingId}`); + }, +); diff --git a/test/specs/steps/refunds.ts b/test/specs/steps/refunds.ts new file mode 100644 index 0000000000..0e91c1f4ad --- /dev/null +++ b/test/specs/steps/refunds.ts @@ -0,0 +1,476 @@ +// jscpd:ignore-start + +import { Given, Then, When } from "@cucumber/cucumber"; +import { expect } from "@std/expect"; +import { + attendeeAccount, + BOOKING_FEE_INCOME, + modifierAccount, + WORLD, +} from "#shared/accounting/accounts.ts"; +import { + accountBalance, + transfersByAccount, +} from "#shared/accounting/queries.ts"; +import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; +import { settings } from "#shared/db/settings.ts"; +import { + askForRefund, + bookingId, + buyOnePlace, + expectRefundMessage, + listingIdFor, + minorUnits, + sellPlacesAt, + timesProviderWasAsked, +} from "#test/specs/support/money.ts"; +import { + requiredWorldValue, + type TicketsWorld, +} from "#test/specs/support/world.ts"; +import { createPaidTestAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; +import { insertModifier } from "#test-utils/modifiers.ts"; +import { runStripeSuccess } from "#test-utils/money/drivers.ts"; +import { + adminPageHtml, + assertEditPageIncome, + assertRenderedModifierRevenue, + assertStatementBalance, + attendeeLegsOfKind, + incomeOf, + kindsOf, + legsOfKind, + owedBy, + sumOfAllBalances, + worldBalance, +} from "#test-utils/money/reads.ts"; +import { adminFormPost } from "#test-utils/session.ts"; +import { setupStripe } from "#test-utils/settings.ts"; + +// jscpd:ignore-end + +const CONCERT = "Concert"; +const FEE_DAY = "Fee Day"; +const TALK = "Talk"; +const FESTIVAL = "Festival"; +const SERVICE_CHARGE = "Service charge"; + +const refundPath = (world: TicketsWorld, page: string): string => + `/admin/attendees/${bookingId(world)}/${page}`; + +/** Sell one place with an extra charge on top and pay the whole amount, the way + * a real checkout does: the signed total must match what the site re-derives. */ +const buyPlaceWithExtra = async ( + world: TicketsWorld, + name: string, + pounds: string, + extraPounds: string, + who: string, + modifierId?: number, +): Promise => { + await setupStripe(); + // The story may already have put this listing on sale (with its extra charge + // attached), so reuse it rather than selling a second one of the same name. + const listingId = + world.listingIds.get(name) ?? (await sellPlacesAt(world, name, pounds)).id; + const price = minorUnits(pounds); + await runStripeSuccess({ + email: `${who.toLowerCase().replaceAll(" ", ".")}@example.com`, + items: JSON.stringify([{ e: listingId, p: price, q: 1 }]), + ...(modifierId === undefined + ? {} + : { modifiers: [{ i: modifierId, q: 1 }] }), + name: who, + paymentIntent: `pi_${name.toLowerCase().replaceAll(" ", "_")}`, + sessionId: `cs_${name.toLowerCase().replaceAll(" ", "_")}`, + total: price + minorUnits(extraPounds), + }); + world.attendeeId = (await getAttendeesRaw(listingId))[0]!.id; + world.attendeeName = who; +}; + +/** Add the Service charge the Talk scenarios both use. */ +const addServiceCharge = async (world: TicketsWorld): Promise => { + const modifier = await insertModifier({ + calcKind: "percent", + calcValue: 10, + name: SERVICE_CHARGE, + }); + world.modifierId = modifier.id; + return modifier.id; +}; + +Given( + "a customer paid 45.00 for a Concert place", + async function (this: TicketsWorld): Promise { + await buyOnePlace(this, CONCERT, "45.00", "Refundee"); + expect(await incomeOf(listingIdFor(this, CONCERT))).toBe(4500); + }, +); + +When( + "the organiser refunds the booking", + function (this: TicketsWorld): Promise { + return askForRefund(this, true); + }, +); + +Then( + "the customer is handed back 45.00 once", + async function (this: TicketsWorld): Promise { + await expectRefundMessage( + this, + refundPath(this, "actions"), + "Refund issued", + true, + ); + expect(timesProviderWasAsked(this)).toBe(1); + // One full refund of the whole payment, returned where it came from. + const handedBack = await attendeeLegsOfKind(bookingId(this), "refund_cash"); + expect(handedBack.length).toBe(1); + expect(handedBack[0]!.amount).toBe(4500); + expect(handedBack[0]!.destination).toEqual(WORLD); + }, +); + +Then( + "the Concert has earned nothing and the customer owes nothing", + async function (this: TicketsWorld): Promise { + const listingId = listingIdFor(this, CONCERT); + expect(await incomeOf(listingId)).toBe(0); + expect(await owedBy(bookingId(this))).toBe(0); + expect(await sumOfAllBalances()).toBe(0); + // The money record nets the refund; the listing's own income box reports + // the sale less write-offs, so an ordinary refund leaves it standing. + await assertStatementBalance(listingId, 0); + await assertEditPageIncome(listingId, 4500); + }, +); + +Then( + "the booking page says the booking is fully paid", + async function (this: TicketsWorld): Promise { + expect(await adminPageHtml(refundPath(this, "ledger"))).toContain( + "This booking is fully paid", + ); + }, +); + +Given( + "a customer's paid Concert place was already refunded", + async function (this: TicketsWorld): Promise { + await buyOnePlace(this, CONCERT, "45.00", "Logged Guest"); + await askForRefund(this, true); + await expectRefundMessage( + this, + refundPath(this, "actions"), + "Refund issued", + true, + ); + // The money event is on the customer's own history for anyone to see. + expect(await adminPageHtml(refundPath(this, "activity"))).toContain( + "Refund issued for attendee 'Logged Guest'", + ); + }, +); + +When( + "the organiser tries to refund it again", + function (this: TicketsWorld): Promise { + return askForRefund(this, true); + }, +); + +Then( + "the organiser is told it was already refunded", + async function (this: TicketsWorld): Promise { + await expectRefundMessage( + this, + refundPath(this, "refund"), + "already been refunded", + false, + ); + }, +); + +Then( + "the payment provider is not asked again", + function (this: TicketsWorld): void { + expect(timesProviderWasAsked(this)).toBe(0); + }, +); + +Then( + "the customer was handed money back only once", + async function (this: TicketsWorld): Promise { + expect( + (await attendeeLegsOfKind(bookingId(this), "refund_cash")).length, + ).toBe(1); + expect(await owedBy(bookingId(this))).toBe(0); + expect(await sumOfAllBalances()).toBe(0); + }, +); + +Given( + "the site adds a 10 percent booking fee", + async function (this: TicketsWorld): Promise { + await settings.update.bookingFee("10"); + }, +); + +When( + "a customer pays 55.00 for a 50.00 Fee Day place", + function (this: TicketsWorld): Promise { + return buyPlaceWithExtra(this, FEE_DAY, "50.00", "5.00", "Fee Payer"); + }, +); + +Then( + "the Fee Day place has earned 50.00 and the booking fee has earned 5.00", + async function (this: TicketsWorld): Promise { + expect(await incomeOf(listingIdFor(this, FEE_DAY))).toBe(5000); + expect(await accountBalance(BOOKING_FEE_INCOME)).toBe(500); + expect(await worldBalance()).toBe(-5500); + // The fee is its own line on the booking, not part of the ticket's price. + const legs = await transfersByAccount(attendeeAccount(bookingId(this))); + expect(kindsOf(legs)).toEqual(["fee", "payment", "sale"]); + const fee = legsOfKind(legs, "fee")[0]!; + expect(fee.amount).toBe(500); + expect(fee.destination).toEqual(BOOKING_FEE_INCOME); + }, +); + +Then( + "the customer owes nothing", + async function (this: TicketsWorld): Promise { + expect(await owedBy(bookingId(this))).toBe(0); + }, +); + +Given( + "a customer paid a 10 percent booking fee on a 50.00 Fee Day place", + async function (this: TicketsWorld): Promise { + await settings.update.bookingFee("10"); + await buyPlaceWithExtra(this, FEE_DAY, "50.00", "5.00", "Fee Payer"); + expect(await accountBalance(BOOKING_FEE_INCOME)).toBe(500); + }, +); + +Then( + "the Fee Day place and the booking fee have both earned nothing", + async function (this: TicketsWorld): Promise { + expect(await incomeOf(listingIdFor(this, FEE_DAY))).toBe(0); + expect(await accountBalance(BOOKING_FEE_INCOME)).toBe(0); + const handedBackFee = await attendeeLegsOfKind( + bookingId(this), + "refund_fee", + ); + expect(handedBackFee.length).toBe(1); + expect(handedBackFee[0]!.amount).toBe(500); + }, +); + +Then( + "the site is holding none of the customer's money", + async function (this: TicketsWorld): Promise { + expect(await owedBy(bookingId(this))).toBe(0); + expect(await worldBalance()).toBe(0); + expect(await sumOfAllBalances()).toBe(0); + }, +); + +Given( + "a Talk place costs 50.00 and adds a 10 percent Service charge", + async function (this: TicketsWorld): Promise { + await setupStripe(); + await sellPlacesAt(this, TALK, "50.00"); + await addServiceCharge(this); + }, +); + +When( + "a customer pays for one Talk place", + async function (this: TicketsWorld): Promise { + const listingId = listingIdFor(this, TALK); + const modifierId = requiredWorldValue(this.modifierId, "modifier id"); + await runStripeSuccess({ + email: "svc@example.com", + items: JSON.stringify([{ e: listingId, p: 5000, q: 1 }]), + modifiers: [{ i: modifierId, q: 1 }], + name: "Svc Buyer", + paymentIntent: "pi_svc", + sessionId: "cs_svc", + total: 5500, + }); + this.attendeeId = (await getAttendeesRaw(listingId))[0]!.id; + this.attendeeName = "Svc Buyer"; + }, +); + +Then( + "the Service charge has earned 5.00", + async function (this: TicketsWorld): Promise { + const modifierId = requiredWorldValue(this.modifierId, "modifier id"); + expect(await accountBalance(modifierAccount(modifierId))).toBe(500); + expect(await incomeOf(listingIdFor(this, TALK))).toBe(5000); + expect(await owedBy(bookingId(this))).toBe(0); + // The charge is its own line, paid to the charge itself. + const legs = await transfersByAccount(attendeeAccount(bookingId(this))); + expect(kindsOf(legs)).toEqual(["modifier", "payment", "sale"]); + const charge = legsOfKind(legs, "modifier")[0]!; + expect(charge.amount).toBe(500); + expect(charge.destination).toEqual(modifierAccount(modifierId)); + }, +); + +Then( + "the organiser's pages show the Service charge earnings", + async function (this: TicketsWorld): Promise { + await assertRenderedModifierRevenue( + requiredWorldValue(this.modifierId, "modifier id"), + 500, + ); + }, +); + +Given( + "a customer paid a 10 percent Service charge on a 50.00 Talk place", + async function (this: TicketsWorld): Promise { + await setupStripe(); + await sellPlacesAt(this, TALK, "50.00"); + const modifierId = await addServiceCharge(this); + await buyPlaceWithExtra( + this, + TALK, + "50.00", + "5.00", + "Svc Buyer", + modifierId, + ); + expect(await accountBalance(modifierAccount(modifierId))).toBe(500); + }, +); + +Then( + "the Service charge has earned nothing", + async function (this: TicketsWorld): Promise { + const modifierId = requiredWorldValue(this.modifierId, "modifier id"); + expect(await accountBalance(modifierAccount(modifierId))).toBe(0); + const handedBackCharge = await attendeeLegsOfKind( + bookingId(this), + "refund_modifier", + ); + expect(handedBackCharge.length).toBe(1); + expect(handedBackCharge[0]!.amount).toBe(500); + }, +); + +Then("no money is left unaccounted for", async (): Promise => { + expect(await sumOfAllBalances()).toBe(0); +}); + +Given( + "Part One costs 30.00 and Part Two costs 20.00", + async function (this: TicketsWorld): Promise { + await setupStripe(); + await sellPlacesAt(this, "Part One", "30.00"); + await sellPlacesAt(this, "Part Two", "20.00"); + }, +); + +When( + "a customer pays 50.00 for one place on each", + async function (this: TicketsWorld): Promise { + const first = listingIdFor(this, "Part One"); + await runStripeSuccess({ + email: "both@example.com", + items: JSON.stringify([ + { e: first, p: 3000, q: 1 }, + { e: listingIdFor(this, "Part Two"), p: 2000, q: 1 }, + ]), + name: "Both Buyer", + paymentIntent: "pi_multi", + sessionId: "cs_multi", + total: 5000, + }); + this.attendeeId = (await getAttendeesRaw(first))[0]!.id; + this.attendeeName = "Both Buyer"; + }, +); + +Then( + "Part One has earned 30.00 and Part Two has earned 20.00", + async function (this: TicketsWorld): Promise { + expect(await incomeOf(listingIdFor(this, "Part One"))).toBe(3000); + expect(await incomeOf(listingIdFor(this, "Part Two"))).toBe(2000); + expect(await owedBy(bookingId(this))).toBe(0); + expect(await worldBalance()).toBe(-5000); + expect(await sumOfAllBalances()).toBe(0); + }, +); + +Then( + "both places belong to the same order", + async function (this: TicketsWorld): Promise { + const legs = await transfersByAccount(attendeeAccount(bookingId(this))); + expect(legsOfKind(legs, "sale").length).toBe(2); + expect(new Set(legs.map((leg) => leg.eventGroup)).size).toBe(1); + }, +); + +Then( + "each listing's page shows its own earnings", + async function (this: TicketsWorld): Promise { + await assertEditPageIncome(listingIdFor(this, "Part One"), 3000); + await assertEditPageIncome(listingIdFor(this, "Part Two"), 2000); + }, +); + +Given( + "two customers each paid 70.00 for a Festival place", + async function (this: TicketsWorld): Promise { + await buyOnePlace(this, FESTIVAL, "70.00", "Mixed One"); + await createPaidTestAttendee( + listingIdFor(this, FESTIVAL), + "Mixed Two", + "mixed2@example.com", + "pi_mix2", + 7000, + ); + expect(await incomeOf(listingIdFor(this, FESTIVAL))).toBe(14000); + }, +); + +Given( + "the organiser corrected the Festival income to 100.00", + async function (this: TicketsWorld): Promise { + const listingId = listingIdFor(this, FESTIVAL); + const { response } = await adminFormPost( + `/admin/listing/${listingId}/income`, + { income: "100.00" }, + ); + expect(response.status).toBe(302); + expect(await incomeOf(listingId)).toBe(10000); + }, +); + +When( + "the organiser refunds the first customer", + function (this: TicketsWorld): Promise { + return askForRefund(this, true); + }, +); + +Then( + "the Festival earnings and the refunded customer's balance agree", + async function (this: TicketsWorld): Promise { + const listingId = listingIdFor(this, FESTIVAL); + expect(timesProviderWasAsked(this)).toBe(1); + expect(await owedBy(bookingId(this))).toBe(0); + // 140 sold − 40 written down − 70 refunded = 30 on the money record, while + // the listing's income box ignores the refund and still reads 100. + expect(await incomeOf(listingId)).toBe(3000); + await assertStatementBalance(listingId, 3000); + await assertEditPageIncome(listingId, 10000); + }, +); diff --git a/test/specs/support/money.ts b/test/specs/support/money.ts new file mode 100644 index 0000000000..e997e86b56 --- /dev/null +++ b/test/specs/support/money.ts @@ -0,0 +1,101 @@ +/** + * The money set-up every payment story shares: sell a place, pay for it, and + * hand the refund driver's call count to the World so a Then can check whether + * the provider was asked. + */ + +import { expect } from "@std/expect"; +import type { Stub } from "@std/testing/mock"; +import type { Listing } from "#shared/types.ts"; +import { + requiredWorldValue, + type TicketsWorld, +} from "#test/specs/support/world.ts"; +import { expectFlashRedirect } from "#test-utils/assertions.ts"; +import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { + completePaidOrder, + submitRefund, + withRefundMock, +} from "#test-utils/money/drivers.ts"; +import { setupStripe } from "#test-utils/settings.ts"; + +/** The id of a listing the story put on sale, by the name it used. */ +export const listingIdFor = (world: TicketsWorld, name: string): number => + requiredWorldValue(world.listingIds.get(name), `${name} listing id`); + +/** The booking the story is about. */ +export const bookingId = (world: TicketsWorld): number => + requiredWorldValue(world.attendeeId, "attendee id"); + +/** Pounds as the minor units the ledger stores, so a story can say "45.00". */ +export const minorUnits = (pounds: string): number => + Math.round(Number(pounds) * 100); + +/** A listing that sells places at the given price, remembered by name. */ +export const sellPlacesAt = async ( + world: TicketsWorld, + name: string, + pounds: string, +): Promise => { + const listing = await createTestListing({ + maxAttendees: 50, + name, + unitPrice: minorUnits(pounds), + }); + world.listingIds.set(name, listing.id); + return listing; +}; + +/** One customer pays in full for one place, through the real payment return. */ +export const buyOnePlace = async ( + world: TicketsWorld, + name: string, + pounds: string, + who: string, +): Promise => { + await setupStripe(); + const { id: listingId } = await sellPlacesAt(world, name, pounds); + const attendeeId = await completePaidOrder( + listingId, + who, + `${who.toLowerCase().replaceAll(" ", ".")}@example.com`, + minorUnits(pounds), + `cs_${name.toLowerCase().replaceAll(" ", "_")}`, + `pi_${name.toLowerCase().replaceAll(" ", "_")}`, + ); + world.attendeeId = attendeeId; + world.attendeeName = who; + return attendeeId; +}; + +/** Ask for a refund with the provider answering `succeeds`, keeping the reply + * and how many times the provider was asked. */ +export const askForRefund = async ( + world: TicketsWorld, + succeeds: boolean, +): Promise => { + const attendeeId = requiredWorldValue(world.attendeeId, "attendee id"); + const who = requiredWorldValue(world.attendeeName, "attendee name"); + await withRefundMock(succeeds, async (mockRefund: Stub) => { + world.refundResponse = await submitRefund(attendeeId, who); + world.refundCalls = () => mockRefund.calls.length; + }); +}; + +/** How many times the provider was asked to hand money back. */ +export const timesProviderWasAsked = (world: TicketsWorld): number => + requiredWorldValue(world.refundCalls, "refund calls")(); + +/** The message the organiser was shown after asking for a refund. */ +export const expectRefundMessage = ( + world: TicketsWorld, + path: string, + message: string, + succeeded: boolean, +): Promise => + expectFlashRedirect( + path, + expect.stringContaining(message), + succeeded, + )(requiredWorldValue(world.refundResponse, "refund response")); diff --git a/test/specs/support/world.ts b/test/specs/support/world.ts index bb755dab9d..dd6b0ccc56 100644 --- a/test/specs/support/world.ts +++ b/test/specs/support/world.ts @@ -18,9 +18,11 @@ export interface TicketsWorld extends World { holdListingId?: number; listingId?: number; listingIds: Map; + modifierId?: number; placeholderId?: number; questionId?: number; refundCalls?: () => number; + refundResponse?: Response; secondBody?: string; secondStatus?: number; servicingEventId?: number; diff --git a/test/e2e/accounting/drivers.ts b/test/test-utils/money/drivers.ts similarity index 100% rename from test/e2e/accounting/drivers.ts rename to test/test-utils/money/drivers.ts diff --git a/test/e2e/accounting/ledger-helpers.ts b/test/test-utils/money/reads.ts similarity index 93% rename from test/e2e/accounting/ledger-helpers.ts rename to test/test-utils/money/reads.ts index 200cb8c03f..2e02c4a723 100644 --- a/test/e2e/accounting/ledger-helpers.ts +++ b/test/test-utils/money/reads.ts @@ -5,7 +5,11 @@ import { revenueAccount, WORLD, } from "#shared/accounting/accounts.ts"; -import { accountBalance, allTransfers } from "#shared/accounting/queries.ts"; +import { + accountBalance, + allTransfers, + transfersByAccount, +} from "#shared/accounting/queries.ts"; import { formatCurrency, formatSignedCurrency } from "#shared/currency.ts"; import { allBalances } from "#shared/ledger/project.ts"; import type { Transfer } from "#shared/ledger/types.ts"; @@ -149,3 +153,11 @@ export const kindsOf = (legs: Transfer[]): string[] => export const legsOfKind = (legs: Transfer[], kind: string): Transfer[] => legs.filter((leg) => leg.kind === kind); + +/** The legs of one kind posted to an attendee's own ledger account — e.g. the + * `refund_cash` legs (the money handed back) on their account. */ +export const attendeeLegsOfKind = async ( + attendeeId: number, + kind: string, +): Promise => + legsOfKind(await transfersByAccount(attendeeAccount(attendeeId)), kind); From 6f1880b0db9343d554f8f4342cbe332501a80711 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 12:21:56 +0000 Subject: [PATCH 02/16] Cover the money-page reading helpers Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ga3qdj1PzXyZTcjmXZRqz7 --- test/test-utils/money/reads.test.ts | 49 +++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 test/test-utils/money/reads.test.ts diff --git a/test/test-utils/money/reads.test.ts b/test/test-utils/money/reads.test.ts new file mode 100644 index 0000000000..4d5d18e4e6 --- /dev/null +++ b/test/test-utils/money/reads.test.ts @@ -0,0 +1,49 @@ +/** + * Contracts for the money-page reading helpers. The stories rely on these to + * pick a figure out of the right part of a rendered page, so a silent change + * here would weaken every money assertion built on them. + */ + +import { expect } from "@std/expect"; +import { describe, it as test } from "@std/testing/bdd"; +import { formatCurrency } from "#shared/currency.ts"; +import { + incomeLedgerArticle, + signedCurrency, +} from "#test-utils/money/reads.ts"; + +describe("money reads", () => { + describe("signedCurrency", () => { + test("leads a positive figure with a plus", () => { + expect(signedCurrency(500)).toBe(`+${formatCurrency(500)}`); + }); + + test("leads a negative figure with a minus sign, not a hyphen", () => { + expect(signedCurrency(-500)).toBe(`−${formatCurrency(500)}`); + expect(signedCurrency(-500)).not.toContain("-"); + }); + + test("treats zero as a positive figure", () => { + expect(signedCurrency(0)).toBe(`+${formatCurrency(0)}`); + }); + }); + + describe("incomeLedgerArticle", () => { + const page = [ + "

£99 elsewhere on the page

", + '

£45 inside

', + "

£77 after the breakdown

", + ].join(""); + + test("returns only the breakdown, so a figure elsewhere cannot match", () => { + const article = incomeLedgerArticle(page); + expect(article).toContain("£45 inside"); + expect(article).not.toContain("£99"); + expect(article).not.toContain("£77"); + }); + + test("throws when the page has no breakdown at all", () => { + expect(() => incomeLedgerArticle("

no breakdown here

")).toThrow(); + }); + }); +}); From 208ca932a0e6e5497e070e3689a6cfd032e21540 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 12:25:38 +0000 Subject: [PATCH 03/16] Add the two money stories to the catalog test Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ga3qdj1PzXyZTcjmXZRqz7 --- test/scripts/specs/catalog.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/scripts/specs/catalog.test.ts b/test/scripts/specs/catalog.test.ts index 202c8101e1..3e6186f020 100644 --- a/test/scripts/specs/catalog.test.ts +++ b/test/scripts/specs/catalog.test.ts @@ -17,7 +17,9 @@ describe("Cucumber story catalog", () => { "bookings.book-through-the-site", "bookings.volunteer-sign-up", "payments.capacity-after-payment", + "payments.free-and-repeated-money", "payments.provider-choice", + "payments.refunding-a-booking", "servicing.hold-and-cost", ]); }); From 33f3344a6e5ce04af8620f5adb73c441eda82534 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 12:29:46 +0000 Subject: [PATCH 04/16] Cover an order that carries an extra charge The money stories are the only place a paid order carries a charge, and a Cucumber run does not feed the coverage gate, so the driver's charge-carrying path gets a direct test: a signed order with a 10 percent charge books and the charge earns its own money. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ga3qdj1PzXyZTcjmXZRqz7 --- test/test-utils/money/drivers.test.ts | 50 +++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 test/test-utils/money/drivers.test.ts diff --git a/test/test-utils/money/drivers.test.ts b/test/test-utils/money/drivers.test.ts new file mode 100644 index 0000000000..a059b594b2 --- /dev/null +++ b/test/test-utils/money/drivers.test.ts @@ -0,0 +1,50 @@ +/** + * Contract for the paid-order driver's signed metadata. The money stories rely + * on an order carrying its extra charges all the way through the real payment + * return, so if the driver silently dropped them a story could "pass" while + * proving nothing about charges. + */ + +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { modifierAccount } from "#shared/accounting/accounts.ts"; +import { accountBalance } from "#shared/accounting/queries.ts"; +import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; +import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { insertModifier } from "#test-utils/modifiers.ts"; +import { runStripeSuccess } from "#test-utils/money/drivers.ts"; +import { setupStripe } from "#test-utils/settings.ts"; + +describeWithEnv("money drivers", { db: true }, () => { + test("an order's extra charge reaches the site and earns its own money", async () => { + await setupStripe(); + const listing = await createTestListing({ + maxAttendees: 5, + name: "Driver Talk", + unitPrice: 5000, + }); + const modifier = await insertModifier({ + calcKind: "percent", + calcValue: 10, + name: "Driver charge", + }); + + // £50 place plus a 10% charge — the signed total must match what the site + // re-derives, or the payment is refunded instead of booked. + await runStripeSuccess({ + email: "driver@example.com", + items: JSON.stringify([{ e: listing.id, p: 5000, q: 1 }]), + modifiers: [{ i: modifier.id, q: 1 }], + name: "Driver Buyer", + paymentIntent: "pi_driver", + sessionId: "cs_driver", + total: 5500, + }); + + // The booking exists and the charge earned its 10%, which only happens if + // the driver carried the modifier through in the signed metadata. + expect((await getAttendeesRaw(listing.id)).length).toBe(1); + expect(await accountBalance(modifierAccount(modifier.id))).toBe(500); + }); +}); From a468be57fdf14376b1dbf1c5ba9599f812108eea Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 12:34:12 +0000 Subject: [PATCH 05/16] Kill seven surviving ledger mutants Mutation runs over the ledger found seven changes no test caught: - the account key separator could be deleted, letting ("ab","c") and ("a","bc") share one key; the test now covers that exact collision - four identity field names could be blanked without any test noticing, so each is now asserted to be reported by name - the amount and reverses-id rules could reject the smallest real value and stay green, so both boundaries are now checked from the valid side Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ga3qdj1PzXyZTcjmXZRqz7 --- test/shared/ledger/account.test.ts | 8 ++++++++ test/shared/ledger/reconcile.test.ts | 21 +++++++++++++++++++++ test/shared/ledger/validate.test.ts | 10 ++++++++++ 3 files changed, 39 insertions(+) diff --git a/test/shared/ledger/account.test.ts b/test/shared/ledger/account.test.ts index 1de97c6696..4535bfdf99 100644 --- a/test/shared/ledger/account.test.ts +++ b/test/shared/ledger/account.test.ts @@ -25,6 +25,14 @@ describe("accountKey", () => { ); }); + it("does not collide when the split between type and id moves", () => { + // With no separator at all both of these would read "abc"; the separator + // keeps the (type, id) split unambiguous. + expect(accountKey({ id: "c", type: "ab" })).not.toBe( + accountKey({ id: "bc", type: "a" }), + ); + }); + it("does not collide when a part contains a space", () => { // A space separator would map both of these to "a b c"; the NUL separator // keeps them distinct. diff --git a/test/shared/ledger/reconcile.test.ts b/test/shared/ledger/reconcile.test.ts index b69d2c0564..4381a3d588 100644 --- a/test/shared/ledger/reconcile.test.ts +++ b/test/shared/ledger/reconcile.test.ts @@ -63,6 +63,27 @@ describe("legIdentityDiff", () => { expect(legIdentityDiff(legFacts, { ...legFacts, kind: "fee" })).toEqual([ "kind", ]); + expect( + legIdentityDiff(legFacts, { + ...legFacts, + source: account("attendee", 2), + }), + ).toEqual(["source"]); + expect( + legIdentityDiff(legFacts, { + ...legFacts, + destination: account("revenue", 2), + }), + ).toEqual(["destination"]); + expect( + legIdentityDiff(legFacts, { + ...legFacts, + occurredAt: "2026-01-02T00:00:00.000Z", + }), + ).toEqual(["occurredAt"]); + expect(legIdentityDiff(legFacts, { ...legFacts, reversesId: 7 })).toEqual([ + "reversesId", + ]); }); }); diff --git a/test/shared/ledger/validate.test.ts b/test/shared/ledger/validate.test.ts index 262a6c3ea2..9060357d19 100644 --- a/test/shared/ledger/validate.test.ts +++ b/test/shared/ledger/validate.test.ts @@ -72,6 +72,16 @@ describe("validateTransfer", () => { it("accepts a valid positive-integer reverses id", () => { expect(validateTransfer({ ...base, reversesId: 5 }).ok).toBe(true); + // The smallest real row id must pass: the rule rejects 0 and below, not 1. + expect(validateTransfer({ ...base, reversesId: 1 }).ok).toBe(true); + }); + + it("accepts the smallest amount there can be", () => { + // One minor unit is a real amount; only zero and below are rejected. + expect(validateTransfer({ ...base, amount: 1 })).toEqual({ + ok: true, + value: { ...base, amount: 1 }, + }); }); it("accepts a non-canonical instant (no milliseconds or an offset)", () => { From 647e0ef1286e141eeab2d581cb1958605b9f21b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 12:37:07 +0000 Subject: [PATCH 06/16] Split the money steps and fail loudly on a missing booking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refund steps become three story-focused files — the refund itself, the extra charges, and the money shares — so no step module runs long and a mutation run over one story stays narrow. Reading a booking after checkout now goes through soleBookingOn, which throws with the count when a listing does not hold exactly one, instead of quietly taking the first row. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ga3qdj1PzXyZTcjmXZRqz7 --- test/specs/steps/extra-charges.ts | 213 ++++++++++++++++++ test/specs/steps/money-shares.ts | 139 ++++++++++++ test/specs/steps/refunds.ts | 353 +----------------------------- test/specs/support/money.ts | 49 +++++ 4 files changed, 409 insertions(+), 345 deletions(-) create mode 100644 test/specs/steps/extra-charges.ts create mode 100644 test/specs/steps/money-shares.ts diff --git a/test/specs/steps/extra-charges.ts b/test/specs/steps/extra-charges.ts new file mode 100644 index 0000000000..0118310373 --- /dev/null +++ b/test/specs/steps/extra-charges.ts @@ -0,0 +1,213 @@ +// jscpd:ignore-start + +import { Given, Then, When } from "@cucumber/cucumber"; +import { expect } from "@std/expect"; +import { + attendeeAccount, + BOOKING_FEE_INCOME, + modifierAccount, +} from "#shared/accounting/accounts.ts"; +import { + accountBalance, + transfersByAccount, +} from "#shared/accounting/queries.ts"; +import { settings } from "#shared/db/settings.ts"; +import { + bookingId, + buyPlaceWithExtra, + listingIdFor, + sellPlacesAt, + soleBookingOn, +} from "#test/specs/support/money.ts"; +import { + requiredWorldValue, + type TicketsWorld, +} from "#test/specs/support/world.ts"; +import { insertModifier } from "#test-utils/modifiers.ts"; +import { runStripeSuccess } from "#test-utils/money/drivers.ts"; +import { + assertRenderedModifierRevenue, + attendeeLegsOfKind, + incomeOf, + kindsOf, + legsOfKind, + owedBy, + sumOfAllBalances, + worldBalance, +} from "#test-utils/money/reads.ts"; +import { setupStripe } from "#test-utils/settings.ts"; + +// jscpd:ignore-end + +const FEE_DAY = "Fee Day"; +const TALK = "Talk"; +const SERVICE_CHARGE = "Service charge"; + +/** Add the Service charge the Talk scenarios both use. */ +const addServiceCharge = async (world: TicketsWorld): Promise => { + const modifier = await insertModifier({ + calcKind: "percent", + calcValue: 10, + name: SERVICE_CHARGE, + }); + world.modifierId = modifier.id; + return modifier.id; +}; + +Given( + "the site adds a 10 percent booking fee", + async function (this: TicketsWorld): Promise { + await settings.update.bookingFee("10"); + }, +); + +When( + "a customer pays 55.00 for a 50.00 Fee Day place", + function (this: TicketsWorld): Promise { + return buyPlaceWithExtra(this, FEE_DAY, "50.00", "5.00", "Fee Payer"); + }, +); + +Then( + "the Fee Day place has earned 50.00 and the booking fee has earned 5.00", + async function (this: TicketsWorld): Promise { + expect(await incomeOf(listingIdFor(this, FEE_DAY))).toBe(5000); + expect(await accountBalance(BOOKING_FEE_INCOME)).toBe(500); + expect(await worldBalance()).toBe(-5500); + // The fee is its own line on the booking, not part of the ticket's price. + const legs = await transfersByAccount(attendeeAccount(bookingId(this))); + expect(kindsOf(legs)).toEqual(["fee", "payment", "sale"]); + const fee = legsOfKind(legs, "fee")[0]!; + expect(fee.amount).toBe(500); + expect(fee.destination).toEqual(BOOKING_FEE_INCOME); + }, +); + +Then( + "the customer owes nothing", + async function (this: TicketsWorld): Promise { + expect(await owedBy(bookingId(this))).toBe(0); + }, +); + +Given( + "a customer paid a 10 percent booking fee on a 50.00 Fee Day place", + async function (this: TicketsWorld): Promise { + await settings.update.bookingFee("10"); + await buyPlaceWithExtra(this, FEE_DAY, "50.00", "5.00", "Fee Payer"); + expect(await accountBalance(BOOKING_FEE_INCOME)).toBe(500); + }, +); + +Then( + "the Fee Day place and the booking fee have both earned nothing", + async function (this: TicketsWorld): Promise { + expect(await incomeOf(listingIdFor(this, FEE_DAY))).toBe(0); + expect(await accountBalance(BOOKING_FEE_INCOME)).toBe(0); + const handedBackFee = await attendeeLegsOfKind( + bookingId(this), + "refund_fee", + ); + expect(handedBackFee.length).toBe(1); + expect(handedBackFee[0]!.amount).toBe(500); + }, +); + +Then( + "the site is holding none of the customer's money", + async function (this: TicketsWorld): Promise { + expect(await owedBy(bookingId(this))).toBe(0); + expect(await worldBalance()).toBe(0); + expect(await sumOfAllBalances()).toBe(0); + }, +); + +Given( + "a Talk place costs 50.00 and adds a 10 percent Service charge", + async function (this: TicketsWorld): Promise { + await setupStripe(); + await sellPlacesAt(this, TALK, "50.00"); + await addServiceCharge(this); + }, +); + +When( + "a customer pays for one Talk place", + async function (this: TicketsWorld): Promise { + const listingId = listingIdFor(this, TALK); + const modifierId = requiredWorldValue(this.modifierId, "modifier id"); + await runStripeSuccess({ + email: "svc@example.com", + items: JSON.stringify([{ e: listingId, p: 5000, q: 1 }]), + modifiers: [{ i: modifierId, q: 1 }], + name: "Svc Buyer", + paymentIntent: "pi_svc", + sessionId: "cs_svc", + total: 5500, + }); + this.attendeeId = await soleBookingOn(listingId); + this.attendeeName = "Svc Buyer"; + }, +); + +Then( + "the Service charge has earned 5.00", + async function (this: TicketsWorld): Promise { + const modifierId = requiredWorldValue(this.modifierId, "modifier id"); + expect(await accountBalance(modifierAccount(modifierId))).toBe(500); + expect(await incomeOf(listingIdFor(this, TALK))).toBe(5000); + expect(await owedBy(bookingId(this))).toBe(0); + // The charge is its own line, paid to the charge itself. + const legs = await transfersByAccount(attendeeAccount(bookingId(this))); + expect(kindsOf(legs)).toEqual(["modifier", "payment", "sale"]); + const charge = legsOfKind(legs, "modifier")[0]!; + expect(charge.amount).toBe(500); + expect(charge.destination).toEqual(modifierAccount(modifierId)); + }, +); + +Then( + "the organiser's pages show the Service charge earnings", + async function (this: TicketsWorld): Promise { + await assertRenderedModifierRevenue( + requiredWorldValue(this.modifierId, "modifier id"), + 500, + ); + }, +); + +Given( + "a customer paid a 10 percent Service charge on a 50.00 Talk place", + async function (this: TicketsWorld): Promise { + await setupStripe(); + await sellPlacesAt(this, TALK, "50.00"); + const modifierId = await addServiceCharge(this); + await buyPlaceWithExtra( + this, + TALK, + "50.00", + "5.00", + "Svc Buyer", + modifierId, + ); + expect(await accountBalance(modifierAccount(modifierId))).toBe(500); + }, +); + +Then( + "the Service charge has earned nothing", + async function (this: TicketsWorld): Promise { + const modifierId = requiredWorldValue(this.modifierId, "modifier id"); + expect(await accountBalance(modifierAccount(modifierId))).toBe(0); + const handedBackCharge = await attendeeLegsOfKind( + bookingId(this), + "refund_modifier", + ); + expect(handedBackCharge.length).toBe(1); + expect(handedBackCharge[0]!.amount).toBe(500); + }, +); + +Then("no money is left unaccounted for", async (): Promise => { + expect(await sumOfAllBalances()).toBe(0); +}); diff --git a/test/specs/steps/money-shares.ts b/test/specs/steps/money-shares.ts new file mode 100644 index 0000000000..70bcaae50d --- /dev/null +++ b/test/specs/steps/money-shares.ts @@ -0,0 +1,139 @@ +// jscpd:ignore-start + +import { Given, Then, When } from "@cucumber/cucumber"; +import { expect } from "@std/expect"; +import { attendeeAccount } from "#shared/accounting/accounts.ts"; +import { transfersByAccount } from "#shared/accounting/queries.ts"; +import { + askForRefund, + bookingId, + buyOnePlace, + listingIdFor, + sellPlacesAt, + soleBookingOn, + timesProviderWasAsked, +} from "#test/specs/support/money.ts"; +import type { TicketsWorld } from "#test/specs/support/world.ts"; +import { createPaidTestAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; +import { runStripeSuccess } from "#test-utils/money/drivers.ts"; +import { + assertEditPageIncome, + assertStatementBalance, + incomeOf, + legsOfKind, + owedBy, + sumOfAllBalances, + worldBalance, +} from "#test-utils/money/reads.ts"; +import { adminFormPost } from "#test-utils/session.ts"; +import { setupStripe } from "#test-utils/settings.ts"; + +// jscpd:ignore-end + +const FESTIVAL = "Festival"; + +Given( + "Part One costs 30.00 and Part Two costs 20.00", + async function (this: TicketsWorld): Promise { + await setupStripe(); + await sellPlacesAt(this, "Part One", "30.00"); + await sellPlacesAt(this, "Part Two", "20.00"); + }, +); + +When( + "a customer pays 50.00 for one place on each", + async function (this: TicketsWorld): Promise { + const first = listingIdFor(this, "Part One"); + await runStripeSuccess({ + email: "both@example.com", + items: JSON.stringify([ + { e: first, p: 3000, q: 1 }, + { e: listingIdFor(this, "Part Two"), p: 2000, q: 1 }, + ]), + name: "Both Buyer", + paymentIntent: "pi_multi", + sessionId: "cs_multi", + total: 5000, + }); + this.attendeeId = await soleBookingOn(first); + this.attendeeName = "Both Buyer"; + }, +); + +Then( + "Part One has earned 30.00 and Part Two has earned 20.00", + async function (this: TicketsWorld): Promise { + expect(await incomeOf(listingIdFor(this, "Part One"))).toBe(3000); + expect(await incomeOf(listingIdFor(this, "Part Two"))).toBe(2000); + expect(await owedBy(bookingId(this))).toBe(0); + expect(await worldBalance()).toBe(-5000); + expect(await sumOfAllBalances()).toBe(0); + }, +); + +Then( + "both places belong to the same order", + async function (this: TicketsWorld): Promise { + const legs = await transfersByAccount(attendeeAccount(bookingId(this))); + expect(legsOfKind(legs, "sale").length).toBe(2); + expect(new Set(legs.map((leg) => leg.eventGroup)).size).toBe(1); + }, +); + +Then( + "each listing's page shows its own earnings", + async function (this: TicketsWorld): Promise { + await assertEditPageIncome(listingIdFor(this, "Part One"), 3000); + await assertEditPageIncome(listingIdFor(this, "Part Two"), 2000); + }, +); + +Given( + "two customers each paid 70.00 for a Festival place", + async function (this: TicketsWorld): Promise { + await buyOnePlace(this, FESTIVAL, "70.00", "Mixed One"); + await createPaidTestAttendee( + listingIdFor(this, FESTIVAL), + "Mixed Two", + "mixed2@example.com", + "pi_mix2", + 7000, + ); + expect(await incomeOf(listingIdFor(this, FESTIVAL))).toBe(14000); + }, +); + +Given( + "the organiser corrected the Festival income to 100.00", + async function (this: TicketsWorld): Promise { + const listingId = listingIdFor(this, FESTIVAL); + const { response } = await adminFormPost( + `/admin/listing/${listingId}/income`, + { income: "100.00" }, + ); + expect(response.status).toBe(302); + expect(await incomeOf(listingId)).toBe(10000); + }, +); + +When( + "the organiser refunds the first customer", + function (this: TicketsWorld): Promise { + return askForRefund(this, true); + }, +); + +Then( + "the Festival earnings and the refunded customer's balance agree", + async function (this: TicketsWorld): Promise { + const listingId = listingIdFor(this, FESTIVAL); + expect(timesProviderWasAsked(this)).toBe(1); + expect(await owedBy(bookingId(this))).toBe(0); + // 140 sold − 40 written down − 70 refunded = 30 on the money record, while + // the listing's income box ignores the refund and still reads 100. + expect(await incomeOf(listingId)).toBe(3000); + await assertStatementBalance(listingId, 3000); + await assertEditPageIncome(listingId, 10000); + }, +); diff --git a/test/specs/steps/refunds.ts b/test/specs/steps/refunds.ts index 0e91c1f4ad..04a635f117 100644 --- a/test/specs/steps/refunds.ts +++ b/test/specs/steps/refunds.ts @@ -2,103 +2,30 @@ import { Given, Then, When } from "@cucumber/cucumber"; import { expect } from "@std/expect"; -import { - attendeeAccount, - BOOKING_FEE_INCOME, - modifierAccount, - WORLD, -} from "#shared/accounting/accounts.ts"; -import { - accountBalance, - transfersByAccount, -} from "#shared/accounting/queries.ts"; -import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; -import { settings } from "#shared/db/settings.ts"; +import { WORLD } from "#shared/accounting/accounts.ts"; import { askForRefund, bookingId, + bookingPagePath, buyOnePlace, expectRefundMessage, listingIdFor, - minorUnits, - sellPlacesAt, timesProviderWasAsked, } from "#test/specs/support/money.ts"; -import { - requiredWorldValue, - type TicketsWorld, -} from "#test/specs/support/world.ts"; -import { createPaidTestAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; -import { insertModifier } from "#test-utils/modifiers.ts"; -import { runStripeSuccess } from "#test-utils/money/drivers.ts"; +import type { TicketsWorld } from "#test/specs/support/world.ts"; import { adminPageHtml, assertEditPageIncome, - assertRenderedModifierRevenue, assertStatementBalance, attendeeLegsOfKind, incomeOf, - kindsOf, - legsOfKind, owedBy, sumOfAllBalances, - worldBalance, } from "#test-utils/money/reads.ts"; -import { adminFormPost } from "#test-utils/session.ts"; -import { setupStripe } from "#test-utils/settings.ts"; // jscpd:ignore-end const CONCERT = "Concert"; -const FEE_DAY = "Fee Day"; -const TALK = "Talk"; -const FESTIVAL = "Festival"; -const SERVICE_CHARGE = "Service charge"; - -const refundPath = (world: TicketsWorld, page: string): string => - `/admin/attendees/${bookingId(world)}/${page}`; - -/** Sell one place with an extra charge on top and pay the whole amount, the way - * a real checkout does: the signed total must match what the site re-derives. */ -const buyPlaceWithExtra = async ( - world: TicketsWorld, - name: string, - pounds: string, - extraPounds: string, - who: string, - modifierId?: number, -): Promise => { - await setupStripe(); - // The story may already have put this listing on sale (with its extra charge - // attached), so reuse it rather than selling a second one of the same name. - const listingId = - world.listingIds.get(name) ?? (await sellPlacesAt(world, name, pounds)).id; - const price = minorUnits(pounds); - await runStripeSuccess({ - email: `${who.toLowerCase().replaceAll(" ", ".")}@example.com`, - items: JSON.stringify([{ e: listingId, p: price, q: 1 }]), - ...(modifierId === undefined - ? {} - : { modifiers: [{ i: modifierId, q: 1 }] }), - name: who, - paymentIntent: `pi_${name.toLowerCase().replaceAll(" ", "_")}`, - sessionId: `cs_${name.toLowerCase().replaceAll(" ", "_")}`, - total: price + minorUnits(extraPounds), - }); - world.attendeeId = (await getAttendeesRaw(listingId))[0]!.id; - world.attendeeName = who; -}; - -/** Add the Service charge the Talk scenarios both use. */ -const addServiceCharge = async (world: TicketsWorld): Promise => { - const modifier = await insertModifier({ - calcKind: "percent", - calcValue: 10, - name: SERVICE_CHARGE, - }); - world.modifierId = modifier.id; - return modifier.id; -}; Given( "a customer paid 45.00 for a Concert place", @@ -120,7 +47,7 @@ Then( async function (this: TicketsWorld): Promise { await expectRefundMessage( this, - refundPath(this, "actions"), + bookingPagePath(this, "actions"), "Refund issued", true, ); @@ -150,7 +77,7 @@ Then( Then( "the booking page says the booking is fully paid", async function (this: TicketsWorld): Promise { - expect(await adminPageHtml(refundPath(this, "ledger"))).toContain( + expect(await adminPageHtml(bookingPagePath(this, "ledger"))).toContain( "This booking is fully paid", ); }, @@ -163,12 +90,12 @@ Given( await askForRefund(this, true); await expectRefundMessage( this, - refundPath(this, "actions"), + bookingPagePath(this, "actions"), "Refund issued", true, ); // The money event is on the customer's own history for anyone to see. - expect(await adminPageHtml(refundPath(this, "activity"))).toContain( + expect(await adminPageHtml(bookingPagePath(this, "activity"))).toContain( "Refund issued for attendee 'Logged Guest'", ); }, @@ -186,7 +113,7 @@ Then( async function (this: TicketsWorld): Promise { await expectRefundMessage( this, - refundPath(this, "refund"), + bookingPagePath(this, "refund"), "already been refunded", false, ); @@ -210,267 +137,3 @@ Then( expect(await sumOfAllBalances()).toBe(0); }, ); - -Given( - "the site adds a 10 percent booking fee", - async function (this: TicketsWorld): Promise { - await settings.update.bookingFee("10"); - }, -); - -When( - "a customer pays 55.00 for a 50.00 Fee Day place", - function (this: TicketsWorld): Promise { - return buyPlaceWithExtra(this, FEE_DAY, "50.00", "5.00", "Fee Payer"); - }, -); - -Then( - "the Fee Day place has earned 50.00 and the booking fee has earned 5.00", - async function (this: TicketsWorld): Promise { - expect(await incomeOf(listingIdFor(this, FEE_DAY))).toBe(5000); - expect(await accountBalance(BOOKING_FEE_INCOME)).toBe(500); - expect(await worldBalance()).toBe(-5500); - // The fee is its own line on the booking, not part of the ticket's price. - const legs = await transfersByAccount(attendeeAccount(bookingId(this))); - expect(kindsOf(legs)).toEqual(["fee", "payment", "sale"]); - const fee = legsOfKind(legs, "fee")[0]!; - expect(fee.amount).toBe(500); - expect(fee.destination).toEqual(BOOKING_FEE_INCOME); - }, -); - -Then( - "the customer owes nothing", - async function (this: TicketsWorld): Promise { - expect(await owedBy(bookingId(this))).toBe(0); - }, -); - -Given( - "a customer paid a 10 percent booking fee on a 50.00 Fee Day place", - async function (this: TicketsWorld): Promise { - await settings.update.bookingFee("10"); - await buyPlaceWithExtra(this, FEE_DAY, "50.00", "5.00", "Fee Payer"); - expect(await accountBalance(BOOKING_FEE_INCOME)).toBe(500); - }, -); - -Then( - "the Fee Day place and the booking fee have both earned nothing", - async function (this: TicketsWorld): Promise { - expect(await incomeOf(listingIdFor(this, FEE_DAY))).toBe(0); - expect(await accountBalance(BOOKING_FEE_INCOME)).toBe(0); - const handedBackFee = await attendeeLegsOfKind( - bookingId(this), - "refund_fee", - ); - expect(handedBackFee.length).toBe(1); - expect(handedBackFee[0]!.amount).toBe(500); - }, -); - -Then( - "the site is holding none of the customer's money", - async function (this: TicketsWorld): Promise { - expect(await owedBy(bookingId(this))).toBe(0); - expect(await worldBalance()).toBe(0); - expect(await sumOfAllBalances()).toBe(0); - }, -); - -Given( - "a Talk place costs 50.00 and adds a 10 percent Service charge", - async function (this: TicketsWorld): Promise { - await setupStripe(); - await sellPlacesAt(this, TALK, "50.00"); - await addServiceCharge(this); - }, -); - -When( - "a customer pays for one Talk place", - async function (this: TicketsWorld): Promise { - const listingId = listingIdFor(this, TALK); - const modifierId = requiredWorldValue(this.modifierId, "modifier id"); - await runStripeSuccess({ - email: "svc@example.com", - items: JSON.stringify([{ e: listingId, p: 5000, q: 1 }]), - modifiers: [{ i: modifierId, q: 1 }], - name: "Svc Buyer", - paymentIntent: "pi_svc", - sessionId: "cs_svc", - total: 5500, - }); - this.attendeeId = (await getAttendeesRaw(listingId))[0]!.id; - this.attendeeName = "Svc Buyer"; - }, -); - -Then( - "the Service charge has earned 5.00", - async function (this: TicketsWorld): Promise { - const modifierId = requiredWorldValue(this.modifierId, "modifier id"); - expect(await accountBalance(modifierAccount(modifierId))).toBe(500); - expect(await incomeOf(listingIdFor(this, TALK))).toBe(5000); - expect(await owedBy(bookingId(this))).toBe(0); - // The charge is its own line, paid to the charge itself. - const legs = await transfersByAccount(attendeeAccount(bookingId(this))); - expect(kindsOf(legs)).toEqual(["modifier", "payment", "sale"]); - const charge = legsOfKind(legs, "modifier")[0]!; - expect(charge.amount).toBe(500); - expect(charge.destination).toEqual(modifierAccount(modifierId)); - }, -); - -Then( - "the organiser's pages show the Service charge earnings", - async function (this: TicketsWorld): Promise { - await assertRenderedModifierRevenue( - requiredWorldValue(this.modifierId, "modifier id"), - 500, - ); - }, -); - -Given( - "a customer paid a 10 percent Service charge on a 50.00 Talk place", - async function (this: TicketsWorld): Promise { - await setupStripe(); - await sellPlacesAt(this, TALK, "50.00"); - const modifierId = await addServiceCharge(this); - await buyPlaceWithExtra( - this, - TALK, - "50.00", - "5.00", - "Svc Buyer", - modifierId, - ); - expect(await accountBalance(modifierAccount(modifierId))).toBe(500); - }, -); - -Then( - "the Service charge has earned nothing", - async function (this: TicketsWorld): Promise { - const modifierId = requiredWorldValue(this.modifierId, "modifier id"); - expect(await accountBalance(modifierAccount(modifierId))).toBe(0); - const handedBackCharge = await attendeeLegsOfKind( - bookingId(this), - "refund_modifier", - ); - expect(handedBackCharge.length).toBe(1); - expect(handedBackCharge[0]!.amount).toBe(500); - }, -); - -Then("no money is left unaccounted for", async (): Promise => { - expect(await sumOfAllBalances()).toBe(0); -}); - -Given( - "Part One costs 30.00 and Part Two costs 20.00", - async function (this: TicketsWorld): Promise { - await setupStripe(); - await sellPlacesAt(this, "Part One", "30.00"); - await sellPlacesAt(this, "Part Two", "20.00"); - }, -); - -When( - "a customer pays 50.00 for one place on each", - async function (this: TicketsWorld): Promise { - const first = listingIdFor(this, "Part One"); - await runStripeSuccess({ - email: "both@example.com", - items: JSON.stringify([ - { e: first, p: 3000, q: 1 }, - { e: listingIdFor(this, "Part Two"), p: 2000, q: 1 }, - ]), - name: "Both Buyer", - paymentIntent: "pi_multi", - sessionId: "cs_multi", - total: 5000, - }); - this.attendeeId = (await getAttendeesRaw(first))[0]!.id; - this.attendeeName = "Both Buyer"; - }, -); - -Then( - "Part One has earned 30.00 and Part Two has earned 20.00", - async function (this: TicketsWorld): Promise { - expect(await incomeOf(listingIdFor(this, "Part One"))).toBe(3000); - expect(await incomeOf(listingIdFor(this, "Part Two"))).toBe(2000); - expect(await owedBy(bookingId(this))).toBe(0); - expect(await worldBalance()).toBe(-5000); - expect(await sumOfAllBalances()).toBe(0); - }, -); - -Then( - "both places belong to the same order", - async function (this: TicketsWorld): Promise { - const legs = await transfersByAccount(attendeeAccount(bookingId(this))); - expect(legsOfKind(legs, "sale").length).toBe(2); - expect(new Set(legs.map((leg) => leg.eventGroup)).size).toBe(1); - }, -); - -Then( - "each listing's page shows its own earnings", - async function (this: TicketsWorld): Promise { - await assertEditPageIncome(listingIdFor(this, "Part One"), 3000); - await assertEditPageIncome(listingIdFor(this, "Part Two"), 2000); - }, -); - -Given( - "two customers each paid 70.00 for a Festival place", - async function (this: TicketsWorld): Promise { - await buyOnePlace(this, FESTIVAL, "70.00", "Mixed One"); - await createPaidTestAttendee( - listingIdFor(this, FESTIVAL), - "Mixed Two", - "mixed2@example.com", - "pi_mix2", - 7000, - ); - expect(await incomeOf(listingIdFor(this, FESTIVAL))).toBe(14000); - }, -); - -Given( - "the organiser corrected the Festival income to 100.00", - async function (this: TicketsWorld): Promise { - const listingId = listingIdFor(this, FESTIVAL); - const { response } = await adminFormPost( - `/admin/listing/${listingId}/income`, - { income: "100.00" }, - ); - expect(response.status).toBe(302); - expect(await incomeOf(listingId)).toBe(10000); - }, -); - -When( - "the organiser refunds the first customer", - function (this: TicketsWorld): Promise { - return askForRefund(this, true); - }, -); - -Then( - "the Festival earnings and the refunded customer's balance agree", - async function (this: TicketsWorld): Promise { - const listingId = listingIdFor(this, FESTIVAL); - expect(timesProviderWasAsked(this)).toBe(1); - expect(await owedBy(bookingId(this))).toBe(0); - // 140 sold − 40 written down − 70 refunded = 30 on the money record, while - // the listing's income box ignores the refund and still reads 100. - expect(await incomeOf(listingId)).toBe(3000); - await assertStatementBalance(listingId, 3000); - await assertEditPageIncome(listingId, 10000); - }, -); diff --git a/test/specs/support/money.ts b/test/specs/support/money.ts index e997e86b56..e6d2030879 100644 --- a/test/specs/support/money.ts +++ b/test/specs/support/money.ts @@ -6,6 +6,7 @@ import { expect } from "@std/expect"; import type { Stub } from "@std/testing/mock"; +import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; import type { Listing } from "#shared/types.ts"; import { requiredWorldValue, @@ -15,6 +16,7 @@ import { expectFlashRedirect } from "#test-utils/assertions.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; import { completePaidOrder, + runStripeSuccess, submitRefund, withRefundMock, } from "#test-utils/money/drivers.ts"; @@ -69,6 +71,53 @@ export const buyOnePlace = async ( return attendeeId; }; +/** The one booking a checkout made on a listing. Fails loudly when there is + * not exactly one, so a story can never carry on against an arbitrary row. */ +export const soleBookingOn = async (listingId: number): Promise => { + const bookings = await getAttendeesRaw(listingId); + if (bookings.length !== 1) { + throw new Error( + `Expected one booking on listing ${listingId}, found ${bookings.length}`, + ); + } + return bookings[0]!.id; +}; + +/** Sell one place with an extra charge on top and pay the whole amount, the way + * a real checkout does: the signed total must match what the site re-derives. */ +export const buyPlaceWithExtra = async ( + world: TicketsWorld, + name: string, + pounds: string, + extraPounds: string, + who: string, + modifierId?: number, +): Promise => { + await setupStripe(); + // The story may already have put this listing on sale (with its extra charge + // attached), so reuse it rather than selling a second one of the same name. + const listingId = + world.listingIds.get(name) ?? (await sellPlacesAt(world, name, pounds)).id; + const price = minorUnits(pounds); + await runStripeSuccess({ + email: `${who.toLowerCase().replaceAll(" ", ".")}@example.com`, + items: JSON.stringify([{ e: listingId, p: price, q: 1 }]), + ...(modifierId === undefined + ? {} + : { modifiers: [{ i: modifierId, q: 1 }] }), + name: who, + paymentIntent: `pi_${name.toLowerCase().replaceAll(" ", "_")}`, + sessionId: `cs_${name.toLowerCase().replaceAll(" ", "_")}`, + total: price + minorUnits(extraPounds), + }); + world.attendeeId = await soleBookingOn(listingId); + world.attendeeName = who; +}; + +/** One of the booking's own admin pages. */ +export const bookingPagePath = (world: TicketsWorld, page: string): string => + `/admin/attendees/${bookingId(world)}/${page}`; + /** Ask for a refund with the provider answering `succeeds`, keeping the reply * and how many times the provider was asked. */ export const askForRefund = async ( From fec22b4ea7159d1bc397a3c786ddb5af38c0ced1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 12:43:48 +0000 Subject: [PATCH 07/16] Kill eight surviving accounting mutants Mutation runs over the money paths found eight changes no test caught: - a one-unit correction could flow the wrong way and stay green, so the smallest credit now asserts its direction - the three correction kinds could share one event key, which would take the second and third for a replay of the first; each now records its own event - the duplicate-reference and shared-event guards could be deleted, because a later guard's message also matched; both messages are now exact - a one-leg event could be posted twice, so a replay of a single leg now asserts it is skipped Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ga3qdj1PzXyZTcjmXZRqz7 --- test/shared/accounting/adjustments.test.ts | 10 ++++++++++ test/shared/accounting/ledger-tx.test.ts | 14 ++++++++++++++ test/shared/accounting/store.test.ts | 18 ++++++++++++++++-- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/test/shared/accounting/adjustments.test.ts b/test/shared/accounting/adjustments.test.ts index 31ec0812db..030436310d 100644 --- a/test/shared/accounting/adjustments.test.ts +++ b/test/shared/accounting/adjustments.test.ts @@ -52,6 +52,16 @@ describe("db > accounting > postWriteoffAdjustment", () => { expect(await accountBalance(revenue)).toBe(-1200); }); + test("the smallest credit still flows into the account", async () => { + // One minor unit is a credit like any other: the rule turns on the sign of + // the delta, not on it clearing some larger figure. + const leg = await postSoleAdjustment(1); + expect(leg.amount).toBe(1); + expect(accountKey(leg.source)).toBe(accountKey(WRITEOFF)); + expect(accountKey(leg.destination)).toBe(accountKey(revenue)); + expect(await accountBalance(revenue)).toBe(1); + }); + test("amount is the absolute value of the delta either way", async () => { await postWriteoffAdjustment(revenue, -300, ["income-adjust", 7]); const [leg] = await allTransfers(); diff --git a/test/shared/accounting/ledger-tx.test.ts b/test/shared/accounting/ledger-tx.test.ts index 4d90e9150f..7eddd663f5 100644 --- a/test/shared/accounting/ledger-tx.test.ts +++ b/test/shared/accounting/ledger-tx.test.ts @@ -64,6 +64,20 @@ describe("db > accounting > ledger-tx", () => { expect(await readIncome(1)).toBe(8000); }); + test("each kind of correction records its own event, never a shared one", async () => { + // Income, modifier-revenue and owed corrections of the same size on the + // same id must not share an event group: if they did, the second and third + // would be taken for a replay of the first and post nothing. + await inOwnTx(ledgerTx.correct.income)(4, 500); + await inOwnTx(ledgerTx.correct.modifierRevenue)(4, 500); + await inOwnTx(ledgerTx.correct.owed)(4, 500); + + const legs = await allTransfers(); + expect(legs.length).toBe(3); + expect(new Set(legs.map((leg) => leg.eventGroup)).size).toBe(3); + expect(new Set(legs.map((leg) => leg.reference)).size).toBe(3); + }); + 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 diff --git a/test/shared/accounting/store.test.ts b/test/shared/accounting/store.test.ts index 57419b3369..d8817ffb0e 100644 --- a/test/shared/accounting/store.test.ts +++ b/test/shared/accounting/store.test.ts @@ -93,7 +93,12 @@ describe("db > accounting > store", () => { }), ]), ); - expect(error.message).toContain("duplicate reference"); + // The exact message matters: this guard runs before any database work, + // so the batch never opens a transaction. The batch-level guard reports + // a different message, which would not satisfy this. + expect(error.message).toBe( + "postTransfers: duplicate reference within one event", + ); expect((await allTransfers()).length).toBe(0); }); @@ -131,7 +136,16 @@ describe("db > accounting > store", () => { tx({ eventGroup: "evt-b", reference: "b" }), ]), ); - expect(error.message).toContain("one eventGroup"); + expect(error.message).toBe( + "postTransfers: every leg must share one eventGroup (got evt-a, evt-b)", + ); + }); + + test("replaying a single-leg event skips it rather than posting again", async () => { + const lone = [tx({ eventGroup: "lone", reference: "lone-sale" })]; + expect(await postTransfers(lone)).toEqual({ inserted: 1, skipped: 0 }); + expect(await postTransfers(lone)).toEqual({ inserted: 0, skipped: 1 }); + expect((await transfersByEventGroup("lone")).length).toBe(1); }); test("treats an empty post as a no-op", async () => { From 846386f8499258f2067453fda34a8f005830c9fb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 12:46:35 +0000 Subject: [PATCH 08/16] Fold the adjustment direction checks into one The new smallest-credit test repeated the existing credit assertions, so all three direction checks now run through one parameterised check that spells out every expected value rather than re-deriving the rule. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ga3qdj1PzXyZTcjmXZRqz7 --- test/shared/accounting/adjustments.test.ts | 80 +++++++++++++++------- 1 file changed, 54 insertions(+), 26 deletions(-) diff --git a/test/shared/accounting/adjustments.test.ts b/test/shared/accounting/adjustments.test.ts index 030436310d..0a8068d40d 100644 --- a/test/shared/accounting/adjustments.test.ts +++ b/test/shared/accounting/adjustments.test.ts @@ -5,6 +5,7 @@ import { revenueAccount, WRITEOFF } from "#shared/accounting/accounts.ts"; import { writeoffAdjustmentInserts } from "#shared/accounting/adjustments.ts"; import { accountBalance, allTransfers } from "#shared/accounting/queries.ts"; import { accountKey } from "#shared/ledger/account.ts"; +import type { AccountRef } from "#shared/ledger/types.ts"; import { postWriteoffAdjustment, useTransactionalDb, @@ -29,37 +30,64 @@ describe("db > accounting > postWriteoffAdjustment", () => { expect(await allTransfers()).toEqual([]); }); - test("a positive delta credits the account (writeoff → account)", async () => { - const leg = await postSoleAdjustment(1500); + /** Post one adjustment and assert the leg it wrote: how big it is, which way + * the money flowed, and the balance it left behind. Every expected value is + * spelled out, so the check never re-derives the rule it is testing. */ + const expectAdjustment = async ({ + amount, + balance, + delta, + from, + to, + }: { + amount: number; + balance: number; + delta: number; + from: AccountRef; + to: AccountRef; + }): Promise => { + const leg = await postSoleAdjustment(delta); expect(leg.kind).toBe("adjustment"); - expect(leg.amount).toBe(1500); - // Crediting the account: money flows from writeoff into the account. - expect(accountKey(leg.source)).toBe(accountKey(WRITEOFF)); - expect(accountKey(leg.destination)).toBe(accountKey(revenue)); - // balanceOf(account) rises by the delta. - expect(await accountBalance(revenue)).toBe(1500); - }); + expect(leg.amount).toBe(amount); + expect(accountKey(leg.source)).toBe(accountKey(from)); + expect(accountKey(leg.destination)).toBe(accountKey(to)); + expect(await accountBalance(revenue)).toBe(balance); + }; - test("a negative delta debits the account (account → writeoff)", async () => { - const leg = await postSoleAdjustment(-1200); - expect(leg.kind).toBe("adjustment"); - // amount is the magnitude of the delta. - expect(leg.amount).toBe(1200); - // Debiting the account: money flows from the account out to writeoff. - expect(accountKey(leg.source)).toBe(accountKey(revenue)); - expect(accountKey(leg.destination)).toBe(accountKey(WRITEOFF)); - // balanceOf(account) falls by the delta. - expect(await accountBalance(revenue)).toBe(-1200); + test("a positive delta credits the account (writeoff → account)", async () => { + // Crediting the account: money flows from writeoff into the account, and + // its balance rises by the delta. + await expectAdjustment({ + amount: 1500, + balance: 1500, + delta: 1500, + from: WRITEOFF, + to: revenue, + }); }); test("the smallest credit still flows into the account", async () => { - // One minor unit is a credit like any other: the rule turns on the sign of - // the delta, not on it clearing some larger figure. - const leg = await postSoleAdjustment(1); - expect(leg.amount).toBe(1); - expect(accountKey(leg.source)).toBe(accountKey(WRITEOFF)); - expect(accountKey(leg.destination)).toBe(accountKey(revenue)); - expect(await accountBalance(revenue)).toBe(1); + // One minor unit is a credit like any other: the direction turns on the + // sign of the delta, not on it clearing some larger figure. + await expectAdjustment({ + amount: 1, + balance: 1, + delta: 1, + from: WRITEOFF, + to: revenue, + }); + }); + + test("a negative delta debits the account (account → writeoff)", async () => { + // Debiting the account: money flows out to writeoff, its balance falls, and + // the amount posted is the magnitude of the delta. + await expectAdjustment({ + amount: 1200, + balance: -1200, + delta: -1200, + from: revenue, + to: WRITEOFF, + }); }); test("amount is the absolute value of the delta either way", async () => { From f65c879f30130685ef07163292d3511391e6053d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 12:49:26 +0000 Subject: [PATCH 09/16] Prove each correction kind keys its own event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference a correction is filed under is built from its kind, its id, the size of the change and the moment — the account is not part of it, so the kind is the only thing keeping an income correction apart from a modifier-revenue one of the same size on the same id. With the clock frozen so both share one moment, the test now shows both post. Without the kind in the key the second would be dropped as a replay, and a modifier's correction would silently vanish. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ga3qdj1PzXyZTcjmXZRqz7 --- test/shared/accounting/ledger-tx.test.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/test/shared/accounting/ledger-tx.test.ts b/test/shared/accounting/ledger-tx.test.ts index 7eddd663f5..a80266b42c 100644 --- a/test/shared/accounting/ledger-tx.test.ts +++ b/test/shared/accounting/ledger-tx.test.ts @@ -12,6 +12,7 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; +import { FakeTime } from "@std/testing/time"; import { inOwnTx, ledgerTx } from "#shared/accounting/ledger-tx.ts"; import { allTransfers } from "#shared/accounting/queries.ts"; import { postTransfers } from "#shared/accounting/store.ts"; @@ -64,18 +65,22 @@ describe("db > accounting > ledger-tx", () => { expect(await readIncome(1)).toBe(8000); }); - test("each kind of correction records its own event, never a shared one", async () => { - // Income, modifier-revenue and owed corrections of the same size on the - // same id must not share an event group: if they did, the second and third - // would be taken for a replay of the first and post nothing. + 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); - await inOwnTx(ledgerTx.correct.owed)(4, 500); const legs = await allTransfers(); - expect(legs.length).toBe(3); - expect(new Set(legs.map((leg) => leg.eventGroup)).size).toBe(3); - expect(new Set(legs.map((leg) => leg.reference)).size).toBe(3); + 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 () => { From 1323546bccb7b5ddd3c9eef24e53d86f21f72c93 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 12:51:48 +0000 Subject: [PATCH 10/16] Assert a correction is filed under its own kind Each of the three correction kinds now checks its stored key against one rebuilt from the kind, the id, the size of the change and the moment, so dropping the kind from the key can no longer pass unnoticed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ga3qdj1PzXyZTcjmXZRqz7 --- test/shared/accounting/ledger-tx.test.ts | 51 +++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/test/shared/accounting/ledger-tx.test.ts b/test/shared/accounting/ledger-tx.test.ts index a80266b42c..e98b017ca6 100644 --- a/test/shared/accounting/ledger-tx.test.ts +++ b/test/shared/accounting/ledger-tx.test.ts @@ -15,9 +15,11 @@ import { describe, it as test } from "@std/testing/bdd"; import { FakeTime } from "@std/testing/time"; import { inOwnTx, ledgerTx } from "#shared/accounting/ledger-tx.ts"; import { allTransfers } from "#shared/accounting/queries.ts"; +import { eventGroup, legReference } from "#shared/accounting/refs.ts"; import { postTransfers } from "#shared/accounting/store.ts"; -import { withTransaction } from "#shared/db/client.ts"; +import { type TxScope, withTransaction } from "#shared/db/client.ts"; import { account } from "#shared/ledger/account.ts"; +import { nowIso } from "#shared/now.ts"; import { postListingSale, postModifierLeg, @@ -65,6 +67,53 @@ describe("db > accounting > ledger-tx", () => { 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, + kind: string, + target: number, + delta: number, + ): Promise => { + using _time = new FakeTime(new Date("2026-06-21T00:00:00.000Z")); + await inOwnTx(correct)(4, target); + const [leg, ...rest] = await allTransfers(); + expect(rest).toEqual([]); + 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 From 07fd7a5126596ade752fe463dcce423abb04d85e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 12:55:04 +0000 Subject: [PATCH 11/16] Drop a stale comment from the money drivers The attendee-balance helper carried a leftover comment describing a form- scraping helper that no longer exists. Comments describe the code as it is, so it goes. Addresses the CodeRabbit review finding on test/test-utils/money/drivers.ts (its inline comment failed to post, so there is no thread to reply on). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ga3qdj1PzXyZTcjmXZRqz7 --- test/test-utils/money/drivers.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/test/test-utils/money/drivers.ts b/test/test-utils/money/drivers.ts index 98415d7ec4..018653b97f 100644 --- a/test/test-utils/money/drivers.ts +++ b/test/test-utils/money/drivers.ts @@ -266,13 +266,8 @@ export const submitRefund = async ( return response; }; -// -- Attendee-edit driver (scrapes the real edit form) -------------------- // +// -- Attendee-balance driver ---------------------------------------------- // -/** Extract the hidden/select fields the attendee edit form round-trips - * (`line_listing_*`, `qty_*`, `line_key_*`, `line_package_*`, `status_id`) - * from the rendered edit page, so a balance correction re-submits the EXACT - * booking and changes only the owed figure — exactly what a browser would - * post back. */ /** Move an attendee's owed balance through the ledger — the proper path now the * attendee form no longer edits balances. `MANUAL_ATTENDEE_WRITEOFF` lowers what * they owe (a goodwill write-off), `MANUAL_ATTENDEE_CHARGE` raises it, each by From 6ce2c67ff5709ce88dde42f5351b68132966e742 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 13:09:43 +0000 Subject: [PATCH 12/16] Address the second Codex review round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete the old refund-and-second-refund test that the story replaced. It was still running the same journey, leaving two paths for one behaviour — my miss when migrating it. - Split the four unrelated rules into their own stories: free bookings, repeated money actions, and how a listing's income adds up. The turned-down refund is a refund rule, so it joins the refund story. - Drive the refund from its own page: the story now opens the refund page, checks it offers the confirm-by-typing box, and submits that form with the page's own token, then reads the message the organiser is shown. - Use the production money formatter instead of a copy of it. The copy signed a zero, which the real page never does, so the test had locked in a format the page does not use. - Check the success message after each income correction, since a failed save redirects too. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ga3qdj1PzXyZTcjmXZRqz7 --- .../payments/free-and-repeated-money.feature | 65 ------------------- specs/payments/free-bookings.feature | 19 ++++++ .../payments/income-figures-explained.feature | 21 ++++++ specs/payments/refunding-a-booking.feature | 12 ++++ specs/payments/repeated-money-actions.feature | 25 +++++++ .../accounting/bulk-merges-and-pwyw.test.ts | 56 ---------------- test/scripts/specs/catalog.test.ts | 4 +- test/specs/steps/money-actions.ts | 22 ++++--- test/specs/steps/money-shares.ts | 6 +- test/specs/steps/refunds.ts | 9 +-- test/specs/support/money.ts | 33 ++++++---- test/specs/support/world.ts | 1 - test/test-utils/money/reads.test.ts | 21 +----- test/test-utils/money/reads.ts | 6 -- 14 files changed, 121 insertions(+), 179 deletions(-) delete mode 100644 specs/payments/free-and-repeated-money.feature create mode 100644 specs/payments/free-bookings.feature create mode 100644 specs/payments/income-figures-explained.feature create mode 100644 specs/payments/repeated-money-actions.feature diff --git a/specs/payments/free-and-repeated-money.feature b/specs/payments/free-and-repeated-money.feature deleted file mode 100644 index a61979f5e3..0000000000 --- a/specs/payments/free-and-repeated-money.feature +++ /dev/null @@ -1,65 +0,0 @@ -@story:payments.free-and-repeated-money -@owner:payments @risk:high -@actor:organiser @actor:customer -@edition:managed @edition:self-hosted -Feature: Free bookings and repeated money actions - A free booking must never invent money, a refund the provider turns down must - never pretend to have happened, and doing the same thing twice must never - count it twice. The organiser can also see how the two income figures on a - listing are worked out. - - @rule:payments.a-free-booking-records-no-money - @surface:admin - Rule: A free booking records no money at all - Even with a booking fee set up, nothing is charged and nothing is recorded. - - @case:payment.free-booking-records-no-money - Scenario: A customer books a free place while a booking fee is set up - Given the site adds a 10 percent booking fee - When a customer books a free Free Meetup place - Then no money is recorded for the booking - And no booking fee is recorded - - @rule:payments.a-turned-down-refund-changes-nothing - @surface:admin - Rule: A refund the provider turns down changes nothing - The organiser is told it failed, and the books are exactly as they were. - - @case:payment.declined-refund-changes-nothing - Scenario: The payment provider turns down the refund - Given a customer paid 45.00 for a Show place - When the organiser asks for a refund and the provider turns it down - Then the organiser is told the refund failed - And the Show has still earned 45.00 and no money was handed back - - @rule:payments.doing-it-twice-counts-once - @surface:admin - Rule: Doing the same thing twice counts it only once - A repeated payment message makes no second booking, and re-saving the same - income figure makes no second correction. - - @case:payment.replayed-payment-counts-once - Scenario: The same payment message arrives again - Given a customer paid 60.00 for a Repeat place - When the same payment message arrives again - Then there is still one booking and one sale - - @case:payment.repeated-correction-counts-once - Scenario: The organiser saves the same income figure twice - Given a customer paid 60.00 for a Repeat place - When the organiser sets the Repeat income to 40.00 twice - Then the Repeat has earned 40.00 from a single correction - - @rule:payments.the-income-figures-are-explained - @surface:admin - Rule: The listing page explains how its income figures are worked out - Sales, corrections and refunds are each listed with their own sign, so the - two income figures can never quietly disagree. - - @case:payment.income-breakdown-explains-the-figures - Scenario: The organiser reads the money breakdown after a correction and a refund - Given a customer paid 50.00 for a Reconciled place - And the organiser corrected the Reconciled income to 40.00 - When the organiser refunds the booking - Then the Reconciled page breaks the money down line by line - And the breakdown links to the Reconciled money record diff --git a/specs/payments/free-bookings.feature b/specs/payments/free-bookings.feature new file mode 100644 index 0000000000..1677081dcc --- /dev/null +++ b/specs/payments/free-bookings.feature @@ -0,0 +1,19 @@ +@story:payments.free-bookings +@owner:payments @risk:medium +@actor:organiser @actor:customer +@edition:managed @edition:self-hosted +Feature: A free booking costs nothing + A listing given away for free must never invent money, whatever fees the site + charges on its paid listings. + + @rule:payments.a-free-booking-records-no-money + @surface:admin + Rule: A free booking records no money at all + Even with a booking fee set up, nothing is charged and nothing is recorded. + + @case:payment.free-booking-records-no-money + Scenario: A customer books a free place while a booking fee is set up + Given the site adds a 10 percent booking fee + When a customer books a free Free Meetup place + Then no money is recorded for the booking + And no booking fee is recorded diff --git a/specs/payments/income-figures-explained.feature b/specs/payments/income-figures-explained.feature new file mode 100644 index 0000000000..ccb57773c1 --- /dev/null +++ b/specs/payments/income-figures-explained.feature @@ -0,0 +1,21 @@ +@story:payments.income-figures-explained +@owner:payments @risk:medium +@actor:organiser +@edition:managed @edition:self-hosted +Feature: An organiser can see how a listing's income adds up + A listing shows two income figures. The page must explain, line by line, how + each one is worked out, so they can never quietly disagree. + + @rule:payments.the-income-figures-are-explained + @surface:admin + Rule: The listing page explains how its income figures are worked out + Sales, corrections and refunds are each listed with their own sign, so the + two income figures can never quietly disagree. + + @case:payment.income-breakdown-explains-the-figures + Scenario: The organiser reads the money breakdown after a correction and a refund + Given a customer paid 50.00 for a Reconciled place + And the organiser corrected the Reconciled income to 40.00 + When the organiser refunds the booking + Then the Reconciled page breaks the money down line by line + And the breakdown links to the Reconciled money record diff --git a/specs/payments/refunding-a-booking.feature b/specs/payments/refunding-a-booking.feature index 6e595a4d32..218edb8d02 100644 --- a/specs/payments/refunding-a-booking.feature +++ b/specs/payments/refunding-a-booking.feature @@ -100,3 +100,15 @@ Feature: An organiser refunds a booking When the organiser refunds the first customer Then no money is left unaccounted for And the Festival earnings and the refunded customer's balance agree + + @rule:payments.a-turned-down-refund-changes-nothing + @surface:admin + Rule: A refund the provider turns down changes nothing + The organiser is told it failed, and the books are exactly as they were. + + @case:payment.declined-refund-changes-nothing + Scenario: The payment provider turns down the refund + Given a customer paid 45.00 for a Show place + When the organiser asks for a refund and the provider turns it down + Then the organiser is told the refund failed + And the Show has still earned 45.00 and no money was handed back diff --git a/specs/payments/repeated-money-actions.feature b/specs/payments/repeated-money-actions.feature new file mode 100644 index 0000000000..39dd9ba6c1 --- /dev/null +++ b/specs/payments/repeated-money-actions.feature @@ -0,0 +1,25 @@ +@story:payments.repeated-money-actions +@owner:payments @risk:high +@actor:organiser @actor:customer +@edition:managed @edition:self-hosted +Feature: Doing the same money action twice counts it once + A payment message that arrives again, or an income figure saved again, must + leave the books exactly as they already were. + + @rule:payments.doing-it-twice-counts-once + @surface:admin + Rule: Doing the same thing twice counts it only once + A repeated payment message makes no second booking, and re-saving the same + income figure makes no second correction. + + @case:payment.replayed-payment-counts-once + Scenario: The same payment message arrives again + Given a customer paid 60.00 for a Repeat place + When the same payment message arrives again + Then there is still one booking and one sale + + @case:payment.repeated-correction-counts-once + Scenario: The organiser saves the same income figure twice + Given a customer paid 60.00 for a Repeat place + When the organiser sets the Repeat income to 40.00 twice + Then the Repeat has earned 40.00 from a single correction diff --git a/test/e2e/accounting/bulk-merges-and-pwyw.test.ts b/test/e2e/accounting/bulk-merges-and-pwyw.test.ts index dd434d5d8c..d54da303a5 100644 --- a/test/e2e/accounting/bulk-merges-and-pwyw.test.ts +++ b/test/e2e/accounting/bulk-merges-and-pwyw.test.ts @@ -11,13 +11,11 @@ import { createTestAttendeeDirect } from "#test-utils/db-helpers/attendees.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; import { singleItem } from "#test-utils/factories.ts"; import { - completePaidOrder, describeAccounting, mergePost, mergePreview, moneyFieldFor, runStripeSuccess, - submitRefund, twoPaidDuplicates, withRefundMock, } from "#test-utils/money/drivers.ts"; @@ -116,60 +114,6 @@ describeAccounting(() => { expect(await sumOfAllBalances()).toBe(0); }); - // 16. A refund is written to the attendee's own activity log (the audit trail - // that makes a ledger change visible — transparency), and a SECOND refund is - // rejected before the provider is even called, posting no duplicate leg. - test("a refund is logged on the attendee and a second refund is a safe no-op", async () => { - await setupStripe(); - const listing = await createTestListing({ - maxAttendees: 50, - name: "Logged", - unitPrice: 4500, - }); - const attendeeId = await completePaidOrder( - listing.id, - "Logged Guest", - "logged@example.com", - 4500, - "cs_log", - "pi_log", - ); - - await withRefundMock(true, async (mockRefund) => { - const refund = await submitRefund(attendeeId, "Logged Guest"); - await expectFlashRedirect( - `/admin/attendees/${attendeeId}/actions`, - "Refund issued", - )(refund); - expect(mockRefund.calls.length).toBe(1); - }); - expect(await owedBy(attendeeId)).toBe(0); - const refundCash = await attendeeLegsOfKind(attendeeId, "refund_cash"); - expect(refundCash.length).toBe(1); - - // Transparency: the money event shows in the attendee's activity log. - const activityPage = await adminPageHtml( - `/admin/attendees/${attendeeId}/activity`, - ); - expect(activityPage).toContain("Refund issued for attendee 'Logged Guest'"); - - // Idempotency: a second refund is refused (already refunded) without calling - // the provider, and no second reversal is posted. - await withRefundMock(true, async (mockRefund) => { - const again = await submitRefund(attendeeId, "Logged Guest"); - await expectFlashRedirect( - `/admin/attendees/${attendeeId}/refund`, - expect.stringContaining("already been refunded"), - false, - )(again); - expect(mockRefund.calls.length).toBe(0); - }); - expect((await attendeeLegsOfKind(attendeeId, "refund_cash")).length).toBe( - 1, - ); - expect(await sumOfAllBalances()).toBe(0); - }); - // 17. Merging two PAID duplicate bookings, CREDIT choice (decision 17 — an // explicit operator choice, never a silent default): the survivor keeps ONE // ticket, the discarded duplicate's recognised sale is un-billed (so the diff --git a/test/scripts/specs/catalog.test.ts b/test/scripts/specs/catalog.test.ts index 3e6186f020..02264fec39 100644 --- a/test/scripts/specs/catalog.test.ts +++ b/test/scripts/specs/catalog.test.ts @@ -17,9 +17,11 @@ describe("Cucumber story catalog", () => { "bookings.book-through-the-site", "bookings.volunteer-sign-up", "payments.capacity-after-payment", - "payments.free-and-repeated-money", + "payments.free-bookings", + "payments.income-figures-explained", "payments.provider-choice", "payments.refunding-a-booking", + "payments.repeated-money-actions", "servicing.hold-and-cost", ]); }); diff --git a/test/specs/steps/money-actions.ts b/test/specs/steps/money-actions.ts index 657c868cba..c6e8da8417 100644 --- a/test/specs/steps/money-actions.ts +++ b/test/specs/steps/money-actions.ts @@ -11,7 +11,7 @@ import { accountBalance, transfersByAccount, } from "#shared/accounting/queries.ts"; -import { formatCurrency } from "#shared/currency.ts"; +import { formatCurrency, formatSignedCurrency } from "#shared/currency.ts"; import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; import { askForRefund, @@ -23,6 +23,7 @@ import { timesProviderWasAsked, } from "#test/specs/support/money.ts"; import type { TicketsWorld } from "#test/specs/support/world.ts"; +import { expectFlashRedirect } from "#test-utils/assertions.ts"; import { createTestAttendee } from "#test-utils/db-helpers/attendees.ts"; import { singleItem } from "#test-utils/factories.ts"; import { withStripeSuccess } from "#test-utils/money/drivers.ts"; @@ -33,7 +34,6 @@ import { kindsOf, legsOfKind, owedBy, - signedCurrency, sumOfAllBalances, worldBalance, } from "#test-utils/money/reads.ts"; @@ -46,7 +46,9 @@ const SHOW = "Show"; const REPEAT = "Repeat"; const RECONCILED = "Reconciled"; -/** Set a listing's income to the given figure through the real correction form. */ +/** Set a listing's income to the given figure through the real correction form, + * and check the organiser is told it worked — a failed save redirects too, so a + * bare redirect would not show the difference. */ const correctIncomeTo = async ( listingId: number, pounds: string, @@ -55,7 +57,10 @@ const correctIncomeTo = async ( `/admin/listing/${listingId}/income`, { income: pounds }, ); - expect(response.status).toBe(302); + await expectFlashRedirect( + `/admin/listing/${listingId}/edit`, + "Listing income corrected.", + )(response); }; When( @@ -108,11 +113,10 @@ When( Then( "the organiser is told the refund failed", async function (this: TicketsWorld): Promise { - await expectRefundMessage( + expectRefundMessage( this, `/admin/attendees/${bookingId(this)}/refund`, "Refund failed", - false, ); expect(timesProviderWasAsked(this)).toBe(1); }, @@ -214,13 +218,13 @@ Then( ); expect(breakdown).toContain("Money in and out"); expect(breakdown).toContain("Gross ticket sales"); - expect(breakdown).toContain(signedCurrency(5000)); + expect(breakdown).toContain(formatSignedCurrency(5000)); expect(breakdown).toContain("Income corrections"); - expect(breakdown).toContain(signedCurrency(-1000)); + expect(breakdown).toContain(formatSignedCurrency(-1000)); expect(breakdown).toContain("Total income earned"); expect(breakdown).toContain(formatCurrency(4000)); expect(breakdown).toContain("Refunds"); - expect(breakdown).toContain(signedCurrency(-5000)); + expect(breakdown).toContain(formatSignedCurrency(-5000)); expect(breakdown).toContain("Net after refunds and costs"); }, ); diff --git a/test/specs/steps/money-shares.ts b/test/specs/steps/money-shares.ts index 70bcaae50d..e964ced461 100644 --- a/test/specs/steps/money-shares.ts +++ b/test/specs/steps/money-shares.ts @@ -14,6 +14,7 @@ import { timesProviderWasAsked, } from "#test/specs/support/money.ts"; import type { TicketsWorld } from "#test/specs/support/world.ts"; +import { expectFlashRedirect } from "#test-utils/assertions.ts"; import { createPaidTestAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; import { runStripeSuccess } from "#test-utils/money/drivers.ts"; import { @@ -112,7 +113,10 @@ Given( `/admin/listing/${listingId}/income`, { income: "100.00" }, ); - expect(response.status).toBe(302); + await expectFlashRedirect( + `/admin/listing/${listingId}/edit`, + "Listing income corrected.", + )(response); expect(await incomeOf(listingId)).toBe(10000); }, ); diff --git a/test/specs/steps/refunds.ts b/test/specs/steps/refunds.ts index 04a635f117..c35a1611a3 100644 --- a/test/specs/steps/refunds.ts +++ b/test/specs/steps/refunds.ts @@ -45,11 +45,10 @@ When( Then( "the customer is handed back 45.00 once", async function (this: TicketsWorld): Promise { - await expectRefundMessage( + expectRefundMessage( this, bookingPagePath(this, "actions"), "Refund issued", - true, ); expect(timesProviderWasAsked(this)).toBe(1); // One full refund of the whole payment, returned where it came from. @@ -88,11 +87,10 @@ Given( async function (this: TicketsWorld): Promise { await buyOnePlace(this, CONCERT, "45.00", "Logged Guest"); await askForRefund(this, true); - await expectRefundMessage( + expectRefundMessage( this, bookingPagePath(this, "actions"), "Refund issued", - true, ); // The money event is on the customer's own history for anyone to see. expect(await adminPageHtml(bookingPagePath(this, "activity"))).toContain( @@ -111,11 +109,10 @@ When( Then( "the organiser is told it was already refunded", async function (this: TicketsWorld): Promise { - await expectRefundMessage( + expectRefundMessage( this, bookingPagePath(this, "refund"), "already been refunded", - false, ); }, ); diff --git a/test/specs/support/money.ts b/test/specs/support/money.ts index e6d2030879..776d27c5a0 100644 --- a/test/specs/support/money.ts +++ b/test/specs/support/money.ts @@ -8,16 +8,15 @@ import { expect } from "@std/expect"; import type { Stub } from "@std/testing/mock"; import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; import type { Listing } from "#shared/types.ts"; +import { adminBrowser, scenarioBrowser } from "#test/specs/support/browser.ts"; import { requiredWorldValue, type TicketsWorld, } from "#test/specs/support/world.ts"; -import { expectFlashRedirect } from "#test-utils/assertions.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; import { completePaidOrder, runStripeSuccess, - submitRefund, withRefundMock, } from "#test-utils/money/drivers.ts"; import { setupStripe } from "#test-utils/settings.ts"; @@ -118,16 +117,21 @@ export const buyPlaceWithExtra = async ( export const bookingPagePath = (world: TicketsWorld, page: string): string => `/admin/attendees/${bookingId(world)}/${page}`; -/** Ask for a refund with the provider answering `succeeds`, keeping the reply - * and how many times the provider was asked. */ +/** Ask for a refund the way the organiser does: open the booking's refund page, + * type the name it asks for into its own form, and submit that form. The + * provider answers `succeeds`. Keeps how many times it was asked. */ export const askForRefund = async ( world: TicketsWorld, succeeds: boolean, ): Promise => { - const attendeeId = requiredWorldValue(world.attendeeId, "attendee id"); const who = requiredWorldValue(world.attendeeName, "attendee name"); + const browser = await adminBrowser(world); await withRefundMock(succeeds, async (mockRefund: Stub) => { - world.refundResponse = await submitRefund(attendeeId, who); + await browser.visit(bookingPagePath(world, "refund")); + // The page must offer the confirm-by-typing box; the browser carries the + // page's own token and action, so a broken form fails here. + expect(browser.currentHtml).toContain('name="confirm_identifier"'); + await browser.submitForm({ confirm_identifier: who }, "Refund Attendee"); world.refundCalls = () => mockRefund.calls.length; }); }; @@ -136,15 +140,16 @@ export const askForRefund = async ( export const timesProviderWasAsked = (world: TicketsWorld): number => requiredWorldValue(world.refundCalls, "refund calls")(); -/** The message the organiser was shown after asking for a refund. */ +/** Where the organiser landed after asking for a refund, and what they were + * told there. */ export const expectRefundMessage = ( world: TicketsWorld, path: string, message: string, - succeeded: boolean, -): Promise => - expectFlashRedirect( - path, - expect.stringContaining(message), - succeeded, - )(requiredWorldValue(world.refundResponse, "refund response")); +): void => { + // Read the page the refund left behind — asking for an admin browser here + // would navigate away from it first. + const browser = scenarioBrowser(world); + expect(browser.currentUrl).toBe(path); + expect(browser.containsText(message)).toBe(true); +}; diff --git a/test/specs/support/world.ts b/test/specs/support/world.ts index dd6b0ccc56..a242a7d79b 100644 --- a/test/specs/support/world.ts +++ b/test/specs/support/world.ts @@ -22,7 +22,6 @@ export interface TicketsWorld extends World { placeholderId?: number; questionId?: number; refundCalls?: () => number; - refundResponse?: Response; secondBody?: string; secondStatus?: number; servicingEventId?: number; diff --git a/test/test-utils/money/reads.test.ts b/test/test-utils/money/reads.test.ts index 4d5d18e4e6..56b8bdd981 100644 --- a/test/test-utils/money/reads.test.ts +++ b/test/test-utils/money/reads.test.ts @@ -6,28 +6,9 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; -import { formatCurrency } from "#shared/currency.ts"; -import { - incomeLedgerArticle, - signedCurrency, -} from "#test-utils/money/reads.ts"; +import { incomeLedgerArticle } from "#test-utils/money/reads.ts"; describe("money reads", () => { - describe("signedCurrency", () => { - test("leads a positive figure with a plus", () => { - expect(signedCurrency(500)).toBe(`+${formatCurrency(500)}`); - }); - - test("leads a negative figure with a minus sign, not a hyphen", () => { - expect(signedCurrency(-500)).toBe(`−${formatCurrency(500)}`); - expect(signedCurrency(-500)).not.toContain("-"); - }); - - test("treats zero as a positive figure", () => { - expect(signedCurrency(0)).toBe(`+${formatCurrency(0)}`); - }); - }); - describe("incomeLedgerArticle", () => { const page = [ "

£99 elsewhere on the page

", diff --git a/test/test-utils/money/reads.ts b/test/test-utils/money/reads.ts index 2e02c4a723..ee08a96bf5 100644 --- a/test/test-utils/money/reads.ts +++ b/test/test-utils/money/reads.ts @@ -130,12 +130,6 @@ export const assertRenderedModifierRevenue = async ( expect(list).toContain(formatted); }; -/** The breakdown template's signed-magnitude format (a leading +/− with a U+2212 - * minus), replicated so a test can assert the exact reconciliation figures the - * page renders. Only non-zero figures are ever rendered as a signed row. */ -export const signedCurrency = (value: number): string => - `${value < 0 ? "−" : "+"}${formatCurrency(Math.abs(value))}`; - /** Slice the `#income-ledger` reconciliation article out of a listing detail * page, so a figure is asserted WITHIN the breakdown and can't accidentally * match an unrelated figure elsewhere on the page. */ From 8436475af7178660927fb9216563519e80b5a3cc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 13:22:27 +0000 Subject: [PATCH 13/16] Correct income through its own form and read each breakdown row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The income correction now goes through the form on the listing's edit page, the way the refund already does, so a form that stopped rendering the box or pointed somewhere else fails the story. The money breakdown is now read row by row, each figure found by its own label. Before, a page-wide search let the correction row's figure satisfy the net row's check — the two happened to be the same amount. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ga3qdj1PzXyZTcjmXZRqz7 --- test/specs/steps/money-actions.ts | 67 +++++++++++++++++++------------ 1 file changed, 42 insertions(+), 25 deletions(-) diff --git a/test/specs/steps/money-actions.ts b/test/specs/steps/money-actions.ts index c6e8da8417..c5ffca490c 100644 --- a/test/specs/steps/money-actions.ts +++ b/test/specs/steps/money-actions.ts @@ -11,8 +11,9 @@ import { accountBalance, transfersByAccount, } from "#shared/accounting/queries.ts"; -import { formatCurrency, formatSignedCurrency } from "#shared/currency.ts"; +import { formatSignedCurrency } from "#shared/currency.ts"; import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; +import { adminBrowser } from "#test/specs/support/browser.ts"; import { askForRefund, bookingId, @@ -23,7 +24,6 @@ import { timesProviderWasAsked, } from "#test/specs/support/money.ts"; import type { TicketsWorld } from "#test/specs/support/world.ts"; -import { expectFlashRedirect } from "#test-utils/assertions.ts"; import { createTestAttendee } from "#test-utils/db-helpers/attendees.ts"; import { singleItem } from "#test-utils/factories.ts"; import { withStripeSuccess } from "#test-utils/money/drivers.ts"; @@ -37,7 +37,6 @@ import { sumOfAllBalances, worldBalance, } from "#test-utils/money/reads.ts"; -import { adminFormPost } from "#test-utils/session.ts"; // jscpd:ignore-end @@ -46,21 +45,33 @@ const SHOW = "Show"; const REPEAT = "Repeat"; const RECONCILED = "Reconciled"; -/** Set a listing's income to the given figure through the real correction form, - * and check the organiser is told it worked — a failed save redirects too, so a - * bare redirect would not show the difference. */ +/** Set a listing's income through the correction form on its own edit page, and + * check the organiser is told it worked — a failed save redirects too, so a bare + * redirect would not show the difference. */ const correctIncomeTo = async ( + world: TicketsWorld, listingId: number, pounds: string, ): Promise => { - const { response } = await adminFormPost( - `/admin/listing/${listingId}/income`, - { income: pounds }, + const browser = await adminBrowser(world); + await browser.visit(`/admin/listing/${listingId}/edit`); + // The page must offer the correction box; the browser posts the form's own + // action and token, so a broken form fails here. + expect(browser.currentHtml).toContain('id="income"'); + await browser.submitForm({ income: pounds }, "Save income correction"); + expect(browser.containsText("Listing income corrected.")).toBe(true); +}; + +/** The amount shown on one row of the money breakdown, found by that row's own + * label — so a figure belonging to a different row can never satisfy a check. */ +const breakdownRowAmount = (breakdown: string, label: string): string => { + const row = breakdown.match( + new RegExp( + `]*>(?:)?${label}(?:)?\\s*]*>(?:)?([^<]*)`, + ), ); - await expectFlashRedirect( - `/admin/listing/${listingId}/edit`, - "Listing income corrected.", - )(response); + if (!row) throw new Error(`the breakdown has no ${label} row`); + return row[1]!; }; When( @@ -177,8 +188,8 @@ When( "the organiser sets the Repeat income to 40.00 twice", async function (this: TicketsWorld): Promise { const listingId = listingIdFor(this, REPEAT); - await correctIncomeTo(listingId, "40.00"); - await correctIncomeTo(listingId, "40.00"); + await correctIncomeTo(this, listingId, "40.00"); + await correctIncomeTo(this, listingId, "40.00"); }, ); @@ -206,7 +217,7 @@ Given( Given( "the organiser corrected the Reconciled income to 40.00", function (this: TicketsWorld): Promise { - return correctIncomeTo(listingIdFor(this, RECONCILED), "40.00"); + return correctIncomeTo(this, listingIdFor(this, RECONCILED), "40.00"); }, ); @@ -217,15 +228,21 @@ Then( await adminPageHtml(`/admin/listing/${listingIdFor(this, RECONCILED)}`), ); expect(breakdown).toContain("Money in and out"); - expect(breakdown).toContain("Gross ticket sales"); - expect(breakdown).toContain(formatSignedCurrency(5000)); - expect(breakdown).toContain("Income corrections"); - expect(breakdown).toContain(formatSignedCurrency(-1000)); - expect(breakdown).toContain("Total income earned"); - expect(breakdown).toContain(formatCurrency(4000)); - expect(breakdown).toContain("Refunds"); - expect(breakdown).toContain(formatSignedCurrency(-5000)); - expect(breakdown).toContain("Net after refunds and costs"); + // Each figure is read from its own row: the 50.00 sale, the 10.00 taken off + // by the correction, the 40.00 that leaves as earned income, the 50.00 + // handed back, and the 10.00 the listing is down overall. + const amountOn = (label: string): string => + breakdownRowAmount(breakdown, label); + expect(amountOn("Gross ticket sales")).toBe(formatSignedCurrency(5000)); + expect(amountOn("Income corrections")).toBe(formatSignedCurrency(-1000)); + // The two subtotal rows drop a leading plus but keep a real minus. + expect(amountOn("Total income earned")).toBe( + formatSignedCurrency(4000, false), + ); + expect(amountOn("Refunds")).toBe(formatSignedCurrency(-5000)); + expect(amountOn("Net after refunds and costs")).toBe( + formatSignedCurrency(-1000, false), + ); }, ); From 5b093737a2ff757d0b5f27c5f14571dcf98d99cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 13:38:31 +0000 Subject: [PATCH 14/16] Book and correct through the rendered forms everywhere The free booking now goes through the listing's own public page in a visitor's browser, and the Festival correction uses the same shared correction helper as the other stories, so no money story posts a hand-built form any more. Money listings now keep the site's own thank-you page, so a story can read what the customer is shown instead of following a link off the site. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ga3qdj1PzXyZTcjmXZRqz7 --- test/specs/steps/money-actions.ts | 30 +++------------------- test/specs/steps/money-shares.ts | 12 ++------- test/specs/support/money.ts | 42 +++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 37 deletions(-) diff --git a/test/specs/steps/money-actions.ts b/test/specs/steps/money-actions.ts index c5ffca490c..686cc58ca0 100644 --- a/test/specs/steps/money-actions.ts +++ b/test/specs/steps/money-actions.ts @@ -13,18 +13,18 @@ import { } from "#shared/accounting/queries.ts"; import { formatSignedCurrency } from "#shared/currency.ts"; import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; -import { adminBrowser } from "#test/specs/support/browser.ts"; import { askForRefund, + bookFreePlace, bookingId, buyOnePlace, + correctIncomeTo, expectRefundMessage, listingIdFor, sellPlacesAt, timesProviderWasAsked, } from "#test/specs/support/money.ts"; import type { TicketsWorld } from "#test/specs/support/world.ts"; -import { createTestAttendee } from "#test-utils/db-helpers/attendees.ts"; import { singleItem } from "#test-utils/factories.ts"; import { withStripeSuccess } from "#test-utils/money/drivers.ts"; import { @@ -45,23 +45,6 @@ const SHOW = "Show"; const REPEAT = "Repeat"; const RECONCILED = "Reconciled"; -/** Set a listing's income through the correction form on its own edit page, and - * check the organiser is told it worked — a failed save redirects too, so a bare - * redirect would not show the difference. */ -const correctIncomeTo = async ( - world: TicketsWorld, - listingId: number, - pounds: string, -): Promise => { - const browser = await adminBrowser(world); - await browser.visit(`/admin/listing/${listingId}/edit`); - // The page must offer the correction box; the browser posts the form's own - // action and token, so a broken form fails here. - expect(browser.currentHtml).toContain('id="income"'); - await browser.submitForm({ income: pounds }, "Save income correction"); - expect(browser.containsText("Listing income corrected.")).toBe(true); -}; - /** The amount shown on one row of the money breakdown, found by that row's own * label — so a figure belonging to a different row can never satisfy a check. */ const breakdownRowAmount = (breakdown: string, label: string): string => { @@ -78,14 +61,7 @@ When( "a customer books a free Free Meetup place", async function (this: TicketsWorld): Promise { const listing = await sellPlacesAt(this, FREE_MEETUP, "0.00"); - const attendee = await createTestAttendee( - listing.id, - listing.slug, - "Free Guest", - "free@example.com", - ); - this.attendeeId = attendee.id; - this.attendeeName = "Free Guest"; + await bookFreePlace(this, listing, "Free Guest", "free@example.com"); }, ); diff --git a/test/specs/steps/money-shares.ts b/test/specs/steps/money-shares.ts index e964ced461..4db67a76af 100644 --- a/test/specs/steps/money-shares.ts +++ b/test/specs/steps/money-shares.ts @@ -8,13 +8,13 @@ import { askForRefund, bookingId, buyOnePlace, + correctIncomeTo, listingIdFor, sellPlacesAt, soleBookingOn, timesProviderWasAsked, } from "#test/specs/support/money.ts"; import type { TicketsWorld } from "#test/specs/support/world.ts"; -import { expectFlashRedirect } from "#test-utils/assertions.ts"; import { createPaidTestAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; import { runStripeSuccess } from "#test-utils/money/drivers.ts"; import { @@ -26,7 +26,6 @@ import { sumOfAllBalances, worldBalance, } from "#test-utils/money/reads.ts"; -import { adminFormPost } from "#test-utils/session.ts"; import { setupStripe } from "#test-utils/settings.ts"; // jscpd:ignore-end @@ -109,14 +108,7 @@ Given( "the organiser corrected the Festival income to 100.00", async function (this: TicketsWorld): Promise { const listingId = listingIdFor(this, FESTIVAL); - const { response } = await adminFormPost( - `/admin/listing/${listingId}/income`, - { income: "100.00" }, - ); - await expectFlashRedirect( - `/admin/listing/${listingId}/edit`, - "Listing income corrected.", - )(response); + await correctIncomeTo(this, listingId, "100.00"); expect(await incomeOf(listingId)).toBe(10000); }, ); diff --git a/test/specs/support/money.ts b/test/specs/support/money.ts index 776d27c5a0..8bcc2405b4 100644 --- a/test/specs/support/money.ts +++ b/test/specs/support/money.ts @@ -20,6 +20,7 @@ import { withRefundMock, } from "#test-utils/money/drivers.ts"; import { setupStripe } from "#test-utils/settings.ts"; +import { TestBrowser } from "#test-utils/test-browser.ts"; /** The id of a listing the story put on sale, by the name it used. */ export const listingIdFor = (world: TicketsWorld, name: string): number => @@ -42,6 +43,9 @@ export const sellPlacesAt = async ( const listing = await createTestListing({ maxAttendees: 50, name, + // Keep the site's own thank-you page, so a story can read what the customer + // is shown rather than being sent off to another site. + thankYouUrl: "", unitPrice: minorUnits(pounds), }); world.listingIds.set(name, listing.id); @@ -136,6 +140,44 @@ export const askForRefund = async ( }); }; +/** Set a listing's income through the correction form on its own edit page, and + * check the organiser is told it worked — a failed save redirects too, so a bare + * redirect would not show the difference. */ +export const correctIncomeTo = async ( + world: TicketsWorld, + listingId: number, + pounds: string, +): Promise => { + const browser = await adminBrowser(world); + await browser.visit(`/admin/listing/${listingId}/edit`); + // The page must offer the correction box; the browser posts the form's own + // action and token, so a broken form fails here. + expect(browser.currentHtml).toContain('id="income"'); + await browser.submitForm({ income: pounds }, "Save income correction"); + expect(browser.containsText("Listing income corrected.")).toBe(true); +}; + +/** A member of the public books one free place through the listing's own page. */ +export const bookFreePlace = async ( + world: TicketsWorld, + listing: Listing, + who: string, + email: string, +): Promise => { + // Their own browser, never signed in: this is what a visitor can do. + const browser = new TestBrowser(); + await browser.visit(`/ticket/${listing.slug}`); + expect(browser.pageText).toContain(listing.name); + await browser.submitForm( + { email, name: who, [`quantity_${listing.id}`]: "1" }, + "Continue", + ); + expect(browser.pageText).toContain("Thank you for your order"); + world.customerBrowser = browser; + world.attendeeId = await soleBookingOn(listing.id); + world.attendeeName = who; +}; + /** How many times the provider was asked to hand money back. */ export const timesProviderWasAsked = (world: TicketsWorld): number => requiredWorldValue(world.refundCalls, "refund calls")(); From 29990637d7bc3ddc9976e2e77dc172bed109f27a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 13:49:02 +0000 Subject: [PATCH 15/16] Give the split payment its own story, and prove the money came back The one-payment-two-listings rule never refunds anything, so it moves out of the refund story into its own. That story now also proves both listings really hold the booking, not just that the money adds up: a place could be missing while the books still balanced. The service-charge refund now checks the customer actually received the whole 55.00 back. Undoing the charge alone left the books balanced while the customer waited for their money, and the story could not tell. The refund-cash check is now one shared helper both refund stories use. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ga3qdj1PzXyZTcjmXZRqz7 --- .../one-payment-many-listings.feature | 21 +++++++++++++++++++ specs/payments/refunding-a-booking.feature | 15 +------------ test/scripts/specs/catalog.test.ts | 1 + test/specs/steps/extra-charges.ts | 14 +++++++++++++ test/specs/steps/money-shares.ts | 7 ++++++- test/specs/steps/refunds.ts | 9 ++------ test/specs/support/money.ts | 15 +++++++++++++ 7 files changed, 60 insertions(+), 22 deletions(-) create mode 100644 specs/payments/one-payment-many-listings.feature diff --git a/specs/payments/one-payment-many-listings.feature b/specs/payments/one-payment-many-listings.feature new file mode 100644 index 0000000000..bb2bc430e2 --- /dev/null +++ b/specs/payments/one-payment-many-listings.feature @@ -0,0 +1,21 @@ +@story:payments.one-payment-many-listings +@owner:payments @risk:high +@actor:organiser @actor:customer +@edition:managed @edition:self-hosted +Feature: One payment can cover places on several listings + A customer buying places on more than one listing pays once. Each listing must + earn only its own part of that payment. + + @rule:payments.one-payment-pays-each-listing-its-share + @surface:admin + Rule: One payment covering two listings pays each its own share + A customer buying places on two listings at once pays once, and each + listing earns only its own part. + + @case:payment.one-payment-two-listings + Scenario: A customer pays once for a place on each of two listings + Given Part One costs 30.00 and Part Two costs 20.00 + When a customer pays 50.00 for one place on each + Then Part One has earned 30.00 and Part Two has earned 20.00 + And both places belong to the same order + And each listing's page shows its own earnings diff --git a/specs/payments/refunding-a-booking.feature b/specs/payments/refunding-a-booking.feature index 218edb8d02..d94d03abbb 100644 --- a/specs/payments/refunding-a-booking.feature +++ b/specs/payments/refunding-a-booking.feature @@ -72,22 +72,9 @@ Feature: An organiser refunds a booking Given a customer paid a 10 percent Service charge on a 50.00 Talk place When the organiser refunds the booking Then the Service charge has earned nothing + And the customer has the whole 55.00 back And no money is left unaccounted for - @rule:payments.one-payment-pays-each-listing-its-share - @surface:admin - Rule: One payment covering two listings pays each its own share - A customer buying places on two listings at once pays once, and each - listing earns only its own part. - - @case:payment.one-payment-two-listings - Scenario: A customer pays once for a place on each of two listings - Given Part One costs 30.00 and Part Two costs 20.00 - When a customer pays 50.00 for one place on each - Then Part One has earned 30.00 and Part Two has earned 20.00 - And both places belong to the same order - And each listing's page shows its own earnings - @rule:payments.money-is-never-created-or-destroyed @surface:admin Rule: Money is never created or destroyed diff --git a/test/scripts/specs/catalog.test.ts b/test/scripts/specs/catalog.test.ts index 02264fec39..dc54a991ab 100644 --- a/test/scripts/specs/catalog.test.ts +++ b/test/scripts/specs/catalog.test.ts @@ -19,6 +19,7 @@ describe("Cucumber story catalog", () => { "payments.capacity-after-payment", "payments.free-bookings", "payments.income-figures-explained", + "payments.one-payment-many-listings", "payments.provider-choice", "payments.refunding-a-booking", "payments.repeated-money-actions", diff --git a/test/specs/steps/extra-charges.ts b/test/specs/steps/extra-charges.ts index 0118310373..1c9fbc31fd 100644 --- a/test/specs/steps/extra-charges.ts +++ b/test/specs/steps/extra-charges.ts @@ -15,6 +15,7 @@ import { settings } from "#shared/db/settings.ts"; import { bookingId, buyPlaceWithExtra, + expectMoneyHandedBack, listingIdFor, sellPlacesAt, soleBookingOn, @@ -208,6 +209,19 @@ Then( }, ); +Then( + "the customer has the whole 55.00 back", + async function (this: TicketsWorld): Promise { + // Undoing the charge on its own would leave the books balanced while the + // customer still waited for their money, so the cash itself is checked: + // the whole 55.00 goes back where it came from, once. + await expectMoneyHandedBack(this, 5500); + // Nobody is left holding anything: not the customer, not the site. + expect(await owedBy(bookingId(this))).toBe(0); + expect(await worldBalance()).toBe(0); + }, +); + Then("no money is left unaccounted for", async (): Promise => { expect(await sumOfAllBalances()).toBe(0); }); diff --git a/test/specs/steps/money-shares.ts b/test/specs/steps/money-shares.ts index 4db67a76af..1abce2ccd4 100644 --- a/test/specs/steps/money-shares.ts +++ b/test/specs/steps/money-shares.ts @@ -75,7 +75,12 @@ Then( Then( "both places belong to the same order", async function (this: TicketsWorld): Promise { - const legs = await transfersByAccount(attendeeAccount(bookingId(this))); + // Both listings must hold the very same booking — money alone could add up + // while one of the places was never given to anybody. + const booking = bookingId(this); + expect(await soleBookingOn(listingIdFor(this, "Part One"))).toBe(booking); + expect(await soleBookingOn(listingIdFor(this, "Part Two"))).toBe(booking); + const legs = await transfersByAccount(attendeeAccount(booking)); expect(legsOfKind(legs, "sale").length).toBe(2); expect(new Set(legs.map((leg) => leg.eventGroup)).size).toBe(1); }, diff --git a/test/specs/steps/refunds.ts b/test/specs/steps/refunds.ts index c35a1611a3..4513d84a11 100644 --- a/test/specs/steps/refunds.ts +++ b/test/specs/steps/refunds.ts @@ -2,12 +2,12 @@ import { Given, Then, When } from "@cucumber/cucumber"; import { expect } from "@std/expect"; -import { WORLD } from "#shared/accounting/accounts.ts"; import { askForRefund, bookingId, bookingPagePath, buyOnePlace, + expectMoneyHandedBack, expectRefundMessage, listingIdFor, timesProviderWasAsked, @@ -50,12 +50,7 @@ Then( bookingPagePath(this, "actions"), "Refund issued", ); - expect(timesProviderWasAsked(this)).toBe(1); - // One full refund of the whole payment, returned where it came from. - const handedBack = await attendeeLegsOfKind(bookingId(this), "refund_cash"); - expect(handedBack.length).toBe(1); - expect(handedBack[0]!.amount).toBe(4500); - expect(handedBack[0]!.destination).toEqual(WORLD); + await expectMoneyHandedBack(this, 4500); }, ); diff --git a/test/specs/support/money.ts b/test/specs/support/money.ts index 8bcc2405b4..f0079d91fe 100644 --- a/test/specs/support/money.ts +++ b/test/specs/support/money.ts @@ -6,6 +6,7 @@ import { expect } from "@std/expect"; import type { Stub } from "@std/testing/mock"; +import { WORLD } from "#shared/accounting/accounts.ts"; import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; import type { Listing } from "#shared/types.ts"; import { adminBrowser, scenarioBrowser } from "#test/specs/support/browser.ts"; @@ -19,6 +20,7 @@ import { runStripeSuccess, withRefundMock, } from "#test-utils/money/drivers.ts"; +import { attendeeLegsOfKind } from "#test-utils/money/reads.ts"; import { setupStripe } from "#test-utils/settings.ts"; import { TestBrowser } from "#test-utils/test-browser.ts"; @@ -182,6 +184,19 @@ export const bookFreePlace = async ( export const timesProviderWasAsked = (world: TicketsWorld): number => requiredWorldValue(world.refundCalls, "refund calls")(); +/** The customer got their money back: one refund of the whole payment, returned + * where it came from, with the provider asked exactly once. */ +export const expectMoneyHandedBack = async ( + world: TicketsWorld, + minor: number, +): Promise => { + expect(timesProviderWasAsked(world)).toBe(1); + const handedBack = await attendeeLegsOfKind(bookingId(world), "refund_cash"); + expect(handedBack.length).toBe(1); + expect(handedBack[0]!.amount).toBe(minor); + expect(handedBack[0]!.destination).toEqual(WORLD); +}; + /** Where the organiser landed after asking for a refund, and what they were * told there. */ export const expectRefundMessage = ( From 9b251983fc4505869d45bfefbec6e9b02cb996ee Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 13:54:16 +0000 Subject: [PATCH 16/16] Assert the correction leg count directly A rest-array check passed on an empty result and then failed while reading the leg, hiding which contract broke. The test now says there is exactly one leg before it reads it. --- test/shared/accounting/ledger-tx.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/shared/accounting/ledger-tx.test.ts b/test/shared/accounting/ledger-tx.test.ts index e98b017ca6..34aae3d7ab 100644 --- a/test/shared/accounting/ledger-tx.test.ts +++ b/test/shared/accounting/ledger-tx.test.ts @@ -79,11 +79,12 @@ describe("db > accounting > ledger-tx", () => { ): Promise => { using _time = new FakeTime(new Date("2026-06-21T00:00:00.000Z")); await inOwnTx(correct)(4, target); - const [leg, ...rest] = await allTransfers(); - expect(rest).toEqual([]); + 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)); + 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 () => {