Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ce199cc
Protect committed bookings from refunds
stefan-burke Jul 11, 2026
6848c5c
Keep orphaned payment replays handled
stefan-burke Jul 11, 2026
ee33cd8
Recover tickets after committed booking errors
stefan-burke Jul 11, 2026
2fd68ed
Complete recovered paid bookings
stefan-burke Jul 12, 2026
bf49d48
Strengthen payment recovery tests
stefan-burke Jul 12, 2026
d4640c3
Cover pruned balance replay
stefan-burke Jul 12, 2026
6ccf91c
Keep payment recovery terminal
stefan-burke Jul 12, 2026
bd5c2f5
Finalize with attendee ticket token
stefan-burke Jul 12, 2026
1b4edd4
Require scoped payment ticket token
stefan-burke Jul 12, 2026
de20ef4
Protect committed bookings from refunds
stefan-burke Jul 11, 2026
047c11c
Keep orphaned payment replays handled
stefan-burke Jul 11, 2026
2ec493d
Recover tickets after committed booking errors
stefan-burke Jul 11, 2026
5345be9
Complete recovered paid bookings
stefan-burke Jul 12, 2026
40ca897
Strengthen payment recovery tests
stefan-burke Jul 12, 2026
0d5e41b
Cover pruned balance replay
stefan-burke Jul 12, 2026
5a70440
Keep payment recovery terminal
stefan-burke Jul 12, 2026
95dece8
Finalize with attendee ticket token
stefan-burke Jul 12, 2026
d10db72
Require scoped payment ticket token
stefan-burke Jul 12, 2026
af47a14
Merge remote-tracking branch 'origin/fix/committed-booking-refunds' i…
stefan-burke Jul 12, 2026
3eaebc6
Restore recovered contact history
stefan-burke Jul 12, 2026
93ca61a
Recover after ticket redirect race
stefan-burke Jul 12, 2026
df56d4b
Merge branch 'main' into fix/committed-booking-refunds
stefan-burke Jul 12, 2026
10dc882
Merge remote-tracking branch 'origin/fix/committed-booking-refunds' i…
stefan-burke Jul 12, 2026
1aac4e0
Update recovered ticket copy assertion
stefan-burke Jul 12, 2026
a1d8563
Make payment recovery writes idempotent
stefan-burke Jul 12, 2026
653191b
Merge remote-tracking branch 'origin/main' into fix/committed-booking…
stefan-burke Jul 12, 2026
a3284cc
Use shared listing question links
stefan-burke Jul 12, 2026
b02e9f1
Stage paid bookings before checkout
stefan-burke Jul 12, 2026
4236e09
Harden checkout cleanup and servicing edits
stefan-burke Jul 12, 2026
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
24 changes: 6 additions & 18 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,26 +338,14 @@ existing ticket?) and likely adding payment-intent uniqueness — out of scope f
a test-only file split. Starting point: `src/features/api/payment-processing.ts`
(the `/payment/success` finalize path) and `#shared/db/processed-payments.ts`.

## Payment-processing review follow-ups (from PR #1692)
## Payment-processing review follow-up (from PR #1692)

Both items describe behaviour that predates the payment-processing split (the
code was moved verbatim from the old `payment-processing.ts` monolith). They are
This item describes behaviour that predates the payment-processing split (the
code was moved verbatim from the old `payment-processing.ts` monolith). It is
recorded here because the split PR was a pure reorganisation — changing this
behaviour there would be out of scope — and CodeRabbit flagged them as worth a
look.

- **Refund after a committed booking** (`src/features/api/payment-processing/index.ts`,
the `try { honoured = await createAttendeeForSession(...) } catch` in
`processReservedSession`). `createAttendeeForSession` commits the attendee +
bookings atomically, then runs `ensureAllBookings` (a post-commit read). If
that post-write step *threw*, the `catch` would route to `storeRefundedBooking`
— refunding a booking that actually persisted. Today `ensureAllBookings`
returns a structured `{ ok: false }` rather than throwing on the capacity path,
so the window is theoretical, but it isn't guarded structurally. Fix direction:
narrow the `try` to the pre-commit call only, or guarantee the post-commit
cleanup path is non-throwing, so a persisted booking can never be refunded.
Add a regression test that makes the post-commit step throw and asserts no
refund is issued.
behaviour there would have been out of scope — and CodeRabbit flagged it as
worth a look.

