Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions specs/payments/free-bookings.feature
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions specs/payments/income-figures-explained.feature
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions specs/payments/one-payment-many-listings.feature
Original file line number Diff line number Diff line change
@@ -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
101 changes: 101 additions & 0 deletions specs/payments/refunding-a-booking.feature
Original file line number Diff line number Diff line change
@@ -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
Comment thread
stefan-burke marked this conversation as resolved.
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
25 changes: 25 additions & 0 deletions specs/payments/repeated-money-actions.feature
Original file line number Diff line number Diff line change
@@ -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
12 changes: 0 additions & 12 deletions test/e2e/accounting/_shared.ts

This file was deleted.

66 changes: 5 additions & 61 deletions test/e2e/accounting/bulk-merges-and-pwyw.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -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
Expand Down
Loading