Skip to content
Closed
Show file tree
Hide file tree
Changes from 10 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
12 changes: 8 additions & 4 deletions src/features/admin/attendee-refunds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ const handleAttendeeRefund = verifiedAttendeeAction(
{ attendee: data.attendee, references },
listingId,
);
if (refunded.outcome === "pending") {
return refundError(attendeeId, t("error.refund_pending"), returnUrl);
}
if (refunded.outcome !== "refunded") {
return refundError(attendeeId, t("error.refund_failed"), returnUrl);
}
Expand Down Expand Up @@ -196,16 +199,16 @@ const buildRefundProblemResponse = async (
ctx: RefundResponseCtx,
): Promise<Response> => {
const { listing, refundAllUrl, counts, remaining } = ctx;
const { refundedCount, failedCount, errorCount } = counts;
const { refundedCount, pendingCount, failedCount, errorCount } = counts;
const problemCount = failedCount + errorCount;
const errorNote =
errorCount > 0
? ` (${errorCount} errored — check the activity log for details)`
: "";
const msg =
remaining > 0
? `${refundedCount} refund(s) succeeded, ${problemCount} failed${errorNote}. ${remaining} remaining — submit again to continue.`
: `${refundedCount} refund(s) succeeded, ${problemCount} failed${errorNote}. Some payments may have already been refunded.`;
? `${refundedCount} refund(s) succeeded, ${pendingCount} pending, and ${problemCount} failed${errorNote}. ${remaining} remaining. Submit again to continue.`
: `${refundedCount} refund(s) succeeded, ${pendingCount} pending, and ${problemCount} failed${errorNote}. Some payments may have already been refunded.`;
await logActivity(
`Bulk refund: ${refundedCount} succeeded, ${problemCount} failed for '${listing.name}'`,
listing.id,
Expand All @@ -219,7 +222,8 @@ const buildRefundAllResponse = async (
): Promise<Response> => {
const { counts, listing, refundAllUrl, totalRefundable, remaining } = ctx;
const refundedCount = counts.refundedCount;
const hasProblems = counts.failedCount + counts.errorCount > 0;
const hasProblems =
counts.pendingCount + counts.failedCount + counts.errorCount > 0;

if (hasProblems) {
return buildRefundProblemResponse({
Expand Down
7 changes: 6 additions & 1 deletion src/features/admin/refunds/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type MarkReturnedReferences = (

export type RefundCounts = {
refundedCount: number;
pendingCount: number;
failedCount: number;
errorCount: number;
};
Expand All @@ -46,7 +47,8 @@ const refundReferenceAtProvider = async (
const paymentReference = reference.reference;
try {
if (reference.providerRefunded) return "refunded";
if (await provider.refundPayment(paymentReference)) return "refunded";
const result = await provider.refundPayment(paymentReference);
if (result === "refunded" || result === "pending") return result;
Comment thread
stefan-burke marked this conversation as resolved.
Resolved
if (await provider.isPaymentRefunded(paymentReference)) return "refunded";
logError({
code: ErrorCode.PAYMENT_REFUND,
Expand Down Expand Up @@ -153,6 +155,8 @@ const tallyProviderRefund = (
} else if (outcome === "failed") {
counts.failedCount++;
logBulkRefundProblem(outcome, candidate, listingId);
} else if (outcome === "pending") {
counts.pendingCount++;
} else {
refundedAttendees.push({
attendeeId: candidate.attendee.id,
Expand All @@ -171,6 +175,7 @@ export const processRefundBatch = async (
const counts: RefundCounts = {
errorCount: 0,
failedCount: 0,
pendingCount: 0,
refundedCount: 0,
};
for (const group of packByReferenceCount(PROVIDER_REFUND_CONCURRENCY)(
Expand Down
6 changes: 4 additions & 2 deletions src/features/admin/refunds/waves.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { PaymentRefundResult } from "#shared/payments.ts";
import type { RefundCandidate } from "./candidates.ts";

/** The result of attempting to refund one charge reference (or a whole
* candidate, once its per-reference outcomes are combined). */
export type RefundOutcome = "refunded" | "failed" | "errored";
export type RefundOutcome = PaymentRefundResult | "errored";

/** Pack candidates into waves whose combined charge references stay within
* `budget`, so each concurrently-processed wave issues at most ~`budget`
Expand Down Expand Up @@ -30,11 +31,12 @@ export const packByReferenceCount =

/** Reduce a candidate's per-reference outcomes to a single outcome, worst
* first: any errored reference errors the candidate, any failed reference
* fails it, otherwise it is fully refunded. */
* fails it, any pending reference keeps it pending, otherwise it is refunded. */
export const combineRefundOutcomes = (
outcomes: RefundOutcome[],
): RefundOutcome => {
if (outcomes.includes("errored")) return "errored";
if (outcomes.includes("failed")) return "failed";
if (outcomes.includes("pending")) return "pending";
return "refunded";
};
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
Loading