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/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 new file mode 100644 index 0000000000..d94d03abbb --- /dev/null +++ b/specs/payments/refunding-a-booking.feature @@ -0,0 +1,101 @@ +@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 the customer has the whole 55.00 back + And no money is left unaccounted for + + @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 + + @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/_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..d54da303a5 100644 --- a/test/e2e/accounting/bulk-merges-and-pwyw.test.ts +++ b/test/e2e/accounting/bulk-merges-and-pwyw.test.ts @@ -10,29 +10,27 @@ 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, mergePost, mergePreview, moneyFieldFor, runStripeSuccess, - 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). */ @@ -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/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/scripts/specs/catalog.test.ts b/test/scripts/specs/catalog.test.ts index 202c8101e1..dc54a991ab 100644 --- a/test/scripts/specs/catalog.test.ts +++ b/test/scripts/specs/catalog.test.ts @@ -17,7 +17,12 @@ describe("Cucumber story catalog", () => { "bookings.book-through-the-site", "bookings.volunteer-sign-up", "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", "servicing.hold-and-cost", ]); }); diff --git a/test/shared/accounting/adjustments.test.ts b/test/shared/accounting/adjustments.test.ts index 31ec0812db..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,27 +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 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 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 () => { - 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); + // 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 () => { diff --git a/test/shared/accounting/ledger-tx.test.ts b/test/shared/accounting/ledger-tx.test.ts index 4d90e9150f..34aae3d7ab 100644 --- a/test/shared/accounting/ledger-tx.test.ts +++ b/test/shared/accounting/ledger-tx.test.ts @@ -12,11 +12,14 @@ 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 { 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, @@ -64,6 +67,72 @@ 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 legs = await allTransfers(); + expect(legs).toHaveLength(1); + const leg = legs[0]!; + const parts = [kind, 4, delta, nowIso()]; + expect(leg.reference).toBe(await legReference(parts)); + expect(leg.eventGroup).toBe(await eventGroup(parts)); + }; + + test("an income correction is filed under its own kind", async () => { + await expectFiledUnderKind( + ledgerTx.correct.income, + "income-adjust", + 500, + 500, + ); + }); + + test("a modifier-revenue correction is filed under its own kind", async () => { + await expectFiledUnderKind( + ledgerTx.correct.modifierRevenue, + "modifier-revenue-adjust", + 500, + 500, + ); + }); + + test("an owed correction is filed under its own kind", async () => { + // Crediting the attendee lowers what they owe, so the change is negative. + await expectFiledUnderKind( + ledgerTx.correct.owed, + "balance-adjust", + 500, + -500, + ); + }); + + test("two kinds of correction in the same millisecond both post", async () => { + // Freeze the clock so both posts share one millisecond, and use the same id + // and the same size of change, so the ONLY thing keeping the two apart is + // the kind of correction each one is. Without that, the second would hash + // identically to the first and be dropped as a replay — a modifier's + // revenue correction would silently vanish behind a listing's. + using _time = new FakeTime(new Date("2026-06-21T00:00:00.000Z")); + await inOwnTx(ledgerTx.correct.income)(4, 500); + await inOwnTx(ledgerTx.correct.modifierRevenue)(4, 500); + + const legs = await allTransfers(); + expect(legs.length).toBe(2); + expect(new Set(legs.map((leg) => leg.eventGroup)).size).toBe(2); + expect(new Set(legs.map((leg) => leg.reference)).size).toBe(2); + // And each figure really moved onto its own target. + expect(await readIncome(4)).toBe(500); + }); + test("correct.income lowers income onto a lower target", async () => { await postListingSale({ attendeeId: 1, gross: 5000, listingId: 1 }); // Steering down to 2000 posts a negative delta (a write-off debit) — the 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 () => { 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)", () => { diff --git a/test/specs/steps/extra-charges.ts b/test/specs/steps/extra-charges.ts new file mode 100644 index 0000000000..1c9fbc31fd --- /dev/null +++ b/test/specs/steps/extra-charges.ts @@ -0,0 +1,227 @@ +// 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, + expectMoneyHandedBack, + 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( + "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-actions.ts b/test/specs/steps/money-actions.ts new file mode 100644 index 0000000000..686cc58ca0 --- /dev/null +++ b/test/specs/steps/money-actions.ts @@ -0,0 +1,233 @@ +// 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 { formatSignedCurrency } from "#shared/currency.ts"; +import { getAttendeesRaw } from "#shared/db/attendees/queries.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 { singleItem } from "#test-utils/factories.ts"; +import { withStripeSuccess } from "#test-utils/money/drivers.ts"; +import { + adminPageHtml, + incomeLedgerArticle, + incomeOf, + kindsOf, + legsOfKind, + owedBy, + sumOfAllBalances, + worldBalance, +} from "#test-utils/money/reads.ts"; + +// jscpd:ignore-end + +const FREE_MEETUP = "Free Meetup"; +const SHOW = "Show"; +const REPEAT = "Repeat"; +const RECONCILED = "Reconciled"; + +/** 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*]*>(?:)?([^<]*)`, + ), + ); + if (!row) throw new Error(`the breakdown has no ${label} row`); + return row[1]!; +}; + +When( + "a customer books a free Free Meetup place", + async function (this: TicketsWorld): Promise { + const listing = await sellPlacesAt(this, FREE_MEETUP, "0.00"); + await bookFreePlace(this, listing, "Free Guest", "free@example.com"); + }, +); + +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 { + expectRefundMessage( + this, + `/admin/attendees/${bookingId(this)}/refund`, + "Refund failed", + ); + 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(this, listingId, "40.00"); + await correctIncomeTo(this, 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(this, 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"); + // 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), + ); + }, +); + +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/money-shares.ts b/test/specs/steps/money-shares.ts new file mode 100644 index 0000000000..1abce2ccd4 --- /dev/null +++ b/test/specs/steps/money-shares.ts @@ -0,0 +1,140 @@ +// 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, + correctIncomeTo, + 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 { 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 { + // 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); + }, +); + +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); + await correctIncomeTo(this, listingId, "100.00"); + 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 new file mode 100644 index 0000000000..4513d84a11 --- /dev/null +++ b/test/specs/steps/refunds.ts @@ -0,0 +1,131 @@ +// jscpd:ignore-start + +import { Given, Then, When } from "@cucumber/cucumber"; +import { expect } from "@std/expect"; +import { + askForRefund, + bookingId, + bookingPagePath, + buyOnePlace, + expectMoneyHandedBack, + expectRefundMessage, + listingIdFor, + timesProviderWasAsked, +} from "#test/specs/support/money.ts"; +import type { TicketsWorld } from "#test/specs/support/world.ts"; +import { + adminPageHtml, + assertEditPageIncome, + assertStatementBalance, + attendeeLegsOfKind, + incomeOf, + owedBy, + sumOfAllBalances, +} from "#test-utils/money/reads.ts"; + +// jscpd:ignore-end + +const CONCERT = "Concert"; + +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 { + expectRefundMessage( + this, + bookingPagePath(this, "actions"), + "Refund issued", + ); + await expectMoneyHandedBack(this, 4500); + }, +); + +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(bookingPagePath(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); + expectRefundMessage( + this, + bookingPagePath(this, "actions"), + "Refund issued", + ); + // The money event is on the customer's own history for anyone to see. + expect(await adminPageHtml(bookingPagePath(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 { + expectRefundMessage( + this, + bookingPagePath(this, "refund"), + "already been refunded", + ); + }, +); + +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); + }, +); diff --git a/test/specs/support/money.ts b/test/specs/support/money.ts new file mode 100644 index 0000000000..f0079d91fe --- /dev/null +++ b/test/specs/support/money.ts @@ -0,0 +1,212 @@ +/** + * 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 { 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"; +import { + requiredWorldValue, + type TicketsWorld, +} from "#test/specs/support/world.ts"; +import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { + completePaidOrder, + 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"; + +/** 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, + // 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); + 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; +}; + +/** 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 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 who = requiredWorldValue(world.attendeeName, "attendee name"); + const browser = await adminBrowser(world); + await withRefundMock(succeeds, async (mockRefund: Stub) => { + 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; + }); +}; + +/** 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")(); + +/** 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 = ( + world: TicketsWorld, + path: string, + message: string, +): 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 bb755dab9d..a242a7d79b 100644 --- a/test/specs/support/world.ts +++ b/test/specs/support/world.ts @@ -18,6 +18,7 @@ export interface TicketsWorld extends World { holdListingId?: number; listingId?: number; listingIds: Map; + modifierId?: number; placeholderId?: number; questionId?: number; refundCalls?: () => number; 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); + }); +}); diff --git a/test/e2e/accounting/drivers.ts b/test/test-utils/money/drivers.ts similarity index 95% rename from test/e2e/accounting/drivers.ts rename to test/test-utils/money/drivers.ts index 98415d7ec4..018653b97f 100644 --- a/test/e2e/accounting/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 diff --git a/test/test-utils/money/reads.test.ts b/test/test-utils/money/reads.test.ts new file mode 100644 index 0000000000..56b8bdd981 --- /dev/null +++ b/test/test-utils/money/reads.test.ts @@ -0,0 +1,30 @@ +/** + * 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 { incomeLedgerArticle } from "#test-utils/money/reads.ts"; + +describe("money reads", () => { + 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(); + }); + }); +}); diff --git a/test/e2e/accounting/ledger-helpers.ts b/test/test-utils/money/reads.ts similarity index 92% rename from test/e2e/accounting/ledger-helpers.ts rename to test/test-utils/money/reads.ts index 200cb8c03f..ee08a96bf5 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"; @@ -126,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. */ @@ -149,3 +147,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);