- **Per-item DB reads not batched** (`src/features/api/payment-processing/items.ts`
`validateAllItems`, and `package-pricing.ts` `loadPackagePricingByGroup`).
`validateAllItems` calls `getListingWithCount` once per item in a loop, and
Expand Down
9 changes: 4 additions & 5 deletions scripts/mutation/equivalent-mutants.txt
Original file line number Diff line number Diff line change
Expand Up @@ -813,13 +813,12 @@ src/features/admin/attendees-merge.ts:188:40 && → || # keptIsPinned = Boole
src/features/admin/attendees-merge.ts:344:54 target → "" # parsePiiDecisions fallback string: every consumer of decision.pii (pickPiiField, the kept/address ternary on :186, mergedPiiName on :319, applyPiiDecisions on attendee-merge.ts:501) only tests `=== "source"`, so any non-"source" value (including "" and "target") is treated identically as "keep target"
src/features/admin/attendees-merge.ts:374:15 skip_source → "" # toBookingChoice comparison `raw === "skip_source"` → `raw === ""`: parse "skip_source" as the "keep_target" fallback. Unobservable — both fall to applyBookingDecisions' same `else` branch (no DB write, bookingsSkipped++) and discardedSaleAmount returns sourceSaleAmount for both, so the merge outcome is identical for every input
src/features/admin/attendees-merge.ts:408:10 return undefined → return undefined # toMoneyChoice's fallback is already `return undefined`; the mutation is a no-op (it produces identical code), so no test can ever observe a difference
# contact-tokens.ts — `?? → ||` and `return null → return undefined` sites whose
# contact-tokens.ts — `?? → ||` sites whose
# operand can never be a falsy-but-non-null value, or whose result is only ever
# consumed by a nullish check. Each is provably equivalent from the types.
src/shared/db/contact-tokens.ts:92:30 - → + # splitTokenBlob's `separatorAt === -1`: every app-written line has a tab at the marker-length position (64), and a malformed separator-less line's first-chars marker can never equal a real BlindIndex (an hmac hex), and its ciphertext never decrypts, so taking the else-branch for a tab-less line (marker = raw.slice(0, -1)) is unobservable through ensureBookingToken/removeBookingToken/getRecentBookingTokens
src/shared/db/contact-tokens.ts:177:35 ?? → || # loadTokenBlob's `row?.attendee_tokens_blob ?? null`: attendee_tokens_blob is string|undefined; the only falsy-non-null is "" and tokenLinesFrom("") === tokenLinesFrom(null) === [], so ?? and || agree
src/shared/db/contact-tokens.ts:218:22 return null → return undefined # removeBookingToken's no-match return; removedSource is only ever consumed by `removedSource ?? sync.source`, and null and undefined agree under ??
src/shared/db/contact-tokens.ts:289:18 ?? → || # `removedSource ?? sync.source`: removedSource is BookingSource|null, and both BookingSource values ("admin","public") are non-empty truthy strings, so it is never falsy-but-non-null; ?? and || agree
src/shared/db/contact-tokens.ts:97:30 - → + # splitTokenBlob's `separatorAt === -1`: every app-written line has a tab at the marker-length position (64), and a malformed separator-less line's first-chars marker can never equal a real BlindIndex (an hmac hex), and its ciphertext never decrypts, so taking the else-branch for a tab-less line (marker = raw.slice(0, -1)) is unobservable through ensureBookingToken/removeBookingToken/getRecentBookingTokens
src/shared/db/contact-tokens.ts:174:35 ?? → || # loadTokenBlob's `row?.attendee_tokens_blob ?? null`: attendee_tokens_blob is string|undefined; the only falsy-non-null is "" and tokenLinesFrom("") === tokenLinesFrom(null) === [], so ?? and || agree
src/shared/db/contact-tokens.ts:286:18 ?? → || # `removedSource ?? sync.source`: removedSource is BookingSource|null, and both BookingSource values ("admin","public") are non-empty truthy strings, so it is never falsy-but-non-null; ?? and || agree

