Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
529d114
Add shared checkout expiration contract
stefan-burke Jul 17, 2026
1793c63
Stage paid checkouts before redirect
stefan-burke Jul 17, 2026
a578e91
Activate staged attendees atomically
stefan-burke Jul 17, 2026
dddd8f7
Complete staged payment processing
stefan-burke Jul 17, 2026
973408c
Clean up abandoned checkout stages
stefan-burke Jul 17, 2026
1073cc0
Confirm provider payment outcomes
stefan-burke Jul 17, 2026
2750b67
Harden staged checkout boundaries
stefan-burke Jul 17, 2026
118facf
Resolve staged checkout review feedback
stefan-burke Jul 17, 2026
2449879
Handle checkout races and pending refunds
stefan-burke Jul 17, 2026
e34c3e0
Preserve refund retries and payment state
stefan-burke Jul 18, 2026
42cccd3
Reject malformed Square payment webhooks
stefan-burke Jul 18, 2026
e5c05a5
Finish staged checkout review cleanup
stefan-burke Jul 18, 2026
dafc381
Merge remote-tracking branch 'origin/main' into split/staged-checkout…
stefan-burke Jul 18, 2026
752eea2
Harden staged checkout mutation coverage
stefan-burke Jul 18, 2026
d6fc3ab
Record staged checkout follow-up findings
stefan-burke Jul 18, 2026
0ede058
Defer scheduler key rotation
stefan-burke Jul 19, 2026
4ff99f9
Merge remote-tracking branch 'origin/main' into split/staged-checkout…
stefan-burke Jul 20, 2026
7f67e7c
Merge remote-tracking branch 'origin/main' into split/staged-checkout…
stefan-burke Jul 22, 2026
78306b5
Merge remote-tracking branch 'origin/main' into split/staged-checkout…
stefan-burke Jul 23, 2026
f7c02f9
Recover paid checkout stages on schedule
stefan-burke Jul 23, 2026
78473c7
Prevent duplicate provider refunds
stefan-burke Jul 23, 2026
24dcf46
Keep live checkout recovery data
stefan-burke Jul 23, 2026
f94e69c
Keep payment callback outcomes stable
stefan-burke Jul 23, 2026
627bef9
Close remaining payment recovery gaps
stefan-burke Jul 23, 2026
5e267ac
Merge remote-tracking branch 'origin/main' into split/staged-checkout…
stefan-burke Jul 23, 2026
3e27067
Merge remote-tracking branch 'origin/main' into split/staged-checkout…
stefan-burke Jul 23, 2026
6730daa
Merge origin/main into staged checkout runtime
stefan-burke Jul 23, 2026
7a742ff
Merge latest main into staged checkout runtime
stefan-burke Jul 23, 2026
0e8fb72
Merge remote-tracking branch 'origin/main' into split/staged-checkout…
stefan-burke Jul 24, 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
3 changes: 3 additions & 0 deletions scripts/mutation/equivalent-mutants.txt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
# `x ?? F` vs `x || F` differ only when x is falsy-but-not-null; these are all
# cases where x can't be such a value, or F equals the only one it can be.
src/shared/checkout-pricing.ts:85:35 ?? → || # Map<number,number> sum: 0 ?? 0 === 0 || 0
src/shared/booking-lines.ts:145:40 ?? → || # packageGroupId is undefined, 0, or a positive id; both return 0 for the only falsy value
src/shared/sumup.ts:262:18 ?? → || # withClient returns true|null here; the false fallback is identical for null
src/shared/sumup.ts:301:37 ?? → || # keyMode is "live", "test", or null; present values are always truthy
src/shared/checkout-pricing.ts:334:41 ?? → || # intent.modifiers: an array is always truthy
src/shared/logistics-filter.ts:22:35 ?? → || # raw: string|null, only falsy string "" === fallback ""
src/shared/config.ts:150:39 ?? → || # getEnv(): string|undefined, only falsy string "" === fallback ""
Expand Down
14 changes: 9 additions & 5 deletions src/features/api/folded-booking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
type CheckoutItem,
getActivePaymentProvider,
} from "#shared/payments.ts";
import { createAndHandlePaidCheckout } from "#shared/staged-checkout.ts";
import {
type ContactInfo,
isPaidListing,
Expand Down Expand Up @@ -278,11 +279,14 @@ const completeFoldedBooking = async (
if (!available) return soldOutResponse();
const provider = (await getActivePaymentProvider())!;
const baseUrl = getBaseUrl(request);
const result = await provider.createCheckoutSession(intent, baseUrl);
if (!result) return checkoutFailedResponse();
return "error" in result
? checkoutFailedResponse(result.error)
: checkoutResponse(result.checkoutUrl);
return createAndHandlePaidCheckout(
{ baseUrl, intent, provider },
{
checkout: checkoutResponse,
failed: checkoutFailedResponse,
soldOut: soldOutResponse,
},
);
}
// Free, or provider-less paid (owes the full value). An owed order must record
// its gross sale legs in the ledger at creation — the outstanding balance
Expand Down
23 changes: 21 additions & 2 deletions src/features/api/payment-processing/cancel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,15 @@ import { htmlResponse } from "#routes/response.ts";
import { lineGroupIds } from "#shared/booking/signed-metadata.ts";
import { groups } from "#shared/db/groups.ts";
import { getListingWithCount } from "#shared/db/listings/records.ts";
import type { ValidatedPaymentSession } from "#shared/payments.ts";
import type {
PaymentProvider,
ValidatedPaymentSession,
} from "#shared/payments.ts";
import { tryCloseAndPurgeCheckoutStageBySession } from "#shared/staged-checkout.ts";
import { paymentCancelPage } from "#templates/payment.tsx";

type CancelLog = (detail: string) => void;

/** The retry link for a cancelled checkout: the package group's page when the
* order booked one package, else the (first) listing's own page. Returns null
* (no retry link) whenever the target page would no longer serve, so a "Try
Expand Down Expand Up @@ -57,7 +63,7 @@ const retryHrefFor = async (
/** Render the payment-cancelled page for a session's first listing. */
export const cancelPageResponse = async (
session: ValidatedPaymentSession,
logFailure: (detail: string) => void,
logFailure: CancelLog,
): Promise<Response> => {
const intent = extractIntent(session);
const listingId = intent?.items[0]?.e ?? 0;
Expand All @@ -76,3 +82,16 @@ export const cancelPageResponse = async (
const retryHref = await retryHrefFor(intent!, listing);
return htmlResponse(paymentCancelPage(listing, retryHref));
};

/** Close and clear a staged checkout. A paid close stays distinct so the route
* can process the payment. Provider errors keep the stage for scheduled retry. */
export const closeStageForCancel = async (
session: ValidatedPaymentSession,
provider: PaymentProvider,
logFailure: CancelLog,
): Promise<"error" | "kept" | "missing" | "paid" | "purged"> =>
tryCloseAndPurgeCheckoutStageBySession(session.id, provider, (error) =>
logFailure(
`Could not close checkout stage ${session.id}: ${String(error)}`,
),
);
52 changes: 46 additions & 6 deletions src/features/api/payment-processing/classify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
* ours before anything downstream processes or refunds it.
*/

import { cancelPageResponse } from "#routes/api/payment-processing/cancel.ts";
import {
cancelPageResponse,
closeStageForCancel,
} from "#routes/api/payment-processing/cancel.ts";
import { extractIntent } from "#routes/api/payment-processing/metadata.ts";
import type {
BookingIntent,
Expand All @@ -16,6 +19,7 @@ import { ErrorCode, logError } from "#shared/logger.ts";
import { verifyPrice } from "#shared/payment-signature.ts";
import {
getActivePaymentProvider,
type PaymentProvider,
type ValidatedPaymentSession,
} from "#shared/payments.ts";

Expand Down Expand Up @@ -135,10 +139,18 @@ 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") {
return {
ok: false,
response: await cancelPageResponse(session, logRedirectError),
};
const closeResult = await closeStageForCancel(
session,
provider,
logRedirectError,
);
if (closeResult !== "paid") {
return {
ok: false,
response: await cancelPageResponse(session, logRedirectError),
};
}
return validateRefreshedPaidSession(sessionId, provider);
}

if (session.paymentStatus !== "paid") {
Expand All @@ -153,13 +165,41 @@ export const validatePaidSession = async (
};
}

return validateConfirmedPaidSession(session);
};

/** Re-read provider state after close reports that payment won the race. */
export const validateRefreshedPaidSession = async (
sessionId: string,
provider: PaymentProvider,
): Promise<SessionValidation> => {
const session = await provider.retrieveSession(sessionId);
if (session?.paymentStatus !== "paid") {
logRedirectError(
`Paid checkout not yet readable (session=${sessionId}, status=${session?.paymentStatus ?? "missing"})`,
);
return {
ok: false,
response: paymentErrorResponse(
"Payment verification failed. Please try again.",
503,
),
};
}
return validateConfirmedPaidSession(session);
};

/** Validate a session whose provider close operation has just confirmed payment. */
export const validateConfirmedPaidSession = async (
session: ValidatedPaymentSession,
): Promise<SessionValidation> => {
// Only a session carrying a valid price proof is provably ours. Without one we
// cannot prove ownership (foreign instance sharing the provider, replayed or
// corrupt data), so we neither process nor refund it — refunding an
// unverifiable session could refund another instance's payment.
const classified = await classifySessionIntent(session);
if (classified === null) {
logRedirectError(`Unrecognized payment session (session=${sessionId})`);
logRedirectError(`Unrecognized payment session (session=${session.id})`);
return {
ok: false,
response: paymentErrorResponse("Payment session not recognized"),
Expand Down
114 changes: 46 additions & 68 deletions src/features/api/payment-processing/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,20 @@
* Turn a validated, correctly-priced session into a real attendee: create the
* attendee plus its per-listing bookings atomically, finalize the payment
* session in the same batch, and persist the answers the buyer gave. Every
* failure here is *structured* (never a refund) so the caller can keep a
* quantity-0 placeholder instead of dropping a paid customer.
* failure here is structured so the caller can route the staged payment to its
* durable refund path.
*/

import { requiredMapValue } from "#fp";
import { businessTime } from "#routes/api/payment-processing/metadata.ts";
import type { ValidatedItem } from "#routes/api/payment-processing/package-pricing.ts";
import {
orderLineTotal,
paidByItem,
} from "#routes/api/payment-processing/pricing.ts";
import { paidByItem } from "#routes/api/payment-processing/pricing.ts";
import type {
BookingIntent,
PaymentResult,
} from "#routes/api/webhook-types.ts";
import { lineGroupId } from "#shared/booking/signed-metadata.ts";
import { orderBookings } from "#shared/booking-lines.ts";
import { bookingsForOrder } from "#shared/booking-lines.ts";
import { capacityErrorFormatter } from "#shared/capacity-error.ts";
import { bookingBatchPlan } from "#shared/checkout-complete.ts";
import type {
Expand All @@ -26,8 +24,10 @@ import type {
} from "#shared/checkout-pricing.ts";
import { formatCurrency } from "#shared/currency.ts";
import { logActivity } from "#shared/db/activityLog.ts";
import { getPublicStatusId } from "#shared/db/attendee-statuses.ts";
import { attendeesApi } from "#shared/db/attendees/api.ts";
import { contactFields } from "#shared/db/attendees/pii.ts";
import type { CheckoutStage } from "#shared/db/checkout-stages.ts";
import { DatabaseBusyError } from "#shared/db/client.ts";
import {
decryptSessionTokens,
type ProcessedPayment,
Expand All @@ -46,10 +46,8 @@ import type {
} from "#shared/payments.ts";
import type { ListingWithCount } from "#shared/types.ts";

/** The listing id + package path shared by every booking row we build from a
* signed line — a fresh booking, a quantity-0 placeholder, or a dateless ghost.
* A line's package path keeps a listing booked through two paths in two distinct
* slots. */
/** The listing id and package path for a signed booking line. A package path
* keeps one listing booked through two paths in two distinct slots. */
export const bookingSlot = (item: BookingItem) => ({
listingId: item.e,
packageGroupId: lineGroupId(item) ?? 0,
Expand Down Expand Up @@ -133,19 +131,19 @@ export type CreatedEntry = {
/**
* The outcome of trying to honour a signed booking at the charged price: the
* created entries, or a structured reason it couldn't be created. The caller
* decides what to do — a success finalizes a real ticket; any failure keeps a
* quantity-0 placeholder and refunds. createAttendeeForSession never refunds
* itself.
* decides what to do: success finalizes a real ticket and failure enters the
* staged refund path. createAttendeeForSession never refunds itself.
*/
export type HonourResult =
| { ok: true; entries: CreatedEntry[] }
| { ok: true }
| { ok: null; error: unknown }
| {
ok: false;
reason:
| "sold_out"
| "capacity_exceeded"
| "encryption_error"
| "stage_mismatch"
| "unexpected_error";
detail: string;
};
Expand Down Expand Up @@ -205,19 +203,6 @@ export const saveSessionAnswers = async (
await saveAttendeeAnswers(grouped);
};

export const attendeeBaseFields = async (
session: ValidatedPaymentSession,
intent: BookingIntent,
) => ({
address: intent.address,
email: intent.email,
name: intent.name,
paymentId: session.paymentReference,
phone: intent.phone,
special_instructions: intent.special_instructions,
statusId: await getPublicStatusId(),
});

export const logPromoCodeModifiers = async (
specs: ModifierSpec[],
applications: ModifierApplication[],
Expand All @@ -243,46 +228,43 @@ export const logPromoCodeModifiers = async (
* is set iff the attendee row exists — closing the crash window between a
* separate post-transaction finalize and the attendee INSERT. durationDays is
* listing-scoped and re-read here so the stored range always matches the
* listing's current duration policy. Returns a structured failure (never
* refunds) so the caller can keep the booking as a placeholder instead.
* listing's current duration policy. Returns a structured failure so the caller
* can refund the staged booking.
*/
export const createAttendeeForSession = async (
session: ValidatedPaymentSession,
intent: BookingIntent,
validatedItems: ValidatedItem[],
pricingIntent: CheckoutIntent,
pricedOrder: PricedOrder,
ticketToken: string,
stage: CheckoutStage,
): Promise<HonourResult> => {
let prepared: {
attendeeInput: Parameters<typeof attendeesApi.createBookingAtomic>[0];
plan: Parameters<typeof attendeesApi.createBookingAtomic>[1];
attendeeInput: Parameters<typeof attendeesApi.activateStagedAttendee>[1];
plan: Parameters<typeof attendeesApi.activateStagedAttendee>[2];
};
try {
// Per-LINE paid amounts: a listing booked through two paths is two lines
// with their own prices, and each becomes its own booking row. The priced
// order's lines reference the pricing intent's item objects, which pair
// 1:1 by index with validatedItems.
const paidByIntentItem = paidByItem(pricedOrder);
const bookings = orderBookings({
allocations: intent.allocations,
date: intent.date,
dayCount: intent.dayCount,
lines: validatedItems.map(({ item, listing }, index) => {
const pricePaid = paidByIntentItem.get(pricingIntent.items[index]!);
const bookings = bookingsForOrder(
intent,
validatedItems.map(({ item, listing }, index) => {
const pricePaid = requiredMapValue(
paidByIntentItem,
pricingIntent.items[index]!,
`Paid amount for checkout item ${index} was not loaded`,
);
return {
...bookingSlot(item),
listing,
...(pricePaid !== undefined ? { pricePaid } : {}),
pricePaid,
quantity: item.q,
};
}),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
});
const remainingBalance =
intent.reservationAmount === undefined
? 0
: pricedOrder.fullSubtotal - orderLineTotal(pricedOrder);

);
// Build every fallible input before starting the atomic write. A failure
// here is known not to have committed, while a write failure below must be
// checked against primary state before deciding whether to refund.
Expand All @@ -297,12 +279,11 @@ export const createAttendeeForSession = async (
);
prepared = {
attendeeInput: {
...(await attendeeBaseFields(session, intent)),
...contactFields(intent),
bookings,
remainingBalance,
ticketToken,
paymentId: session.paymentReference,
},
plan,
plan: { ...plan, finalize: plan.finalize! },
};
} catch (error) {
return {
Expand All @@ -311,34 +292,31 @@ export const createAttendeeForSession = async (
reason: "unexpected_error",
};
}
let result: Awaited<ReturnType<typeof attendeesApi.createBookingAtomic>>;
let result: Awaited<ReturnType<typeof attendeesApi.activateStagedAttendee>>;
try {
result = await attendeesApi.createBookingAtomic(
result = await attendeesApi.activateStagedAttendee(
stage,
prepared.attendeeInput,
prepared.plan,
);
} catch (error) {
if (error instanceof DatabaseBusyError) throw error;
return { error, ok: null };
}
if (result === "sold-out") {
return {
detail: "a chosen add-on or extra sold out during payment",
ok: false,
reason: "sold_out",
};
}

// All-or-nothing: a capacity failure rolled the transaction back (no legs).
if (!result.success) {
return {
detail: formatPostPaymentError(
result.reason,
validatedItems[0]!.listing.name,
),
detail:
result.reason === "sold_out"
? "a chosen add-on or extra sold out during payment"
: result.reason === "stage_mismatch"
? `Staged order did not match session ${session.id}`
: formatPostPaymentError(
result.reason,
validatedItems[0]!.listing.name,
),
ok: false,
reason: result.reason,
};
}
const entries = pairEntriesByListing(result.attendees, validatedItems);
return { entries, ok: true };
return { ok: true };
};
Loading