# attendees/tokens.ts — `?? → ||` sites where the operand is a Map#get() whose
# value is always a truthy object or array (never a falsy-but-non-null value),
Expand Down
8 changes: 2 additions & 6 deletions src/docs/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,8 @@ export {
type UpdateAttendeeAtomicResult,
} from "#shared/db/attendees/atomic-update.ts";
export * from "#shared/db/attendees/capacity.ts";
export {
type BookingBatchPlan,
buildAttendeeInsert,
ensureAllBookings,
reverseOrderActivity,
} from "#shared/db/attendees/create.ts";
export { buildAttendeeInsert } from "#shared/db/attendees/create.ts";
export type { BookingBatchPlan } from "#shared/db/attendees/create-batch.ts";
export * from "#shared/db/attendees/delete.ts";
export * from "#shared/db/attendees/pii.ts";
export * from "#shared/db/attendees/queries.ts";
Expand Down
20 changes: 4 additions & 16 deletions src/features/admin/attendee-form-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,11 @@ import { getSearchParam } from "#routes/url.ts";
import { manualAddLedgerPoster } from "#shared/checkout-complete.ts";
import { logActivity } from "#shared/db/activityLog.ts";
import { attendeeStatuses } from "#shared/db/attendee-statuses.ts";
import type {
CreateAttendeeResult,
ListingAttendeeRow,
} from "#shared/db/attendee-types.ts";
import type { ListingAttendeeRow } from "#shared/db/attendee-types.ts";
import {
applyAttendeeAtomicEdit,
createAttendeeAtomic,
} from "#shared/db/attendees/api.ts";
import { ensureAllBookings } from "#shared/db/attendees/create.ts";
import { buildPiiBlob, encryptPiiBlob } from "#shared/db/attendees/pii.ts";
import { hasPaidLine } from "#shared/db/attendees/queries.ts";
import { updateAttendeeStatus } from "#shared/db/attendees/update.ts";
Expand Down Expand Up @@ -342,7 +338,7 @@ const applyLogisticsPlan = (
? setLogisticsAssignments(attendeeId, plan.split, plan.perListing)
: Promise.resolve();

/** Run the atomic create flow. All-or-nothing via `ensureAllBookings`. */
/** Run the all-or-nothing atomic create flow. */
const applyCreate = async (
parsed: ParsedAttendeeForm,
logisticsPlan: LogisticsPlan,
Expand All @@ -366,18 +362,10 @@ const applyCreate = async (
},
manualAddLedgerPoster(toLedgerOrder(parsed)),
);
const check = await ensureAllBookings(
createResult,
input.bookings.length,
"admin",
);
if (!check.ok) {
if (!createResult.success) {
return { ok: false, saveError: t("attendee_form.error_capacity") };
}
const { attendees } = createResult as Extract<
CreateAttendeeResult,
{ success: true }
>;
const { attendees } = createResult;
const firstListingId = input.bookings[0]!.listingId;
const newId = attendees[0]!.id;
await applyLogisticsPlan(newId, logisticsPlan);
Expand Down
3 changes: 2 additions & 1 deletion src/features/api/folded-booking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import type { BookingTree } from "#shared/booking/tree.ts";
import { owedOrderForLedger } from "#shared/checkout-ledger.ts";
import { priceCheckout } from "#shared/checkout-pricing.ts";
import { isPaymentsEnabled } from "#shared/config.ts";
import { createStagedCheckout } from "#shared/db/checkout-stages.ts";
import type { FormParams } from "#shared/form-data.ts";
import { mergeListingFields } from "#shared/listing-fields.ts";
import {
Expand Down Expand Up @@ -247,7 +248,7 @@ export const completeFoldedBooking = async (
if (!available) return soldOutResponse();
const provider = (await getActivePaymentProvider())!;
const baseUrl = getBaseUrl(request);
const result = await provider.createCheckoutSession(intent, baseUrl);
const result = await createStagedCheckout(provider, intent, baseUrl);
if (!result) return checkoutFailedResponse();
return "error" in result
? checkoutFailedResponse(result.error)
Expand Down
2 changes: 2 additions & 0 deletions src/features/api/payment-processing/classify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
SignedVerdict,
} from "#routes/api/webhook-types.ts";
import { paymentErrorResponse } from "#routes/payment-response.ts";
import { discardPendingCheckoutSessions } from "#shared/db/checkout-stages.ts";
import { ErrorCode, logError } from "#shared/logger.ts";
import { verifyPrice } from "#shared/payment-signature.ts";
import {
Expand Down Expand Up @@ -109,6 +110,7 @@ export const validatePaidSession = async (
// URL for every outcome, so a card decline lands here. Show the friendly
// cancel/try-again page, not a "contact support" error.
if (session.paymentStatus === "failed") {
await discardPendingCheckoutSessions([sessionId]);
return {
ok: false,
response: await cancelPageResponse(session, logRedirectError),
Expand Down
93 changes: 93 additions & 0 deletions src/features/api/payment-processing/committed-entries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import {
type CreatedEntry,
pairEntriesByListing,
} from "#routes/api/payment-processing/create.ts";
import type { ValidatedItem } from "#routes/api/payment-processing/package-pricing.ts";
import type {
BookingIntent,
ValidatedSession,
} from "#routes/api/webhook-types.ts";
import type { BlindIndex } from "#shared/crypto/sealed.ts";
import { contactFields } from "#shared/db/attendees/pii.ts";
import {
pricePaidFromLedger,
remainingBalanceFromLedger,
} from "#shared/db/attendees/queries.ts";
import { queryBatchPrimary, resultRows } from "#shared/db/client.ts";

type CommittedBookingRow = {
created: string;
date: string | null;
end_date: string | null;
kind: string;
listing_id: number;
package_group_id: number;
price_paid: number;
quantity: number;
remaining_balance: number;
status_id: number | null;
ticket_token_index: BlindIndex;
};

/** Build completion entries from committed booking rows and the signed contact
* intent. This is shared by staged activation and lost-result recovery. */
export const committedEntries = async (
attendeeId: number,
ticketToken: string,
session: ValidatedSession["session"],
intent: BookingIntent,
validatedItems: ValidatedItem[],
): Promise<CreatedEntry[]> => {
const [result] = await queryBatchPrimary([
{
args: [attendeeId],
sql: `SELECT attendee.created,
SUBSTR(listingAttendee.start_at, 1, 10) AS date,
SUBSTR(listingAttendee.end_at, 1, 10) AS end_date,
attendee.kind,
listingAttendee.listing_id,
listingAttendee.package_group_id,
${pricePaidFromLedger(
"listingAttendee.attendee_id",
"listingAttendee.listing_id",
"listingAttendee.ledger_event_group",
"listingAttendee.id",
)},
listingAttendee.quantity,
${remainingBalanceFromLedger("attendee.id")},
attendee.status_id,
attendee.ticket_token_index
FROM attendees AS attendee
JOIN listing_attendees AS listingAttendee
ON listingAttendee.attendee_id = attendee.id
WHERE attendee.id = ?
ORDER BY listingAttendee.id`,
},
]);
const rows = resultRows<CommittedBookingRow>(result!);
const attendees: CreatedEntry["attendee"][] = rows.map((row) => ({
...contactFields(intent),
attachment_downloads: 0,
checked_in: false,
created: row.created,
date: row.date,
end_date: row.end_date,
id: attendeeId,
kind: row.kind,
lat: "",
listing_id: row.listing_id,
lng: "",
package_group_id: row.package_group_id,
payment_id: session.paymentReference,
pii_blob: "",
price_paid: String(row.price_paid),
quantity: row.quantity,
refunded: false,
remaining_balance: row.remaining_balance,
split_logistics_agents: false,
status_id: row.status_id,
ticket_token: ticketToken,
ticket_token_index: row.ticket_token_index,
}));
return pairEntriesByListing(attendees, validatedItems);
};
44 changes: 44 additions & 0 deletions src/features/api/payment-processing/completion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {
type CreatedEntry,
logPromoCodeModifiers,
saveSessionAnswers,
sessionSuccess,
} from "#routes/api/payment-processing/create.ts";
import type {
BookingIntent,
PaymentResult,
} from "#routes/api/webhook-types.ts";
import type { ModifierApplication } from "#shared/checkout-pricing.ts";
import type { ModifierSpec } from "#shared/payments.ts";
import { logAndNotifyRegistration } from "#shared/webhook.ts";

/** Finish the work that follows an atomically committed paid booking. Shared by
* the normal create result and recovery after the database committed but the
* client lost that result. */
export const completePaidBooking = async (
createdEntries: CreatedEntry[],
intent: BookingIntent,
codeSpecs: ModifierSpec[],
modifierApplications: ModifierApplication[],
ticketTokens: string[],
): Promise<PaymentResult> => {
await saveSessionAnswers(createdEntries, intent);
const firstEntry = createdEntries[0]!;

if (codeSpecs.length > 0) {
await logPromoCodeModifiers(
codeSpecs,
modifierApplications,
firstEntry.listing,
firstEntry.attendee.id,
);
}

await logAndNotifyRegistration(createdEntries, intent.siteTokenIndex);

return sessionSuccess(
firstEntry.attendee.id,
firstEntry.listing.id,
ticketTokens,
);
};
Loading