diff --git a/TODO.md b/TODO.md index 30aca87128..ebae5956af 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/scripts/mutation/equivalent-mutants.txt b/scripts/mutation/equivalent-mutants.txt index 88f8b080ef..273ebde7a4 100644 --- a/scripts/mutation/equivalent-mutants.txt +++ b/scripts/mutation/equivalent-mutants.txt @@ -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), diff --git a/src/docs/database.ts b/src/docs/database.ts index 93dfe00261..f9f6ec7a04 100644 --- a/src/docs/database.ts +++ b/src/docs/database.ts @@ -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"; diff --git a/src/features/admin/attendee-form-routes.ts b/src/features/admin/attendee-form-routes.ts index 592e1f6c82..0cb6ed7f8c 100644 --- a/src/features/admin/attendee-form-routes.ts +++ b/src/features/admin/attendee-form-routes.ts @@ -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"; @@ -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, @@ -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); diff --git a/src/features/api/folded-booking.ts b/src/features/api/folded-booking.ts index bfece442a4..74abfaf594 100644 --- a/src/features/api/folded-booking.ts +++ b/src/features/api/folded-booking.ts @@ -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 { @@ -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) diff --git a/src/features/api/payment-processing/classify.ts b/src/features/api/payment-processing/classify.ts index 9942d663f8..dcfa321f8d 100644 --- a/src/features/api/payment-processing/classify.ts +++ b/src/features/api/payment-processing/classify.ts @@ -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 { @@ -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), diff --git a/src/features/api/payment-processing/committed-entries.ts b/src/features/api/payment-processing/committed-entries.ts new file mode 100644 index 0000000000..4cdf572bac --- /dev/null +++ b/src/features/api/payment-processing/committed-entries.ts @@ -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 => { + 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(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); +}; diff --git a/src/features/api/payment-processing/completion.ts b/src/features/api/payment-processing/completion.ts new file mode 100644 index 0000000000..c2cb9e0a01 --- /dev/null +++ b/src/features/api/payment-processing/completion.ts @@ -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 => { + 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, + ); +}; diff --git a/src/features/api/payment-processing/create.ts b/src/features/api/payment-processing/create.ts index 3f62fc4035..3ba8ecd32a 100644 --- a/src/features/api/payment-processing/create.ts +++ b/src/features/api/payment-processing/create.ts @@ -6,6 +6,7 @@ * quantity-0 placeholder instead of dropping a paid customer. */ +import { committedEntries } from "#routes/api/payment-processing/committed-entries.ts"; import { businessTime } from "#routes/api/payment-processing/metadata.ts"; import type { ValidatedItem } from "#routes/api/payment-processing/package-pricing.ts"; import { @@ -16,12 +17,8 @@ import type { BookingIntent, PaymentResult, } from "#routes/api/webhook-types.ts"; -import { bookingDateFields } from "#routes/public/ticket-payment.ts"; -import { - soleParentPackageIds, - stampChildRowPackages, -} from "#shared/booking/page-packages.ts"; import { lineGroupId } from "#shared/booking/signed-metadata.ts"; +import { orderBookings } from "#shared/booking-lines.ts"; import { capacityErrorFormatter } from "#shared/capacity-error.ts"; import { bookingBatchPlan } from "#shared/checkout-complete.ts"; import type { @@ -31,13 +28,13 @@ import type { import { formatCurrency } from "#shared/currency.ts"; import { logActivity } from "#shared/db/activityLog.ts"; import { getPublicStatusId } from "#shared/db/attendee-statuses.ts"; -import type { ListingBooking } from "#shared/db/attendee-types.ts"; +import { activateStagedBooking } from "#shared/db/attendees/activate.ts"; import { type createAttendeeAtomic, createBookingAtomic, } from "#shared/db/attendees/api.ts"; -import { ensureAllBookings } from "#shared/db/attendees/create.ts"; -import { expandChildAllocations } from "#shared/db/attendees/order-parents.ts"; +import type { CheckoutStage } from "#shared/db/checkout-stages.ts"; +import { recordOrderActivity } from "#shared/db/contact-tokens.ts"; import { decryptSessionTokens, type ProcessedPayment, @@ -269,36 +266,21 @@ export const createAttendeeForSession = async ( validatedItems: ValidatedItem[], pricingIntent: CheckoutIntent, pricedOrder: PricedOrder, + ticketToken: string, + stage: CheckoutStage | null, ): Promise => { // 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 rawBookings: ListingBooking[] = validatedItems.map( - ({ item, listing }, index) => ({ - ...bookingSlot(item), + const bookings = orderBookings( + validatedItems.map(({ item, listing }, index) => ({ + item, + listing, pricePaid: paidByIntentItem.get(pricingIntent.items[index]!) ?? 0, - quantity: item.q, - ...bookingDateFields(listing, intent.date, intent.dayCount), - }), - ); - // Expand summed child bookings into per-parent rows when allocations were - // carried through the signed metadata (paid-path provenance): each - // allocation becomes its own listing_attendees row with the correct - // parentListingId and proportional pricePaid, mirroring the free-path - // behaviour in createFreeReservation. Each child row is then stamped with - // its parent's package when the parent books through exactly one path. - const bookings = stampChildRowPackages( - intent.allocations && intent.allocations.length > 0 - ? expandChildAllocations(rawBookings, intent.allocations) - : rawBookings, - soleParentPackageIds( - intent.items.map((item) => ({ - listingId: item.e, - packageGroupId: lineGroupId(item), - })), - ), + })), + intent, ); const fullTotal = pricedOrder.fullSubtotal; const depositTotal = orderLineTotal(pricedOrder); @@ -306,32 +288,73 @@ export const createAttendeeForSession = async ( intent.reservationAmount === undefined ? 0 : fullTotal - depositTotal; // Consume modifier stock, post the ledger legs, and finalize the session in - // ONE libsql batch with the attendee + booking INSERTs, so the booking, its + // one atomic write with the attendee + booking rows, so the booking, its // stock, its sale/payment legs, and attendee_id are all-or-nothing in a single - // round-trip — never an interactive write transaction held open against the - // primary (which timed out under edge→primary latency). The usage amounts come + // write transaction. The usage amounts come // from the same pricing pass that calculated the checkout total, so scoped // bases, quantities, and clamped discounts match. A modifier that sold out // during payment stops the booking landing (→ "sold-out"). The event is keyed // on the payment session and dated from the provider's checkout time. + const ledger = { + eventId: session.id, + occurredAt: businessTime(session), + pricedOrder, + }; + const finalize = { + paymentReference: session.paymentReference, + sessionId: session.id, + }; const plan = await bookingBatchPlan( + stage?.attendeeId ?? null, pricedOrder.modifierApplications, - { - eventId: session.id, - occurredAt: businessTime(session), - pricedOrder, - }, - { paymentReference: session.paymentReference, sessionId: session.id }, + ledger, + finalize, ); - const result = await createBookingAtomic( - { - ...(await attendeeBaseFields(session, intent)), - bookings, - remainingBalance, - }, - plan, - ); + const attendeeInput = { + ...(await attendeeBaseFields(session, intent)), + bookings, + remainingBalance, + ticketToken, + }; + if (stage) { + const activated = await activateStagedBooking( + session.id, + stage.attendeeId, + ticketToken, + attendeeInput, + { ...plan, finalize }, + ); + if (!activated.success) { + return { + detail: formatPostPaymentError( + activated.reason === "sold-out" ? "sold_out" : "capacity_exceeded", + validatedItems[0]!.listing.name, + ), + ok: false, + reason: + activated.reason === "sold-out" ? "sold_out" : "capacity_exceeded", + }; + } + await recordOrderActivity( + intent.email, + intent.phone, + "public", + ticketToken, + ); + return { + entries: await committedEntries( + stage.attendeeId, + ticketToken, + session, + intent, + validatedItems, + ), + ok: true, + }; + } + + const result = await createBookingAtomic(attendeeInput, plan); if (result === "sold-out") { return { detail: "a chosen add-on or extra sold out during payment", @@ -340,24 +363,18 @@ export const createAttendeeForSession = async ( }; } - // All-or-nothing: a capacity failure rolled the transaction back (no legs). - const bookingCheck = await ensureAllBookings( - result, - bookings.length, - "public", - ); - if (!bookingCheck.ok) { + // A capacity failure rolled the whole atomic transaction back, including legs. + if (!result.success) { return { detail: formatPostPaymentError( - bookingCheck.reason, + result.reason, validatedItems[0]!.listing.name, ), ok: false, - reason: bookingCheck.reason, + reason: result.reason, }; } - const created = result as Extract; - const entries = pairEntriesByListing(created.attendees, validatedItems); + const entries = pairEntriesByListing(result.attendees, validatedItems); return { entries, ok: true }; }; diff --git a/src/features/api/payment-processing/index.ts b/src/features/api/payment-processing/index.ts index cbc8c18423..53d17b6834 100644 --- a/src/features/api/payment-processing/index.ts +++ b/src/features/api/payment-processing/index.ts @@ -5,7 +5,7 @@ * * unreserved → reserved → (finalized success | terminal failure) * - * via the steps: validate → reserve → process → record-outcome. + * via validate → reserve → process → record-outcome. * * 1. validate — `validatePaidSession` (classify.ts) confirms with the provider * that the session is paid and `classifySession` proves (via a signed price @@ -14,30 +14,20 @@ * 2. reserve — `processPaymentSession` claims the idempotency lock * (`reserveSession`); a conflict replays the already-recorded outcome * (`handleReservationConflict`) instead of re-processing. - * 3. process — `processReservedSession`, holding the lock, turns the signed - * session into either a real ticket (`createAttendeeForSession`) / a settled - * balance (`settleBalanceSession`), or — for ANY reason it can't be honoured - * (charge mismatch, a price edited mid-checkout, a sold-out extra, a full - * event, a since-deleted listing, or an unexpected error after the charge) — - * a quantity-0 placeholder that is refunded (`storeRefundedBooking`), so a - * paid customer is never dropped. + * 3. process — `processReservedSession` creates a ticket, settles a balance, + * or stores and refunds a quantity-0 placeholder when the paid booking cannot + * be honoured before its atomic write commits. * 4. record-outcome — `processPaymentSession` records a handled failure as the * session's terminal outcome (`markSessionFailed`) so a later redirect/webhook * replays the same result, or releases the reservation when a real refund * failed so the next provider redelivery re-attempts it. * - * The HTTP plumbing (redirect + webhook handlers, routing) lives in - * `webhooks.ts` and calls into this module. The steps above are split across - * sibling files (metadata, classify, cancel, refunds, items, package-pricing, - * pricing, create, store-refund); this file is the orchestration that wires - * them into the two-phase locked lifecycle. */ +import { completePaidBooking } from "#routes/api/payment-processing/completion.ts"; import { alreadyProcessedResult, createAttendeeForSession, - logPromoCodeModifiers, - saveSessionAnswers, sessionSuccess, } from "#routes/api/payment-processing/create.ts"; import { validateAllItems } from "#routes/api/payment-processing/items.ts"; @@ -45,10 +35,10 @@ import { checkoutIntentForSession, paidPricingRefund, } from "#routes/api/payment-processing/pricing.ts"; +import { recoverOrRefundUnexpectedCreate } from "#routes/api/payment-processing/recovery.ts"; import { chargeMismatchSpec, deletedListingSpec, - refundSpec, refuseMismatch, } from "#routes/api/payment-processing/refunds.ts"; import { @@ -66,7 +56,9 @@ import type { } from "#routes/api/webhook-types.ts"; import { eventGroupHasLegs } from "#shared/accounting/queries.ts"; import { type PricedOrder, priceCheckout } from "#shared/checkout-pricing.ts"; +import { generateTicketToken } from "#shared/crypto/utils.ts"; import { balanceEventGroup } from "#shared/db/attendees/balance.ts"; +import { getCheckoutStage } from "#shared/db/checkout-stages.ts"; import { buyerVisits, specsFromRefs } from "#shared/db/modifier-resolve.ts"; import { finalizeSessionIfUnresolved, @@ -75,24 +67,14 @@ import { parseSessionFailure, releaseReservation, reserveSession, - setSessionTicketTokens, } from "#shared/db/processed-payments.ts"; -import { logDebug } from "#shared/logger.ts"; import { bookingLedgerDisposition } from "#shared/session-ledger.ts"; -import { logAndNotifyRegistration } from "#shared/webhook.ts"; -type SessionProcessorOptions = { storeTokens?: boolean }; - -/** The shared shape of the two-phase session processors: reserve/process a paid - * session by id, given its validated data, and resolve to a {@link - * PaymentResult}. */ type SessionProcessor = ( sessionId: string, data: ValidatedSession, - options?: SessionProcessorOptions, ) => Promise; -/** Handle the "already reserved" branch of reserveSession */ const handleReservationConflict = async ( intent: BookingIntent, existing: ProcessedPayment, @@ -103,12 +85,9 @@ const handleReservationConflict = async ( attendee_id: existing.attendee_id, }); } - // A recorded terminal failure replays the same handled outcome (refund - // already issued, sold out, price changed) without re-validating or - // re-refunding. failure_data is encrypted, so this read is async. + // Replay an encrypted terminal outcome without revalidating or refunding. const failure = await parseSessionFailure(existing.failure_data); if (failure) return { ...failure, success: false }; - // Otherwise reserved but not finalized — another request is mid-flight. return { error: "Payment is being processed. Please wait a moment and refresh.", status: 409, @@ -116,34 +95,27 @@ const handleReservationConflict = async ( }; }; -/** - * Replay a payment session the ledger already records as resolved to - * `attendeeId`: heal the fresh reservation at that attendee — token-safely, so a - * racing delivery's finalized tokens survive (see {@link - * finalizeSessionIfUnresolved}) — and return success. NEVER refunds: the money is - * already in the ledger against this attendee. Tokens come back empty, so the - * redirect renders directly from the attendee. Shared by the booking-replay and - * balance-replay preflights. - */ -const replaySuccess = async ( - sessionId: string, - attendeeId: number, - listingId: number, - paymentReference = "", -): Promise => { +/** Heal a fresh reservation from the ledger's attendee, preserving any tokens + * finalized by a racing delivery. The recorded money is never refunded. */ +type ReplaySuccessInput = { + attendeeId: number; + listingId: number; + paymentReference: string; + sessionId: string; +}; + +const replaySuccess = async ({ + attendeeId, + listingId, + paymentReference, + sessionId, +}: ReplaySuccessInput): Promise => { await finalizeSessionIfUnresolved(sessionId, attendeeId, paymentReference); - logDebug("Payment", `Replayed already-ledgered session ${sessionId}`); return sessionSuccess(attendeeId, listingId); }; -/** - * Acknowledge a session the ledger already accounts for but whose booking is - * gone — an operator deleted the attendee (its sale/payment legs remain) or it - * was a refunded quantity-0 placeholder. The money is already recorded, so we - * neither refund again nor recreate the booking: return a terminal handled - * outcome (200 — the webhook acks it, the redirect shows it as processed) and - * leave the orphaned ledger rows for the operator to reconcile. - */ +/** Acknowledge recorded money whose booking is gone without refunding again or + * recreating it; its orphaned ledger rows remain for operator reconciliation. */ const alreadyHandledSession = ( sessionId: string, listingId: number, @@ -154,39 +126,31 @@ const alreadyHandledSession = ( success: false, }); -/** - * The booking-session ledger preflight: the durable ledger — not the prunable - * processed_payments row — is the source of truth for "already honoured", so - * before validating, pricing, or refunding, resolve what it already records. - * Returns the replay outcome for a session it has seen (a live booking replays as - * success; an orphaned one is acknowledged), or null for a session it has never - * recorded (process it fresh). The single guard that stops a late replay — after - * the idempotency row is pruned or lost to a stale-reservation cleanup — from - * refunding a live ticket via the deleted-listing, price-change, inactive-listing, - * or capacity refund paths below. - */ -const replaySessionFromLedger = async ( +/** Resolve a booking against the durable ledger before any validation, pricing, + * or refund path. This protects live tickets after their idempotency row is lost. */ +/** Returns null when the ledger has not recorded this session yet. */ +const replaySessionFromLedgerOrNull = async ( sessionId: string, listingId: number, + paymentReference: string, ): Promise => { const disposition = await bookingLedgerDisposition(sessionId); switch (disposition.status) { case "unrecorded": return null; case "booked": - return replaySuccess(sessionId, disposition.attendeeId, listingId); + return replaySuccess({ + attendeeId: disposition.attendeeId, + listingId, + paymentReference, + sessionId, + }); case "orphaned": return alreadyHandledSession(sessionId, listingId); } }; -/** - * The balance-settlement counterpart of {@link replaySessionFromLedger}: replay a - * balance session whose payment leg the ledger already records (its idempotency - * row was pruned or lost), or null to settle it fresh. The attendee is known from - * the proof-bound intent, so — unlike the booking path — there is no orphaned - * case to resolve. - */ +/** Replay a balance payment already recorded by the ledger, or settle it fresh. */ const replayBalanceFromLedger = async ( sessionId: string, attendeeId: number, @@ -194,24 +158,15 @@ const replayBalanceFromLedger = async ( paymentReference: string, ): Promise => (await eventGroupHasLegs(await balanceEventGroup(sessionId))) - ? replaySuccess(sessionId, attendeeId, listingId, paymentReference) + ? replaySuccess({ attendeeId, listingId, paymentReference, sessionId }) : null; -/** - * Process a session we have just reserved (holding the lock). A signed session - * either becomes a real ticket or — for ANY reason we can't honour it (charge - * mismatch, a price edited mid-checkout, a sold-out extra, a full event, a - * since-deleted listing, or an unexpected error after the charge) — is kept as a - * quantity-0 placeholder and refunded, so a paid customer is never dropped. Every - * failure returned here is a handled terminal outcome; processPaymentSession - * records it so a later redirect/webhook replays the same result instead of - * re-running refunds or stalling behind the idempotency lock. - */ -const processReservedSession: SessionProcessor = async ( - sessionId, - data, - options, -) => { +/** Process a reserved session into a ticket or handled terminal outcome. Errors + * after an atomic booking commit propagate rather than refunding a live ticket. */ +const processReservedSession = async ( + sessionId: string, + data: ValidatedSession, +): Promise => { const { session, intent, verdict } = data; const signedListingId = intent.items[0]!.e; @@ -235,14 +190,19 @@ const processReservedSession: SessionProcessor = async ( } return settleBalanceSession(sessionId, session, intent); } + const stage = await getCheckoutStage(sessionId); // Preflight: the durable ledger is the source of truth for "already honoured". // Replay a session the ledger already records BEFORE any validation, pricing, // or refund path runs below — so a late delivery (after the prunable idempotency // row is gone) never refunds a live ticket via the deleted-listing, price-change, // inactive-listing, or capacity paths, nor double-books it. - const replay = await replaySessionFromLedger(sessionId, signedListingId); - if (replay) return replay; + const replay = await replaySessionFromLedgerOrNull( + sessionId, + signedListingId, + session.paymentReference, + ); + if (replay !== null) return replay; // Phase 2: Validate listings. const validated = await validateAllItems(session, intent); @@ -261,6 +221,7 @@ const processReservedSession: SessionProcessor = async ( intent, datelessGhostBookings(intent.items), deletedListingSpec(session), + stage, ); } return validated; @@ -291,13 +252,32 @@ const processReservedSession: SessionProcessor = async ( ? chargeMismatchSpec(session, verdict.agreed) : paidPricingRefund(validatedItems, pricedOrder, verdict.agreed); if (knownRefund) { - return storeRefundedBooking(session, intent, placeholders, knownRefund); + return storeRefundedBooking( + session, + intent, + placeholders, + knownRefund, + stage, + ); } // Otherwise try to honour it at the charged price. ANY failure keeps the // booking at quantity 0 and refunds rather than dropping a paid customer: a // structured sold-out/capacity/encryption result, OR an unexpected throw after // the charge (which would otherwise crash-loop the webhook over paid money). + const preparedTicketToken = stage?.ticketToken ?? generateTicketToken(); + const codeSpecs = modifierSpecs.filter((spec) => spec.trigger === "code"); + const complete = ( + entries: Parameters[0], + ticketTokens: string[], + ) => + completePaidBooking( + entries, + intent, + codeSpecs, + pricedOrder.modifierApplications, + ticketTokens, + ); let honoured: Awaited>; try { honoured = await createAttendeeForSession( @@ -306,16 +286,21 @@ const processReservedSession: SessionProcessor = async ( validatedItems, pricingIntent, pricedOrder, + preparedTicketToken, + stage, ); } catch (error) { - return storeRefundedBooking( - session, + // The atomic create may have committed before result handling or the client + // threw. Recheck its reservation on the primary before moving money. + return recoverOrRefundUnexpectedCreate({ + complete, + error, intent, placeholders, - refundSpec("unexpected_error")( - `Unexpected error completing session ${session.id}: ${String(error)}`, - ), - ); + session, + ticketToken: preparedTicketToken, + validatedItems, + }); } if (!honoured.ok) { return storeRefundedBooking( @@ -323,41 +308,20 @@ const processReservedSession: SessionProcessor = async ( intent, placeholders, specForFailure(honoured), + stage, ); } // Success: a real ticket, finalized atomically in the creation transaction. const createdEntries = honoured.entries; - await saveSessionAnswers(createdEntries, intent); const firstAttendee = createdEntries[0]!; const ticketToken = firstAttendee.attendee.ticket_token; - - // Persist the ticket token for webhook replay when the caller needs it. - if (options?.storeTokens !== false) { - await setSessionTicketTokens(sessionId, [ticketToken]); - } - - const codeSpecs = modifierSpecs.filter((s) => s.trigger === "code"); - if (codeSpecs.length > 0) { - await logPromoCodeModifiers( - codeSpecs, - pricedOrder.modifierApplications, - firstAttendee.listing, - firstAttendee.attendee.id, - ); - } - - await logAndNotifyRegistration(createdEntries, intent.siteTokenIndex); - - return sessionSuccess(firstAttendee.attendee.id, firstAttendee.listing.id, [ - ticketToken, - ]); + return complete(createdEntries, [ticketToken]); }; export const processPaymentSession: SessionProcessor = async ( sessionId, data, - options, ) => { // Phase 1: Reserve the session (claim the lock) const reservation = await reserveSession(sessionId); @@ -365,7 +329,7 @@ export const processPaymentSession: SessionProcessor = async ( return handleReservationConflict(data.intent, reservation.existing); } - const result = await processReservedSession(sessionId, data, options); + const result = await processReservedSession(sessionId, data); // A refund of a real payment that FAILED must stay retryable, and the very // next provider redelivery should re-attempt it. Releasing the reservation diff --git a/src/features/api/payment-processing/recovery-decision.ts b/src/features/api/payment-processing/recovery-decision.ts new file mode 100644 index 0000000000..4d754a8273 --- /dev/null +++ b/src/features/api/payment-processing/recovery-decision.ts @@ -0,0 +1,24 @@ +export type RecoveryFacts = { + finalizedAttendeeId: number | null; + tokenAttendeeId: number | null; + unresolved: boolean; +}; + +export type RecoveryDecision = + | { attendeeId: number; kind: "recover" } + | { kind: "refund" } + | { kind: "rethrow" }; + +/** Decide from committed primary state only. An attendee beside an unresolved + * reservation is impossible after atomic create cleanup, so it fails loudly. */ +export const decideUnexpectedCreate = ( + facts: RecoveryFacts, +): RecoveryDecision => { + if (facts.finalizedAttendeeId !== null) { + return { attendeeId: facts.finalizedAttendeeId, kind: "recover" }; + } + if (!facts.unresolved || facts.tokenAttendeeId !== null) { + return { kind: "rethrow" }; + } + return { kind: "refund" }; +}; diff --git a/src/features/api/payment-processing/recovery.ts b/src/features/api/payment-processing/recovery.ts new file mode 100644 index 0000000000..49a95721a2 --- /dev/null +++ b/src/features/api/payment-processing/recovery.ts @@ -0,0 +1,112 @@ +import { committedEntries } from "#routes/api/payment-processing/committed-entries.ts"; +import type { CreatedEntry } from "#routes/api/payment-processing/create.ts"; +import type { ValidatedItem } from "#routes/api/payment-processing/package-pricing.ts"; +import { decideUnexpectedCreate } from "#routes/api/payment-processing/recovery-decision.ts"; +import { refundSpec } from "#routes/api/payment-processing/refunds.ts"; +import { + type placeholderBookings, + storeRefundedBooking, +} from "#routes/api/payment-processing/store-refund.ts"; +import type { + BookingIntent, + PaymentResult, + ValidatedSession, +} from "#routes/api/webhook-types.ts"; +import { computeTicketTokenIndex } from "#shared/crypto/hashing.ts"; +import type { BlindIndex } from "#shared/crypto/sealed.ts"; +import { queryBatchPrimary, resultRows } from "#shared/db/client.ts"; +import { recordOrderActivity } from "#shared/db/contact-tokens.ts"; +import { UNRESOLVED_RESERVATION } from "#shared/db/processed-payments.ts"; + +type UnexpectedCreateRecovery = { + complete: ( + entries: CreatedEntry[], + ticketTokens: string[], + ) => Promise; + error: unknown; + intent: BookingIntent; + placeholders: ReturnType; + session: ValidatedSession["session"]; + ticketToken: string; + validatedItems: ValidatedItem[]; +}; + +/** Restore the contact history normally written after the booking batch returns. + * A committed batch whose result was lost never reached that completion step. */ +const recordRecoveredOrderActivity = ( + intent: BookingIntent, + ticketToken: string, +): Promise => + recordOrderActivity(intent.email, intent.phone, "public", ticketToken); + +const loadRecoveryFacts = async ( + sessionId: string, + ticketTokenIndex: BlindIndex, +) => { + const [finalizedResult, unresolvedResult, attendeeResult] = + await queryBatchPrimary([ + { + args: [sessionId, ticketTokenIndex], + sql: `SELECT processedPayment.attendee_id + FROM processed_payments AS processedPayment + JOIN attendees AS attendee + ON attendee.id = processedPayment.attendee_id + WHERE processedPayment.payment_session_id = ? + AND attendee.ticket_token_index = ?`, + }, + { + args: [sessionId], + sql: `SELECT 1 AS present FROM processed_payments + WHERE payment_session_id = ? AND ${UNRESOLVED_RESERVATION}`, + }, + { + args: [ticketTokenIndex], + sql: "SELECT id FROM attendees WHERE ticket_token_index = ?", + }, + ]); + const finalized = resultRows<{ attendee_id: number }>(finalizedResult!)[0]; + const attendee = resultRows<{ id: number }>(attendeeResult!)[0]; + return { + finalizedAttendeeId: finalized === undefined ? null : finalized.attendee_id, + tokenAttendeeId: attendee === undefined ? null : attendee.id, + unresolved: resultRows<{ present: number }>(unresolvedResult!).length === 1, + }; +}; + +/** Recover an atomically finalized ticket after result handling throws. Refund + * only when the primary reservation proves the booking never committed. */ +export const recoverOrRefundUnexpectedCreate = async ({ + complete, + error, + intent, + placeholders, + session, + ticketToken, + validatedItems, +}: UnexpectedCreateRecovery): Promise => { + const ticketTokenIndex = await computeTicketTokenIndex(ticketToken); + const decision = decideUnexpectedCreate( + await loadRecoveryFacts(session.id, ticketTokenIndex), + ); + if (decision.kind === "recover") { + const entries = await committedEntries( + decision.attendeeId, + ticketToken, + session, + intent, + validatedItems, + ); + await recordRecoveredOrderActivity(intent, ticketToken); + return complete(entries, [ticketToken]); + } + if (decision.kind === "rethrow") throw error; + + return storeRefundedBooking( + session, + intent, + placeholders, + refundSpec("unexpected_error")( + `Unexpected error completing session ${session.id}: ${String(error)}`, + ), + ); +}; diff --git a/src/features/api/payment-processing/store-refund.ts b/src/features/api/payment-processing/store-refund.ts index eea117a315..c4c9a02d79 100644 --- a/src/features/api/payment-processing/store-refund.ts +++ b/src/features/api/payment-processing/store-refund.ts @@ -28,10 +28,16 @@ import type { PaymentFailureResult, PaymentResult, } from "#routes/api/webhook-types.ts"; -import { bookingDateFields } from "#routes/public/ticket-payment.ts"; +import { bookingDateFields } from "#shared/booking-date-fields.ts"; import { logActivity } from "#shared/db/activityLog.ts"; import { createAttendeeAtomic } from "#shared/db/attendees/api.ts"; import { settleAttendeeBalance } from "#shared/db/attendees/balance.ts"; +import { contactFields } from "#shared/db/attendees/pii.ts"; +import { updateAttendeePII } from "#shared/db/attendees/update.ts"; +import { + type CheckoutStage, + markCheckoutStage, +} from "#shared/db/checkout-stages.ts"; import { balanceFinalizeStatement } from "#shared/db/payment-finalize.ts"; import { createSystemNote } from "#shared/db/system-notes.ts"; import { ErrorCode, logError } from "#shared/logger.ts"; @@ -158,19 +164,35 @@ export const storeRefundedBooking = async ( intent: BookingIntent, bookings: PlaceholderBookings, spec: RefundSpec, + stage?: CheckoutStage | null, ): Promise => { if (spec.notify) addPendingWork(sendNtfyError(spec.notify)); const listingId = bookings[0]!.listingId; // A quantity-0 overbook insert has no capacity gate and consumes no modifier // stock, so it always writes the row — trust it. (If the PII can't encrypt the // whole system is broken; we don't defend against that.) - const stored = await createAttendeeAtomic({ - ...(await attendeeBaseFields(session, intent)), - allowOverbook: true, - bookings, - }); - const attendeeId = (stored as Extract) - .attendees[0]!.id; + const attendeeId = stage + ? stage.attendeeId + : ( + (await createAttendeeAtomic({ + ...(await attendeeBaseFields(session, intent)), + allowOverbook: true, + bookings, + })) as Extract< + Awaited>, + { success: true } + > + ).attendees[0]!.id; + if (stage) { + await updateAttendeePII(stage.attendeeId, { + ...contactFields(intent), + lat: "", + lng: "", + payment_id: session.paymentReference, + ticket_token: stage.ticketToken, + }); + await markCheckoutStage(session.id, "failed"); + } const refunded = await tryRefund(session.paymentReference, listingId); await recordPlaceholderRefund( { diff --git a/src/features/api/webhooks.ts b/src/features/api/webhooks.ts index a08f4b7e80..88e627e859 100644 --- a/src/features/api/webhooks.ts +++ b/src/features/api/webhooks.ts @@ -41,6 +41,7 @@ import { } from "#routes/tickets/token-utils.ts"; import { getSearchParam } from "#routes/url.ts"; import { getEffectiveDomain } from "#shared/config.ts"; +import { discardPendingCheckoutSessions } from "#shared/db/checkout-stages.ts"; import { getHiddenPackageMemberIds } from "#shared/db/groups.ts"; import { getListing } from "#shared/db/listings.ts"; import { clearSessionTokens } from "#shared/db/processed-payments.ts"; @@ -98,15 +99,7 @@ const processSessionAndRedirect = async ( // verified intent still holds it, rather than redirecting to the token path. const explicitThankYou = validation.data.intent.thankYouUrl ?? ""; - // Token persistence diverges by render path. The redirect path skips persisting - // (the tokens go in the URL, so storing them would leave them in the DB forever - // when the redirect wins the race). The direct-render path (explicit thank-you - // URL) does NOT put the tokens in a URL, so it MUST persist them — otherwise a - // reload hits the already-processed branch with no stored token and the buyer - // loses the ticket link. - const result = await processPaymentSession(sessionId, validation.data, { - storeTokens: explicitThankYou !== "", - }); + const result = await processPaymentSession(sessionId, validation.data); if (!result.success) { // Log once at the redirect boundary @@ -134,16 +127,15 @@ const processSessionAndRedirect = async ( ); } - // Redirect path: the tokens go in the URL, so clear any a racing webhook stored - // (consumed now via the redirect URL), then redirect. + // Redirect path: once the URL is ready, clear the persisted copy in one write. + // Direct-render and webhook paths retain it for a later redirect or reload. // encodeURIComponent preserves + as %2B so URLSearchParams.get() decodes it back correctly if (result.ticketTokens.length > 0) { + const location = `/payment/success?tokens=${encodeURIComponent( + result.ticketTokens.join("+"), + )}`; await clearSessionTokens(sessionId); - return redirectResponse( - `/payment/success?tokens=${encodeURIComponent( - result.ticketTokens.join("+"), - )}`, - ); + return redirectResponse(location); } // Already-processed session (no tokens available) - render directly. An @@ -222,7 +214,7 @@ const handlePaymentSuccess = (request: Request): Promise => { /** * Handle GET /payment/cancel (redirect after cancelled payment) * - * No attendee cleanup needed - attendee is only created after successful payment. + * Remove the quantity-zero staged attendee after an unpaid provider return. */ /** Log a payment session error with cancel context prefix */ const logCancelError = (detail: string): void => @@ -241,6 +233,10 @@ const handlePaymentCancel = withSessionId(async (sid) => { return paymentErrorResponse("Payment session not found"); } + if (session.paymentStatus !== "paid") { + await discardPendingCheckoutSessions([sid]); + } + return cancelPageResponse(session, logCancelError); }); diff --git a/src/features/public/qr-book.ts b/src/features/public/qr-book.ts index 08e20c451d..17a26a2f0b 100644 --- a/src/features/public/qr-book.ts +++ b/src/features/public/qr-book.ts @@ -13,6 +13,7 @@ import { buildTicketListing } from "#shared/booking/model.ts"; import { capacityDateFor } from "#shared/capacity-rules.ts"; import { getBookableStartDates } from "#shared/dates.ts"; import { getGroupRemainingForListing } from "#shared/db/attendees/capacity.ts"; +import { createStagedCheckout } from "#shared/db/checkout-stages.ts"; import { isHiddenPackageMember } from "#shared/db/groups.ts"; import { getActiveHolidays } from "#shared/db/holidays.ts"; import { @@ -123,7 +124,7 @@ const skipToCheckout = ( return runCheckoutFlow( `qr-book listing=${listing.id}`, request, - (provider, baseUrl) => provider.createCheckoutSession(intent, baseUrl), + (provider, baseUrl) => createStagedCheckout(provider, intent, baseUrl), () => errorResponse(listing.slug, 500), ); }; diff --git a/src/features/public/ticket-payment.ts b/src/features/public/ticket-payment.ts index 27dd879ecf..1910734206 100644 --- a/src/features/public/ticket-payment.ts +++ b/src/features/public/ticket-payment.ts @@ -35,14 +35,13 @@ import { stampChildRowPackages, } from "#shared/booking/page-packages.ts"; import type { BookingTree } from "#shared/booking/tree.ts"; -import { capacityDateFor } from "#shared/capacity-rules.ts"; +import { bookingDateFields } from "#shared/booking-date-fields.ts"; import { bookingBatchPlan } from "#shared/checkout-complete.ts"; import type { PricedOrder } from "#shared/checkout-pricing.ts"; import { getBookableStartDates, isBookingRangeValid } from "#shared/dates.ts"; import { getPublicStatusId } from "#shared/db/attendee-statuses.ts"; import type { ChildAllocation, - CreateAttendeeResult, LineBooking, ListingBooking, } from "#shared/db/attendee-types.ts"; @@ -52,8 +51,8 @@ import { createBookingAtomic, } from "#shared/db/attendees/api.ts"; import { getDatelessGroupRemaining } from "#shared/db/attendees/capacity.ts"; -import { ensureAllBookings } from "#shared/db/attendees/create.ts"; import { expandChildAllocations } from "#shared/db/attendees/order-parents.ts"; +import { createStagedCheckout } from "#shared/db/checkout-stages.ts"; import { getGroupIdsByListingIds, getHiddenPackageMemberIds, @@ -96,7 +95,6 @@ import { type Group, type Holiday, type ListingWithCount, - normalizeDurationDays, } from "#shared/types.ts"; import { parsePositiveInt } from "#shared/validation/number.ts"; import { listingsWithQuantity } from "./ticket-form.ts"; @@ -206,22 +204,6 @@ export const checkAvailability = ( * webhook flows aligned. Span: customisable listings use the chosen `dayCount`; * daily listings use their fixed `duration_days`; standard listings span 1 day. */ -export const bookingDateFields = ( - listing: Pick< - TicketListing["listing"], - "listing_type" | "duration_days" | "customisable_days" - >, - date: string | null, - dayCount = 1, -): { date: string | null; durationDays: number } => ({ - date: capacityDateFor(listing.listing_type, date), - durationDays: listing.customisable_days - ? normalizeDurationDays(dayCount) - : listing.listing_type === "daily" - ? normalizeDurationDays(listing.duration_days) - : 1, -}); - /** Load one group's package pricing and shape it into the {@link PagePackage} * the booking flow carries — the group's display fields plus its member * quantity/price maps, scoped to the members actually on the page. */ @@ -257,7 +239,7 @@ export const handlePaymentFlow = ( runCheckoutFlow( `ticket items=${intent.items.length}`, request, - (provider, baseUrl) => provider.createCheckoutSession(intent, baseUrl), + (provider, baseUrl) => createStagedCheckout(provider, intent, baseUrl), (msg) => errorRedirect(ctx.actionUrl ?? `/ticket/${ctx.slugs.join("+")}`, msg), ); @@ -454,7 +436,7 @@ export const createFreeReservation = async ({ // (hasDuplicateBookingSlot) permits same-child/different-parent rows because // it keys on (listingId, date, parentListingId, packageGroupId). The // expanded list replaces the summed list for the create call; - // ensureAllBookings' count uses the expanded length. Each child row is then + // The atomic create count uses the expanded length. Each child row is then // stamped with its parent's package when the parent books through exactly // one path, so a bundle's add-ons group under it. const expanded: ListingBooking[] = @@ -485,19 +467,23 @@ export const createFreeReservation = async ({ ledgerOrder !== null || modifierUsages.length > 0 ? await createBookingAtomic( input, - await bookingBatchPlan(modifierUsages, { - eventId: crypto.randomUUID(), - occurredAt: nowIso(), - pricedOrder: ledgerOrder ?? EMPTY_PRICED_ORDER, - }), + await bookingBatchPlan( + null, + modifierUsages, + { + eventId: crypto.randomUUID(), + occurredAt: nowIso(), + pricedOrder: ledgerOrder ?? EMPTY_PRICED_ORDER, + }, + null, + ), ) : await createAttendeeAtomic(input); if (result === "sold-out") { return { error: MODIFIER_SOLD_OUT_MESSAGE, success: false }; } - const check = await ensureAllBookings(result, finalBookings.length, "public"); - if (!check.ok) { + if (!result.success) { // A package order must never name a member in the capacity error — a hidden // package would leak the listing it concealed. Omit the name (generic // message) for a package; a non-package order keeps its first listing's name. @@ -505,15 +491,11 @@ export const createFreeReservation = async ({ ? "" : listingById.get(items[0]!.listingId)!.name; return { - error: formatAtomicError(check.reason, errorName), + error: formatAtomicError(result.reason, errorName), success: false, }; } - // ensureAllBookings's ok check guarantees result.success here. - const { attendees } = result as Extract< - CreateAttendeeResult, - { success: true } - >; + const { attendees } = result; const entries: EmailEntry[] = attendees.map((attendee) => ({ attendee, diff --git a/src/shared/booking-date-fields.ts b/src/shared/booking-date-fields.ts new file mode 100644 index 0000000000..47068a4030 --- /dev/null +++ b/src/shared/booking-date-fields.ts @@ -0,0 +1,22 @@ +import { capacityDateFor } from "#shared/capacity-rules.ts"; +import { type ListingWithCount, normalizeDurationDays } from "#shared/types.ts"; + +type BookingDateListing = { + customisable_days: boolean; + duration_days: number; + listing_type: ListingWithCount["listing_type"]; +}; + +/** Resolve the date and duration stored for one booking line. */ +export const bookingDateFields = ( + listing: BookingDateListing, + date: string | null, + dayCount: number | undefined, +): { date: string | null; durationDays: number } => ({ + date: capacityDateFor(listing.listing_type, date), + durationDays: listing.customisable_days + ? normalizeDurationDays(dayCount) + : listing.listing_type === "daily" + ? normalizeDurationDays(listing.duration_days) + : 1, +}); diff --git a/src/shared/booking-lines.ts b/src/shared/booking-lines.ts new file mode 100644 index 0000000000..29422dab57 --- /dev/null +++ b/src/shared/booking-lines.ts @@ -0,0 +1,56 @@ +import { + soleParentPackageIds, + stampChildRowPackages, +} from "#shared/booking/page-packages.ts"; +import { lineGroupId } from "#shared/booking/signed-metadata.ts"; +import { bookingDateFields } from "#shared/booking-date-fields.ts"; +import type { ListingBooking } from "#shared/db/attendee-types.ts"; +import { expandChildAllocations } from "#shared/db/attendees/order-parents.ts"; +import type { BookingIntent, BookingItem } from "#shared/payments.ts"; +import type { ListingWithCount } from "#shared/types.ts"; + +type BookingLine = { + item: BookingItem; + listing: ListingWithCount; + pricePaid?: number; +}; + +type BookingLineIntent = Pick< + BookingIntent, + "allocations" | "date" | "dayCount" | "items" +>; + +export type OrderBooking = ListingBooking & { + date: string | null; + durationDays: number; + quantity: number; +}; + +/** Build the final per-path booking rows shared by checkout staging and paid + * activation. Quantities stay desired here; staging maps them to zero last. */ +export const orderBookings = ( + lines: BookingLine[], + intent: BookingLineIntent, +): OrderBooking[] => { + const raw: OrderBooking[] = lines.map(({ item, listing, pricePaid }) => { + const dates = bookingDateFields(listing, intent.date, intent.dayCount); + return { + ...dates, + listingId: item.e, + packageGroupId: lineGroupId(item), + ...(pricePaid === undefined ? {} : { pricePaid }), + quantity: item.q, + }; + }); + return stampChildRowPackages( + intent.allocations && intent.allocations.length > 0 + ? expandChildAllocations(raw, intent.allocations) + : raw, + soleParentPackageIds( + intent.items.map((item) => ({ + listingId: item.e, + packageGroupId: lineGroupId(item), + })), + ), + ); +}; diff --git a/src/shared/booking.ts b/src/shared/booking.ts index b7b88c8780..ff9a3c332f 100644 --- a/src/shared/booking.ts +++ b/src/shared/booking.ts @@ -14,7 +14,8 @@ import { createAttendeeAtomic, hasAvailableSpots, } from "#shared/db/attendees/api.ts"; -import type { LedgerPoster } from "#shared/db/attendees/create.ts"; +import type { LedgerPoster } from "#shared/db/attendees/create-batch.ts"; +import { createStagedCheckout } from "#shared/db/checkout-stages.ts"; import { singleListingAnswerIds } from "#shared/payment-helpers.ts"; import { getActivePaymentProvider } from "#shared/payments.ts"; import type { Attendee, ContactInfo, ListingWithCount } from "#shared/types.ts"; @@ -88,7 +89,8 @@ export const processBooking = async ( const provider = (await getActivePaymentProvider())!; const unitPrice = customUnitPrice ?? listing.unit_price; - const result = await provider.createCheckoutSession( + const result = await createStagedCheckout( + provider, { ...contact, date, diff --git a/src/shared/checkout-complete.ts b/src/shared/checkout-complete.ts index e62fa801d6..bd0b4bf70f 100644 --- a/src/shared/checkout-complete.ts +++ b/src/shared/checkout-complete.ts @@ -20,7 +20,7 @@ import type { PricedOrder } from "#shared/checkout-pricing.ts"; import type { BookingBatchPlan, LedgerPoster, -} from "#shared/db/attendees/create.ts"; +} from "#shared/db/attendees/create-batch.ts"; import { type TxScope, update } from "#shared/db/client.ts"; import type { ModifierUsage } from "#shared/db/modifier-usage.ts"; import { nowIso } from "#shared/now.ts"; @@ -40,14 +40,15 @@ const BATCH_LEG_ATTENDEE_PLACEHOLDER = 1; * the same batch. The paid path keys `eventId` on its payment session id, so the * legs are attendee-id-independent and can be built before the attendee exists. */ export const bookingBatchPlan = async ( + attendeeId: number | null, usages: ModifierUsage[], ledger: { pricedOrder: PricedOrder; occurredAt: string; eventId: string }, - finalize?: { paymentReference: string; sessionId: string }, + finalize: { paymentReference: string; sessionId: string } | null, ): Promise => ({ - ...(finalize !== undefined ? { finalize } : {}), + finalize, legs: await mapBooking( bookingFactsFromOrder(ledger.pricedOrder, { - attendeeId: BATCH_LEG_ATTENDEE_PLACEHOLDER, + attendeeId: attendeeId ?? BATCH_LEG_ATTENDEE_PLACEHOLDER, eventId: ledger.eventId, occurredAt: ledger.occurredAt, }), diff --git a/src/shared/db/attendee-types.ts b/src/shared/db/attendee-types.ts index 436211cd85..b9c5fe588e 100644 --- a/src/shared/db/attendee-types.ts +++ b/src/shared/db/attendee-types.ts @@ -38,7 +38,6 @@ export type EncryptedAttendeeData = { /** Input for encrypting attendee fields */ export type EncryptInput = ContactInfo & { paymentId: string; - pricePaid: number; }; /** Input for building an Attendee result from an insert */ @@ -114,6 +113,9 @@ export type AttendeeInput = ContactFields & { * checkout path can never be silently left uncounted; the admin manual-add * paths pass "admin" explicitly. */ source?: BookingSource; + /** Exact ticket token to encrypt and index. Paid recovery prepares this once; + * other create paths omit it and receive a fresh token. */ + ticketToken?: string; }; /** Row from listing_attendees — per-listing booking data */ diff --git a/src/shared/db/attendees/activate.ts b/src/shared/db/attendees/activate.ts new file mode 100644 index 0000000000..ef82853e6c --- /dev/null +++ b/src/shared/db/attendees/activate.ts @@ -0,0 +1,166 @@ +import type { InValue } from "@libsql/client"; +import { assertPostable } from "#shared/accounting/store.ts"; +import type { OrderBooking } from "#shared/booking-lines.ts"; +import { postBookingLegsTx } from "#shared/checkout-complete.ts"; +import type { AttendeeInput } from "#shared/db/attendee-types.ts"; +import { + lineKeyFromBooking, + loadExistingLines, +} from "#shared/db/attendees/atomic-update.ts"; +import { bookingSlotKey } from "#shared/db/attendees/booking-slot.ts"; +import { + bookingStartAt, + dateToStartEnd, +} from "#shared/db/attendees/capacity.ts"; +import type { FinalizedBookingBatchPlan } from "#shared/db/attendees/create-batch.ts"; +import { + attendeeEncryptionInput, + encryptAttendeeFields, +} from "#shared/db/attendees/pii.ts"; +import { buildCapacityCondition } from "#shared/db/capacity.ts"; +import { type SqlStatement, withTransaction } from "#shared/db/client.ts"; +import { + allModifiersInStockCondition, + anyModifierSoldOut, + usageInsert, +} from "#shared/db/modifier-usage.ts"; +import { batchFinalizeStatement } from "#shared/db/payment-finalize.ts"; + +export type ActivationFailure = "capacity_exceeded" | "sold-out"; + +class ActivationRefused extends Error {} + +const expectedLineKey = (booking: OrderBooking): string => + bookingSlotKey( + booking.listingId, + bookingStartAt(booking), + booking.parentListingId, + booking.packageGroupId, + ); + +const assertStageMatches = async ( + attendeeId: number, + bookings: OrderBooking[], +): Promise => { + const existing = await loadExistingLines(attendeeId); + const expected = bookings.map(expectedLineKey).toSorted(); + const actual = existing + .map(({ booking }) => { + if (booking.quantity !== 0) { + throw new Error(`Checkout stage ${attendeeId} is already active`); + } + return lineKeyFromBooking(booking); + }) + .toSorted(); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`Checkout stage ${attendeeId} booking lines changed`); + } +}; + +const activationStatement = ( + attendeeId: number, + booking: OrderBooking, + modifierCondition: SqlStatement, +): SqlStatement => { + const date = booking.date; + const durationDays = booking.durationDays; + const parentListingId = + booking.parentListingId === undefined ? 0 : booking.parentListingId; + const packageGroupId = + booking.packageGroupId === undefined ? 0 : booking.packageGroupId; + const { startAt, endAt } = dateToStartEnd(date, durationDays); + const capacity = buildCapacityCondition( + booking.listingId, + booking.quantity, + date, + undefined, + durationDays, + ); + const args: InValue[] = [ + booking.quantity, + startAt, + endAt, + attendeeId, + booking.listingId, + startAt, + parentListingId, + packageGroupId, + ...capacity.args, + ...modifierCondition.args, + ]; + return { + args, + sql: `UPDATE listing_attendees + SET quantity = ?, start_at = ?, end_at = ? + WHERE attendee_id = ? AND listing_id = ? AND start_at IS ? + AND parent_listing_id = ? AND package_group_id = ? + AND (${capacity.sql}) AND (${modifierCondition.sql})`, + }; +}; + +/** Claim every staged booking row and complete its money state in one + * transaction. A missed capacity or modifier guard rolls everything back. */ +export const activateStagedBooking = async ( + sessionId: string, + attendeeId: number, + ticketToken: string, + input: Omit & { + bookings: OrderBooking[]; + paymentId: string; + }, + plan: FinalizedBookingBatchPlan, +): Promise< + { success: true } | { reason: ActivationFailure; success: false } +> => { + await assertStageMatches(attendeeId, input.bookings); + const encryptionInput = attendeeEncryptionInput(input, input.paymentId); + const enc = await encryptAttendeeFields(encryptionInput, ticketToken); + if (!enc) throw new Error("Could not encrypt staged attendee"); + const finalize = plan.finalize; + assertPostable(plan.legs); + const modifierCondition = allModifiersInStockCondition(plan.usages); + + try { + await withTransaction(async (tx) => { + await tx.execute({ + args: [enc.encryptedPiiBlob, attendeeId], + sql: "UPDATE attendees SET pii_blob = ? WHERE id = ?", + }); + for (const booking of input.bookings) { + const result = await tx.execute( + activationStatement(attendeeId, booking, modifierCondition), + ); + if (result.rowsAffected !== 1) throw new ActivationRefused(); + } + for (const usage of plan.usages) { + await tx.execute( + usageInsert(usage, "?", [attendeeId], { args: [], sql: "1 = 1" }), + ); + } + await postBookingLegsTx(tx, attendeeId, plan.legs); + const finalized = await tx.execute( + await batchFinalizeStatement( + sessionId, + "?", + attendeeId, + { args: [], sql: "1 = 1" }, + finalize.paymentReference, + ticketToken, + ), + ); + if (finalized.rowsAffected !== 1) { + throw new Error(`Payment session ${sessionId} was not finalized`); + } + await tx.execute({ + args: [sessionId], + sql: "UPDATE checkout_stages SET state = 'booked' WHERE payment_session_id = ?", + }); + }); + return { success: true }; + } catch (error) { + if (!(error instanceof ActivationRefused)) throw error; + return (await anyModifierSoldOut(plan.usages)) + ? { reason: "sold-out", success: false } + : { reason: "capacity_exceeded", success: false }; + } +}; diff --git a/src/shared/db/attendees/api.ts b/src/shared/db/attendees/api.ts index 639b36d55b..d9ed6fd924 100644 --- a/src/shared/db/attendees/api.ts +++ b/src/shared/db/attendees/api.ts @@ -48,7 +48,7 @@ export const createAttendeeAtomic = ( ): Promise => attendeesApi.createAttendeeAtomic(...args); /** Wrapper for test mocking - delegates to attendeesApi at runtime. Creates a - * booking and posts its ledger legs as one batch (the fast checkout path). */ + * booking and posts its ledger legs in one all-or-nothing batch. */ export const createBookingAtomic = ( ...args: Parameters ): ReturnType => diff --git a/src/shared/db/attendees/capacity.ts b/src/shared/db/attendees/capacity.ts index 7c07bc6511..deaab5adf0 100644 --- a/src/shared/db/attendees/capacity.ts +++ b/src/shared/db/attendees/capacity.ts @@ -45,6 +45,12 @@ export const dateToStartEnd = ( return { endAt: range.endAt, startAt: range.startAt }; }; +/** The stored start timestamp for a booking line. */ +export const bookingStartAt = ( + booking: Pick, +): string | null => + dateToStartEnd(booking.date ?? null, booking.durationDays ?? 1).startAt; + type RemainingMap = Map; /** Distinct group ids worth a cap lookup — positive only (0 = ungrouped). */ diff --git a/src/shared/db/attendees/create-batch.ts b/src/shared/db/attendees/create-batch.ts new file mode 100644 index 0000000000..814a15892e --- /dev/null +++ b/src/shared/db/attendees/create-batch.ts @@ -0,0 +1,234 @@ +import { bookingLegBatchInsert } from "#shared/accounting/rows.ts"; +import { assertPostable } from "#shared/accounting/store.ts"; +import type { EncryptedAttendeeData } from "#shared/db/attendee-types.ts"; +import { + executeBatchWithResults, + inPlaceholders, + type SqlStatement, + type TxScope, + withTransaction, +} from "#shared/db/client.ts"; +import { + allModifiersInStockCondition, + type ModifierUsage, + usageInsert, +} from "#shared/db/modifier-usage.ts"; +import { batchFinalizeStatement } from "#shared/db/payment-finalize.ts"; +import type { TransferInput } from "#shared/ledger/types.ts"; +import { nowIso } from "#shared/now.ts"; + +export type PreparedWrite = { + enc: EncryptedAttendeeData; + attendeeInsert: SqlStatement; + bookingStatements: SqlStatement[]; +}; + +export type WriteOutcome = { + insertId: number | bigint | undefined; +}; + +/** Posts ledger legs inside the attendee transaction when a caller cannot + * prepare the whole operation as a batch. */ +export type LedgerPoster = (tx: TxScope, attendeeId: number) => Promise; + +export type BookingBatchPlan = { + usages: ModifierUsage[]; + legs: TransferInput[]; + finalize: { paymentReference: string; sessionId: string } | null; +}; + +export type FinalizedBookingBatchPlan = Omit & { + finalize: { paymentReference: string; sessionId: string }; +}; + +/** The new attendee id inside a batch. last_insert_rowid() cannot be used after + * later inserts, while ticket_token_index uniquely identifies this row. */ +export const ATTENDEE_BY_TOKEN_SQL = + "(SELECT MAX(id) FROM attendees WHERE ticket_token_index = ?)"; + +/** Abort a whole libsql batch when the preceding guarded booking did not land. + * The deliberate NOT NULL failure rolls the transaction back; no compensating + * delete is needed and no partial attendee becomes visible. */ +const BOOKING_WRITE_GUARD: SqlStatement = { + args: [], + sql: `INSERT INTO listing_attendees (listing_id, attendee_id, quantity) + SELECT NULL, NULL, 1 WHERE changes() = 0`, +}; + +const MODIFIER_WRITE_GUARD: SqlStatement = { + args: [], + sql: `INSERT INTO modifier_usages + (modifier_id, attendee_id, quantity, amount_applied, created) + SELECT NULL, NULL, 1, 0, '' WHERE changes() = 0`, +}; + +const isBookingWriteGuard = (error: unknown): boolean => + error instanceof Error && + error.message.includes( + "NOT NULL constraint failed: listing_attendees.listing_id", + ); + +const guardedBookingStatements = ( + bookingStatements: SqlStatement[], +): SqlStatement[] => + bookingStatements.flatMap((statement) => [statement, BOOKING_WRITE_GUARD]); + +const runAtomicBatch = async ( + prepared: PreparedWrite, + leading: SqlStatement[] = [], + trailing: SqlStatement[] = [], +): Promise => { + try { + const results = await executeBatchWithResults([ + prepared.attendeeInsert, + ...leading, + ...guardedBookingStatements(prepared.bookingStatements), + ...trailing, + { + args: [prepared.enc.ticketTokenIndex], + sql: "SELECT id FROM attendees WHERE ticket_token_index = ?", + }, + ]); + const attendeeResult = results[results.length - 1]!; + return { + insertId: Number(attendeeResult.rows[0]!.id), + }; + } catch (error) { + if (isBookingWriteGuard(error)) return null; + throw error; + } +}; + +class IncompleteBooking extends Error {} + +/** Create an attendee and run a callback in one interactive transaction. */ +export const writeWithLedger = ( + prepared: PreparedWrite, + postLedger: LedgerPoster, +): Promise => + withTransaction(async (tx) => { + const insertId = (await tx.execute(prepared.attendeeInsert)) + .lastInsertRowid; + for (const statement of prepared.bookingStatements) { + if ((await tx.execute(statement)).rowsAffected === 0) { + throw new IncompleteBooking(); + } + } + await postLedger(tx, Number(insertId)); + return { insertId }; + }).catch((error) => { + if (error instanceof IncompleteBooking) return null; + throw error; + }); + +/** Create an attendee and all booking rows, rolling the transaction back when + * any requested row cannot land. */ +export const writeAsBatch = ( + prepared: PreparedWrite, +): Promise => runAtomicBatch(prepared); + +class ModifierStockFailure extends Error {} + +export const isModifierStockFailure = ( + error: unknown, +): error is ModifierStockFailure => error instanceof ModifierStockFailure; + +const andConditions = (conditions: SqlStatement[]): SqlStatement => ({ + args: conditions.flatMap((condition) => condition.args), + sql: conditions.map((condition) => `(${condition.sql})`).join(" AND "), +}); + +const noExistingLedgerCondition = (legs: TransferInput[]): SqlStatement => { + if (legs.length === 0) return { args: [], sql: "1 = 1" }; + const eventGroup = legs[0]!.eventGroup; + const references = legs.map((leg) => leg.reference); + return { + args: [eventGroup, ...references], + sql: `NOT EXISTS (SELECT 1 FROM transfers WHERE event_group = ?) + AND NOT EXISTS (SELECT 1 FROM transfers WHERE reference IN (${inPlaceholders( + references, + )}))`, + }; +}; + +export const bookingBatchCondition = (plan: BookingBatchPlan): SqlStatement => + andConditions([ + allModifiersInStockCondition(plan.usages), + noExistingLedgerCondition(plan.legs), + ]); + +/** Create the booking, consume modifier stock, post ledger legs, and optionally + * finalize its payment in one transaction. */ +export const writeAsLedgerBatch = async ( + prepared: PreparedWrite, + plan: BookingBatchPlan, +): Promise => { + assertPostable(plan.legs); + const recordedAt = nowIso(); + const tokenIndex = prepared.enc.ticketTokenIndex; + const always = { args: [], sql: "1 = 1" }; + const modifierCondition = allModifiersInStockCondition(plan.usages); + const modifierCheckStatements: SqlStatement[] = + plan.usages.length > 0 + ? [ + { + args: [tokenIndex, ...modifierCondition.args], + sql: `UPDATE attendees SET id = id + WHERE ticket_token_index = ? AND ${modifierCondition.sql}`, + }, + MODIFIER_WRITE_GUARD, + ] + : []; + const usageStatements = plan.usages.map((usage) => + usageInsert(usage, ATTENDEE_BY_TOKEN_SQL, [tokenIndex], always), + ); + const legStatements = plan.legs.map((leg) => + bookingLegBatchInsert( + leg, + recordedAt, + ATTENDEE_BY_TOKEN_SQL, + tokenIndex, + always, + ), + ); + const eventGroupStatements: SqlStatement[] = + plan.legs.length > 0 + ? [ + { + args: [plan.legs[0]!.eventGroup, tokenIndex], + sql: `UPDATE listing_attendees SET ledger_event_group = ? + WHERE attendee_id = ${ATTENDEE_BY_TOKEN_SQL}`, + }, + ] + : []; + const finalizeStatements: SqlStatement[] = plan.finalize + ? [ + await batchFinalizeStatement( + plan.finalize.sessionId, + ATTENDEE_BY_TOKEN_SQL, + tokenIndex, + always, + plan.finalize.paymentReference, + prepared.enc.ticketToken, + ), + ] + : []; + try { + return await runAtomicBatch(prepared, modifierCheckStatements, [ + ...usageStatements, + ...legStatements, + ...eventGroupStatements, + ...finalizeStatements, + ]); + } catch (error) { + if ( + error instanceof Error && + error.message.includes( + "NOT NULL constraint failed: modifier_usages.modifier_id", + ) + ) { + throw new ModifierStockFailure(); + } + throw error; + } +}; diff --git a/src/shared/db/attendees/create.ts b/src/shared/db/attendees/create.ts index 0c7330cd8d..dfbdd38211 100644 --- a/src/shared/db/attendees/create.ts +++ b/src/shared/db/attendees/create.ts @@ -3,8 +3,7 @@ */ import type { InValue } from "@libsql/client"; -import { bookingLegBatchInsert } from "#shared/accounting/rows.ts"; -import { assertPostable } from "#shared/accounting/store.ts"; +import { generateTicketToken } from "#shared/crypto/utils.ts"; import { addDays } from "#shared/dates.ts"; import type { AttendeeInput, @@ -14,82 +13,29 @@ import type { } from "#shared/db/attendee-types.ts"; import { hasDuplicateBookingSlot } from "#shared/db/attendees/booking-slot.ts"; import { buildCapacityCheckedInsert } from "#shared/db/attendees/capacity.ts"; -import { deleteAttendee } from "#shared/db/attendees/delete.ts"; +import { + ATTENDEE_BY_TOKEN_SQL, + type BookingBatchPlan, + bookingBatchCondition, + isModifierStockFailure, + type LedgerPoster, + type PreparedWrite, + type WriteOutcome, + writeAsBatch, + writeAsLedgerBatch, + writeWithLedger, +} from "#shared/db/attendees/create-batch.ts"; import { ATTENDEE_KIND } from "#shared/db/attendees/kind.ts"; import { annotateOrderParents } from "#shared/db/attendees/order-parents.ts"; import { + attendeeEncryptionInput, contactFields, encryptAttendeeFields, } from "#shared/db/attendees/pii.ts"; -import { - executeBatchWithResults, - inPlaceholders, - insert, - type SqlStatement, - type TxScope, - withTransaction, -} from "#shared/db/client.ts"; -import { - hashEmail, - hashPhone, - recordVisit, - unrecordVisit, -} from "#shared/db/contact-preferences.ts"; -import { - type BookingSource, - recordBooking, - unrecordBooking, -} from "#shared/db/contact-tokens.ts"; -import { - allModifiersInStockCondition, - anyModifierSoldOut, - type ModifierUsage, - usageInsert, -} from "#shared/db/modifier-usage.ts"; -import { batchFinalizeStatement } from "#shared/db/payment-finalize.ts"; -import type { TransferInput } from "#shared/ledger/types.ts"; -import { bestEffort } from "#shared/logger.ts"; -import { nowIso } from "#shared/now.ts"; +import { insert, type SqlStatement } from "#shared/db/client.ts"; +import { recordOrderActivity } from "#shared/db/contact-tokens.ts"; import { type Attendee, normalizeDurationDays } from "#shared/types.ts"; -/** - * Enforce all-or-nothing semantics on a (greedy) create result. - * - * `createAttendeeAtomic` fulfils bookings greedily: it returns success as - * long as at least one booking was created. Callers that need every - * requested line to succeed pass the expected count here; if the result is - * short, the partially-created attendee is rolled back and a failure reason - * is returned. Shared by the public checkout flow, the webhook flow, and the - * admin manual-add form so the "no half-saved attendee" rule lives in one - * place. - */ -export const ensureAllBookings = async ( - result: CreateAttendeeResult, - expectedCount: number, - source: BookingSource, -): Promise< - { ok: true } | { ok: false; reason: "capacity_exceeded" | "encryption_error" } -> => { - if (result.success && result.attendees.length >= expectedCount) { - return { ok: true }; - } - if (result.success && result.attendees.length > 0) { - const attendee = result.attendees[0]!; - await deleteAttendee(attendee.id); - // The greedy create already recorded a visit + booking for this contact; - // undo it now that the order is being rolled back. Best-effort: callers - // such as the paid webhook refund after this returns, so a contact-stats - // write failure must not escape here and skip the refund. - await bestEffort("reverseOrderActivity on partial rollback", () => - reverseOrderActivity(attendee.email, attendee.phone, source), - ); - } - return { - ok: false, - reason: result.success ? "capacity_exceeded" : result.reason, - }; -}; - /** Order-level fields shared by every booking in one atomic create. */ type AttendeeOrderFields = { kind?: string | undefined; @@ -144,189 +90,6 @@ const buildAttendeeResult = (input: BuildAttendeeInput): Attendee => ({ ticket_token_index: input.ticketTokenIndex, }); -/** Collect the contact-identity hashes for an order (email and/or phone). */ -const orderContactHashes = ( - email: unknown, - phone: unknown, -): Promise => { - const hashes: Promise[] = []; - if (typeof email === "string" && email.trim()) { - hashes.push(hashEmail(email)); - } - if (typeof phone === "string" && phone.trim()) { - hashes.push(hashPhone(phone)); - } - return Promise.all(hashes); -}; - -/** Run one per-contact effect against every contact identity on an order, so - * recording and its exact reverse share one iteration. */ -const forEachOrderContact = - (perContact: (hash: string) => Promise) => - async (email: unknown, phone: unknown): Promise => { - await Promise.all((await orderContactHashes(email, phone)).map(perContact)); - }; - -/** Record a visit + source-tagged booking (with the attendee's ticket token, so - * the contact's encrypted token list gains this booking) for every contact on - * an order. */ -const recordOrderActivity = ( - email: unknown, - phone: unknown, - source: BookingSource, - ticketToken: string, -): Promise => - forEachOrderContact(async (hash) => { - await recordVisit(hash); - await recordBooking(hash, source, ticketToken); - })(email, phone); - -/** Reverse {@link recordOrderActivity}'s counts when an order is rolled back - * after the greedy create already recorded it (partial booking, post-payment - * refund). The token entry is left for the read side to filter — the rolled-back - * attendee is deleted, so its token resolves to nothing. */ -export const reverseOrderActivity = ( - email: unknown, - phone: unknown, - source: BookingSource, -): Promise => - forEachOrderContact(async (hash) => { - await unrecordVisit(hash); - await unrecordBooking(hash, source); - })(email, phone); - -/** Per-booking success flags and the new attendee row id (always set in - * practice — an INSERT returns its rowid). */ -type WriteOutcome = { flags: boolean[]; insertId: number | bigint | undefined }; - -/** Posts the ledger legs for a created attendee inside the same transaction, so - * a booking and its legs commit or roll back together. The id is only known - * after the attendee insert, so it is passed in. */ -export type LedgerPoster = (tx: TxScope, attendeeId: number) => Promise; - -/** Thrown to roll the transaction back when no booking could be created (the - * ledger-posting path has no final cleanup DELETE; it just rolls back). */ -class NoBookingsCreated extends Error {} - -/** Remove the just-inserted attendee when none of its capacity-checked booking - * inserts landed a row (the batch path's all-failed cleanup). */ -const cleanupDeleteStatement = (ticketTokenIndex: InValue): SqlStatement => ({ - args: [ticketTokenIndex, ticketTokenIndex], - sql: `DELETE FROM attendees WHERE id = ( - SELECT MAX(id) FROM attendees WHERE ticket_token_index = ? - ) AND NOT EXISTS ( - SELECT 1 FROM listing_attendees WHERE attendee_id = ( - SELECT MAX(id) FROM attendees WHERE ticket_token_index = ? - ) - )`, -}); - -/** - * Run one ACID batch whose statements are, in order: the attendee INSERT, the - * `bookingCount` capacity-checked booking INSERTs, then any number of follow-up - * statements (cleanup, and — for the ledger batch — modifier/leg/finalize). The - * per-booking landed flags come from results 1..bookingCount; null is returned - * when none landed (the attendee was cleaned up). The single place the attendee/ - * booking batch result decoding lives, shared by the plain and ledger batches. */ -const runAttendeeBatch = async ( - statements: SqlStatement[], - bookingCount: number, -): Promise => { - const batchResults = await executeBatchWithResults(statements); - const flags = Array.from( - { length: bookingCount }, - (_, i) => batchResults[i + 1]!.rowsAffected > 0, - ); - return flags.some(Boolean) - ? { flags, insertId: batchResults[0]!.lastInsertRowid } - : null; -}; - -/** The fast path: one ACID batch (attendee, bookings, all-failed cleanup). - * Returns null when no booking landed (the attendee was cleaned up). */ -const writeAsBatch = ( - attendeeInsert: SqlStatement, - bookingStatements: SqlStatement[], - ticketTokenIndex: InValue, -): Promise => - runAttendeeBatch( - [ - attendeeInsert, - ...bookingStatements, - cleanupDeleteStatement(ticketTokenIndex), - ], - bookingStatements.length, - ); - -/** The ledger path: an interactive transaction so the ledger legs commit - * atomically with the attendee and bookings. This path is all-or-nothing — - * the legs describe the whole order, so if any booking fails its capacity check - * the transaction rolls back and null is returned (the caller refunds), rather - * than posting legs for listings that were not booked. */ -const writeWithLedger = ( - attendeeInsert: SqlStatement, - bookingStatements: SqlStatement[], - postLedger: LedgerPoster, -): Promise => - withTransaction(async (tx) => { - const insertId = (await tx.execute(attendeeInsert)).lastInsertRowid; - const flags: boolean[] = []; - for (const statement of bookingStatements) { - flags.push((await tx.execute(statement)).rowsAffected > 0); - } - if (!flags.every(Boolean)) throw new NoBookingsCreated(); - await postLedger(tx, Number(insertId)); - return { flags, insertId }; - }).catch((error) => { - if (error instanceof NoBookingsCreated) return null; - throw error; - }); - -/** - * The attendee-id subquery used everywhere a freshly-inserted attendee's id must - * be referenced later in the SAME batch (its booking links, its ledger legs, the - * finalize). last_insert_rowid() can't be used — it shifts after each INSERT in - * the batch — and ticket_token_index is unique, so MAX(id) for that token is - * this attendee. The single `?` binds the token index. */ -export const ATTENDEE_BY_TOKEN_SQL = - "(SELECT MAX(id) FROM attendees WHERE ticket_token_index = ?)"; - -/** SQL gate that holds only once every one of the order's `expectedCount` - * bookings has landed, so the ledger legs / finalize apply on full success and - * are skipped on a partial booking (cleaned up afterwards). */ -const allBookingsLandedGuard = ( - ticketTokenIndex: InValue, - expectedCount: number, -): SqlStatement => ({ - args: [ticketTokenIndex, expectedCount], - sql: `(SELECT COUNT(*) FROM listing_attendees WHERE attendee_id = ${ATTENDEE_BY_TOKEN_SQL}) = ?`, -}); - -/** What a prepared write needs in hand before touching the database. */ -type PreparedWrite = { - enc: EncryptedAttendeeData; - attendeeInsert: SqlStatement; - bookingStatements: SqlStatement[]; -}; - -const andConditions = (conditions: SqlStatement[]): SqlStatement => ({ - args: conditions.flatMap((condition) => condition.args), - sql: conditions.map((condition) => `(${condition.sql})`).join(" AND "), -}); - -const noExistingLedgerCondition = (legs: TransferInput[]): SqlStatement => { - if (legs.length === 0) return { args: [], sql: "1 = 1" }; - const eventGroup = legs[0]!.eventGroup; - const references = legs.map((leg) => leg.reference); - return { - args: [eventGroup, ...references], - sql: `NOT EXISTS (SELECT 1 FROM transfers WHERE event_group = ?) - AND NOT EXISTS (SELECT 1 FROM transfers WHERE reference IN (${inPlaceholders( - references, - )}))`, - }; -}; - /** * Validate the order and encrypt the attendee, returning the attendee INSERT and * the capacity-checked booking INSERTs — or a failure reason. `extraCondition` is @@ -371,16 +134,10 @@ const prepareAttendeeWrite = async ( // packages, so there is no order-level value to apply here. const bookings = await annotateOrderParents(rawBookings); - // Use first booking's pricePaid for encryption (PII blob is shared) - const enc = await encryptAttendeeFields({ - address: input.address ?? "", - email: input.email, - name: input.name, - paymentId, - phone: input.phone ?? "", - pricePaid: bookings[0]!.pricePaid ?? 0, - special_instructions: input.special_instructions ?? "", - }); + const enc = await encryptAttendeeFields( + attendeeEncryptionInput({ ...input, bookings }, paymentId), + input.ticketToken ?? generateTicketToken(), + ); if (!enc) { return { failure: { reason: "encryption_error", success: false }, @@ -442,30 +199,26 @@ const finishAttendeeWrite = async ( phone: input.phone ?? "", special_instructions: input.special_instructions ?? "", }; - const successfulBookings: Attendee[] = bookings.flatMap((booking, i) => - written.flags[i] - ? [ - buildAttendeeResult({ - insertId: written.insertId, - listingId: booking.listingId, - ...contactInfo, - created: enc.created, - date: booking.date ?? null, - ...(booking.durationDays !== undefined - ? { durationDays: booking.durationDays } - : {}), - kind: input.kind ?? ATTENDEE_KIND, - packageGroupId: booking.packageGroupId ?? 0, - paymentId: input.paymentId ?? "", - pricePaid: booking.pricePaid ?? 0, - quantity: booking.quantity ?? 1, - remainingBalance: input.remainingBalance ?? 0, - statusId: input.statusId ?? null, - ticketToken: enc.ticketToken, - ticketTokenIndex: enc.ticketTokenIndex, - }), - ] - : [], + const successfulBookings: Attendee[] = bookings.map((booking) => + buildAttendeeResult({ + insertId: written.insertId, + listingId: booking.listingId, + ...contactInfo, + created: enc.created, + date: booking.date ?? null, + ...(booking.durationDays !== undefined + ? { durationDays: booking.durationDays } + : {}), + kind: input.kind ?? ATTENDEE_KIND, + packageGroupId: booking.packageGroupId ?? 0, + paymentId: input.paymentId ?? "", + pricePaid: booking.pricePaid ?? 0, + quantity: booking.quantity ?? 1, + remainingBalance: input.remainingBalance ?? 0, + statusId: input.statusId ?? null, + ticketToken: enc.ticketToken, + ticketTokenIndex: enc.ticketTokenIndex, + }), ); if (successfulBookings.some((b) => b.quantity > 0)) { await recordOrderActivity( @@ -505,11 +258,16 @@ const createWith = : strategy.noBooking(); }; +const capacityFailure = (): CreateAttendeeResult => ({ + reason: "capacity_exceeded", + success: false, +}); + /** * Atomically create an attendee linked to one or more listings. * 1. INSERT attendee (unconditional) * 2..N+1. For each booking: INSERT listing_attendees with capacity check - * 3. Clean up / roll back the attendee if ALL capacity checks failed + * 3. Abort and roll back the whole batch if any capacity check fails * Returns one Attendee per successful booking. When `postLedger` is given, the * write runs in one interactive transaction and the ledger legs are posted in * it, so the booking and its legs are all-or-nothing. @@ -519,126 +277,35 @@ export const createAttendeeAtomicImpl = ( postLedger?: LedgerPoster, ): Promise => createWith({ - noBooking: () => ({ reason: "capacity_exceeded", success: false }), - // Ledger path: an interactive transaction so the legs commit with the - // attendee/bookings (all-or-nothing). Plain path: one batch with an - // all-failed cleanup DELETE. - write: ({ attendeeInsert, bookingStatements, enc }) => + noBooking: capacityFailure, + write: (prepared) => postLedger - ? writeWithLedger(attendeeInsert, bookingStatements, postLedger) - : writeAsBatch(attendeeInsert, bookingStatements, enc.ticketTokenIndex), + ? writeWithLedger(prepared, postLedger) + : writeAsBatch(prepared), })(input); /** - * The ledger work to commit atomically with a booking, as DATA rather than a - * transaction callback — so the whole booking can be one libsql batch instead of - * a chatty interactive transaction. `legs` are the booking's ledger legs (built - * by mapBooking with a placeholder attendee id; their references/event group are - * attendee-id-independent, and the real id is spliced in by subquery at write - * time). `finalize`, when set, finalizes that payment session in the same batch - * as the attendee INSERT. */ -export type BookingBatchPlan = { - usages: ModifierUsage[]; - legs: TransferInput[]; - finalize?: { paymentReference: string; sessionId: string }; -}; - -/** - * Assemble and run the single batch for a booking that posts ledger legs: - * attendee INSERT, capacity- AND modifier-stock-guarded booking INSERTs, then — - * each gated on every booking having landed — the modifier-usage consumes, the - * `INSERT OR IGNORE` legs, the ledger_event_group stamp, the optional finalize, - * and finally the all-failed cleanup DELETE. One round-trip, one transaction: - * commits the whole booking or, when a booking can't land, leaves nothing the - * caller's all-or-nothing check won't clean up. Returns the flags + new id, or - * null when no booking landed. */ -const writeAsLedgerBatch = async ( - prepared: PreparedWrite, - plan: BookingBatchPlan, - expectedCount: number, -): Promise => { - const { attendeeInsert, bookingStatements, enc } = prepared; - const tokenIndex = enc.ticketTokenIndex; - const guard = allBookingsLandedGuard(tokenIndex, expectedCount); - - assertPostable(plan.legs); - const recordedAt = nowIso(); - const usageStatements = plan.usages.map((usage) => - usageInsert(usage, ATTENDEE_BY_TOKEN_SQL, [tokenIndex], guard), - ); - const legStatements = plan.legs.map((leg) => - bookingLegBatchInsert(leg, recordedAt, ATTENDEE_BY_TOKEN_SQL, tokenIndex, { - args: guard.args, - sql: guard.sql, - }), - ); - // Stamp the order's event group onto the booking rows so each row's amount-paid - // projection resolves exactly this booking's legs — only once all bookings landed. - const eventGroupUpdate: SqlStatement[] = - plan.legs.length > 0 - ? [ - { - args: [plan.legs[0]!.eventGroup, tokenIndex, ...guard.args], - sql: `UPDATE listing_attendees SET ledger_event_group = ? - WHERE attendee_id = ${ATTENDEE_BY_TOKEN_SQL} AND ${guard.sql}`, - }, - ] - : []; - const finalizeStatements: SqlStatement[] = plan.finalize - ? [ - await batchFinalizeStatement( - plan.finalize.sessionId, - ATTENDEE_BY_TOKEN_SQL, - tokenIndex, - guard, - plan.finalize.paymentReference, - ), - ] - : []; - - return runAttendeeBatch( - [ - attendeeInsert, - ...bookingStatements, - ...usageStatements, - ...legStatements, - ...eventGroupUpdate, - ...finalizeStatements, - cleanupDeleteStatement(tokenIndex), - ], - bookingStatements.length, - ); -}; - -/** - * Create a booking and post its ledger legs as ONE libsql batch — the fast path - * that replaces the interactive transaction for the paid/free checkout. The + * Create a booking and post its ledger legs as ONE libsql batch. The * booking, its modifier-stock consumes, its sale/payment legs, the booking-row * event-group stamp, and (when finalizing a paid session) the session finalize - * all commit or roll back together, in a single round-trip that never holds an - * interactive write transaction open against the primary. + * all commit or roll back together. Each booking insert is followed by a guard + * that aborts the batch on a miss, so no compensating deletes are needed. * * Returns `"sold-out"` when a chosen modifier had no stock left (the * stock-guarded booking insert lands no row), so the caller keeps a placeholder - * and refunds; otherwise the usual create result (a partial cart is the caller's - * all-or-nothing concern, via ensureAllBookings). */ -export const createBookingAtomic = ( + * and refunds; otherwise the usual all-or-nothing create result. */ +export const createBookingAtomic = async ( input: AttendeeInput, plan: BookingBatchPlan, -): Promise => - createWith({ - condition: andConditions([ - allModifiersInStockCondition(plan.usages), - noExistingLedgerCondition(plan.legs), - ]), - // No booking landed: tell capacity-full from a sold-out modifier so the - // caller shows the right reason (and keeps the right placeholder). - noBooking: async () => - (await anyModifierSoldOut(plan.usages)) - ? "sold-out" - : { reason: "capacity_exceeded", success: false }, - // expectedCount === one booking statement per booking, so it equals the - // prepared booking-statement count. - write: (prepared) => - writeAsLedgerBatch(prepared, plan, prepared.bookingStatements.length), - })(input); +): Promise => { + try { + return await createWith({ + condition: bookingBatchCondition(plan), + noBooking: capacityFailure, + write: (prepared) => writeAsLedgerBatch(prepared, plan), + })(input); + } catch (error) { + if (isModifierStockFailure(error)) return "sold-out"; + throw error; + } +}; diff --git a/src/shared/db/attendees/delete.ts b/src/shared/db/attendees/delete.ts index af7f2d6c5f..0758907a54 100644 --- a/src/shared/db/attendees/delete.ts +++ b/src/shared/db/attendees/delete.ts @@ -53,6 +53,7 @@ const restoreListingContributions = ( * links it to the attendee. Deleted (in this order) before the attendee row. */ const DEPENDENT_ROW_TARGETS = [ { field: "attendee_id", table: "processed_payments" }, + { field: "attendee_id", table: "checkout_stages" }, { field: "attendee_id", table: "attendee_answers" }, { field: "attendee_id", table: "listing_attendees" }, { field: "attendee_id", table: "system_notes" }, diff --git a/src/shared/db/attendees/order-parents.ts b/src/shared/db/attendees/order-parents.ts index f1028c2c9a..6ccbc80f90 100644 --- a/src/shared/db/attendees/order-parents.ts +++ b/src/shared/db/attendees/order-parents.ts @@ -111,11 +111,11 @@ const splitPricePaid = ( * (carrying that parent), plus — for any units the allocations don't cover — a * single parent-less remainder row. A booking with no allocation stays one * standalone row. `pricePaid` is split across the rows by {@link splitPricePaid}. */ -const expandBooking = ( - booking: ListingBooking, +const expandBooking = ( + booking: T, childAllocs: readonly { parentId: number; qty: number }[] | undefined, orderToken: string, -): ListingBooking[] => { +): T[] => { if (!childAllocs) return [{ ...booking, orderToken }]; const totalQty = booking.quantity ?? 1; const allocatedQty = childAllocs.reduce((sum, a) => sum + a.qty, 0); @@ -154,10 +154,10 @@ const expandBooking = ( * parent for each unit. Used by both the free path and the paid webhook path, * which thread the allocation through the round-trip. */ -export const expandChildAllocations = ( - bookings: ListingBooking[], +export const expandChildAllocations = ( + bookings: T[], allocations: ChildAllocation[], -): ListingBooking[] => { +): T[] => { const orderToken = crypto.randomUUID(); const allocByChild = new Map(); for (const alloc of allocations) { diff --git a/src/shared/db/attendees/pii.ts b/src/shared/db/attendees/pii.ts index ddb4df8813..144ce5e6ee 100644 --- a/src/shared/db/attendees/pii.ts +++ b/src/shared/db/attendees/pii.ts @@ -14,8 +14,8 @@ import { encryptWithOwnerKey, } from "#shared/crypto/keys.ts"; import type { OwnerKeyEncrypted } from "#shared/crypto/sealed.ts"; -import { generateTicketToken } from "#shared/crypto/utils.ts"; import type { + AttendeeInput, AttendeePii, EncryptedAttendeeData, EncryptInput, @@ -29,6 +29,19 @@ import type { Attendee, ContactInfo, PiiBlob } from "#shared/types.ts"; /** Current PII blob schema version */ export const PII_BLOB_VERSION = 1; +/** Project one attendee write into the fields stored in its encrypted PII. */ +export const attendeeEncryptionInput = ( + input: AttendeeInput, + paymentId: string, +): EncryptInput => ({ + address: input.address ?? "", + email: input.email, + name: input.name, + paymentId, + phone: input.phone ?? "", + special_instructions: input.special_instructions ?? "", +}); + /** Build a PII blob JSON from contact fields. An unpinned latitude/longitude * ("") is left out of the JSON so blobs without a pin stay as small as before. */ export const buildPiiBlob = (info: AttendeePii): string => @@ -126,11 +139,11 @@ export const contactFields = ({ /** Encrypt attendee fields into a PII blob, returning null if key not configured */ export const encryptAttendeeFields = async ( input: EncryptInput, + ticketToken: string, ): Promise => { const publicKeyJwk = settings.publicKey; if (!publicKeyJwk) return null; - const ticketToken = generateTicketToken(); // Bookings never carry a pinned location — lat/lng are admin-side only. const piiJson = buildPiiBlob({ ...contactFields(input), diff --git a/src/shared/db/attendees/servicing.ts b/src/shared/db/attendees/servicing.ts index 6439925e37..f5a961aeb5 100644 --- a/src/shared/db/attendees/servicing.ts +++ b/src/shared/db/attendees/servicing.ts @@ -19,11 +19,9 @@ import { type ExistingLine, loadExistingLines, } from "#shared/db/attendees/atomic-update.ts"; -import { dateToStartEnd } from "#shared/db/attendees/capacity.ts"; -import { - createAttendeeAtomicImpl as createAttendeeAtomic, - ensureAllBookings, -} from "#shared/db/attendees/create.ts"; +import { bookingSlotKey } from "#shared/db/attendees/booking-slot.ts"; +import { bookingStartAt } from "#shared/db/attendees/capacity.ts"; +import { createAttendeeAtomicImpl as createAttendeeAtomic } from "#shared/db/attendees/create.ts"; import { deleteAttendee } from "#shared/db/attendees/delete.ts"; import { SERVICING_KIND } from "#shared/db/attendees/kind.ts"; import { @@ -159,47 +157,18 @@ const joinedListingNames = async (ids: number[]): Promise => { .join(", "); }; -/** The requested listing ids among `bookings` that did NOT land a booking row - * in create `result` — a multiset diff by listing id, so a listing requested - * twice and fulfilled only once is still named. When NOTHING landed at all - * (the create's own failure shape, `result.success === false` — e.g. a - * single-listing hold that didn't fit), every requested listing is named: - * there's no partial attendee to diff against, and for a single booking that - * IS the specific listing that failed. */ -const unfulfilledListingIds = ( - bookings: ListingBooking[], - result: CreateAttendeeResult, -): number[] => { - if (!result.success) return unique(bookings.map((b) => b.listingId)); - const remaining = new Map(); - for (const attendee of result.attendees) { - remaining.set( - attendee.listing_id, - (remaining.get(attendee.listing_id) ?? 0) + 1, - ); - } - const failed: number[] = []; - for (const booking of bookings) { - const have = remaining.get(booking.listingId) ?? 0; - if (have > 0) remaining.set(booking.listingId, have - 1); - else failed.push(booking.listingId); - } - return unique(failed); -}; - const ensureServicingCreateBookings = async ( result: CreateAttendeeResult, bookings: ListingBooking[], ): Promise> => { - const check = await ensureAllBookings(result, bookings.length, "admin"); - if (!check.ok) { + if (!result.success) { const names = - check.reason === "capacity_exceeded" - ? await joinedListingNames(unfulfilledListingIds(bookings, result)) + result.reason === "capacity_exceeded" + ? await joinedListingNames(unique(bookings.map((b) => b.listingId))) : ""; - throw new Error(formatServicingCapacityError(check.reason, names)); + throw new Error(formatServicingCapacityError(result.reason, names)); } - return result as Extract; + return result; }; const normalizedCreateInput = ( @@ -400,11 +369,10 @@ const lineKeyForInput = ( booking: ListingBooking, existingBySlot: Map, ): { exists: boolean; key: string } => { - const { startAt } = dateToStartEnd( - booking.date ?? null, - booking.durationDays ?? 1, - ); - const key = existingBySlot.get(`${booking.listingId}|${startAt ?? ""}`) ?? ""; + const key = + existingBySlot.get( + bookingSlotKey(booking.listingId, bookingStartAt(booking)), + ) ?? ""; return { exists: key !== "", key }; }; @@ -412,12 +380,7 @@ const desiredLines = ( input: ServicingEventInput, existing: Array<{ key: string; booking: ListingAttendeeRow }>, ): DesiredListingLine[] => { - const existingBySlot = new Map( - existing.map(({ key, booking }) => [ - `${booking.listing_id}|${booking.start_at ?? ""}`, - key, - ]), - ); + const existingBySlot = new Map(existing.map(({ key }) => [key, key])); return input.bookings.map((booking) => { const date = booking.date ?? null; const durationDays = normalizeDurationDays(booking.durationDays ?? 1); diff --git a/src/shared/db/checkout-stages.ts b/src/shared/db/checkout-stages.ts new file mode 100644 index 0000000000..d0fec2ff3b --- /dev/null +++ b/src/shared/db/checkout-stages.ts @@ -0,0 +1,218 @@ +import type { InValue } from "@libsql/client"; +import * as v from "valibot"; +import { unique } from "#fp"; +import { orderBookings } from "#shared/booking-lines.ts"; +import type { EnvKeyEncrypted } from "#shared/crypto/sealed.ts"; +import { generateTicketToken } from "#shared/crypto/utils.ts"; +import { getPublicStatusId } from "#shared/db/attendee-statuses.ts"; +import { createAttendeeAtomic } from "#shared/db/attendees/api.ts"; +import { + execute, + executeBatchWithResults, + insert, + queryOnePrimary, + type TxScope, +} from "#shared/db/client.ts"; +import { getListingsWithCountsByIds } from "#shared/db/listings.ts"; +import { + decryptSessionTokens, + encryptTicketTokens, +} from "#shared/db/processed-payments.ts"; +import { nowIso } from "#shared/now.ts"; +import { toBookingItems } from "#shared/payment-helpers.ts"; +import type { + CheckoutIntent, + PaymentProvider, + PaymentProviderType, +} from "#shared/payments.ts"; + +const CheckoutStageStateSchema = v.picklist(["pending", "booked", "failed"]); +export type CheckoutStageState = v.InferOutput; + +type CheckoutStageRow = { + attendee_id: number; + provider: PaymentProviderType; + state: CheckoutStageState; + ticket_tokens: EnvKeyEncrypted; +}; + +export type CheckoutStage = { + attendeeId: number; + provider: PaymentProviderType; + state: CheckoutStageState; + ticketToken: string; +}; + +const stagedBookings = async (intent: CheckoutIntent) => { + const items = toBookingItems(intent); + const listings = await getListingsWithCountsByIds( + unique(items.map((item) => item.e)), + ); + const listingById = new Map(listings.map((listing) => [listing.id, listing])); + const lines = items.map((item) => { + const listing = listingById.get(item.e); + if (!listing) throw new Error(`Listing ${item.e} vanished before checkout`); + return { item, listing }; + }); + return orderBookings(lines, { ...intent, items }).map((booking) => ({ + ...booking, + quantity: 0, + })); +}; + +const stageInsert = async ( + tx: TxScope, + sessionId: string, + attendeeId: number, + provider: PaymentProviderType, + ticketToken: string, +): Promise => { + await tx.execute( + insert("checkout_stages", { + attendee_id: attendeeId, + created_at: nowIso(), + payment_session_id: sessionId, + provider, + state: "pending", + ticket_tokens: await encryptTicketTokens([ticketToken]), + }), + ); +}; + +/** Save a fresh checkout as one attendee with exact quantity-zero path rows. */ +export const stageCheckout = async ( + sessionId: string, + provider: PaymentProviderType, + intent: CheckoutIntent, +): Promise => { + const ticketToken = generateTicketToken(); + const result = await createAttendeeAtomic( + { + address: intent.address, + bookings: await stagedBookings(intent), + email: intent.email, + name: intent.name, + phone: intent.phone, + source: "public", + special_instructions: intent.special_instructions, + statusId: await getPublicStatusId(), + ticketToken, + }, + (tx, attendeeId) => + stageInsert(tx, sessionId, attendeeId, provider, ticketToken), + ); + if (!result.success) { + throw new Error(`Could not stage checkout: ${result.reason}`); + } + return { + attendeeId: result.attendees[0]!.id, + provider, + state: "pending", + ticketToken, + }; +}; + +/** Create the provider session, then stage it before exposing the checkout URL. */ +export const createStagedCheckout = async ( + provider: PaymentProvider, + intent: CheckoutIntent, + baseUrl: string, +) => { + const result = await provider.createCheckoutSession(intent, baseUrl); + if (!result || "error" in result || intent.balanceAttendeeId !== undefined) { + return result; + } + await stageCheckout(result.sessionId, provider.type, intent); + return result; +}; + +export const getCheckoutStage = async ( + sessionId: string, +): Promise => { + const row = await queryOnePrimary( + `SELECT attendee_id, provider, state, ticket_tokens + FROM checkout_stages WHERE payment_session_id = ?`, + [sessionId], + ); + if (!row) return null; + const parsedState = v.parse(CheckoutStageStateSchema, row.state); + const ticketToken = await decryptSessionTokens(row.ticket_tokens); + if (!ticketToken) throw new Error(`Checkout stage ${sessionId} has no token`); + return { + attendeeId: row.attendee_id, + provider: row.provider, + state: parsedState, + ticketToken, + }; +}; + +export const markCheckoutStage = async ( + sessionId: string, + state: CheckoutStageState, +): Promise => { + await execute( + "UPDATE checkout_stages SET state = ? WHERE payment_session_id = ?", + [state, sessionId], + ); +}; + +const pendingStageAttendees = (where: string): string => + `SELECT stage.attendee_id + FROM checkout_stages AS stage + WHERE stage.state = 'pending' + AND ${where} + AND NOT EXISTS ( + SELECT 1 + FROM processed_payments AS payment + WHERE payment.payment_session_id = stage.payment_session_id + )`; + +/** Delete pending checkout PII only while no payment request has claimed it. */ +const discardPendingCheckoutsWhere = async ( + where: string, + args: InValue[], +): Promise => { + const attendeeIds = pendingStageAttendees(where); + const attendeeDelete = (table: string) => ({ + args, + sql: `DELETE FROM ${table} WHERE attendee_id IN (${attendeeIds})`, + }); + const results = await executeBatchWithResults([ + attendeeDelete("attendee_answers"), + attendeeDelete("listing_attendees"), + attendeeDelete("system_notes"), + { + args, + sql: `DELETE FROM attendees WHERE id IN (${attendeeIds})`, + }, + { + args, + sql: `DELETE FROM checkout_stages AS stage + WHERE stage.state = 'pending' + AND ${where} + AND NOT EXISTS ( + SELECT 1 + FROM processed_payments AS payment + WHERE payment.payment_session_id = stage.payment_session_id + )`, + }, + ]); + return results[results.length - 1]!.rowsAffected; +}; + +/** Discard one or more cancelled sessions through the same atomic path. */ +export const discardPendingCheckoutSessions = ( + sessionIds: string[], +): Promise => + sessionIds.length === 0 + ? Promise.resolve(0) + : discardPendingCheckoutsWhere( + `stage.payment_session_id IN (${sessionIds.map(() => "?").join(", ")})`, + sessionIds, + ); + +/** Remove abandoned pending checkouts older than the retention cutoff. */ +export const prunePendingCheckoutStages = ( + cutoffIso: string, +): Promise => + discardPendingCheckoutsWhere("stage.created_at < ?", [cutoffIso]); diff --git a/src/shared/db/contact-preferences.ts b/src/shared/db/contact-preferences.ts index 20f14bb2bc..ced9524b2f 100644 --- a/src/shared/db/contact-preferences.ts +++ b/src/shared/db/contact-preferences.ts @@ -98,20 +98,6 @@ export const getUnsubscribedHashSet = async (): Promise> => { return new Set(rows.map((r) => r.contact_hash)); }; -/** Record one visit against a contact, creating the row on first activity. */ -export const recordVisit = (hash: string): Promise => - run( - "INSERT INTO contact_preferences (contact_hash, last_activity, visits) VALUES (?, ?, 1) ON CONFLICT(contact_hash) DO UPDATE SET visits = visits + 1, last_activity = excluded.last_activity", - [hash, nowMs()], - ); - -/** Reverse one visit increment, clamped at zero. */ -export const unrecordVisit = (hash: string): Promise => - run( - "UPDATE contact_preferences SET visits = MAX(visits - 1, 0), last_activity = ? WHERE contact_hash = ?", - [nowMs(), hash], - ); - export const getVisits = async (hash: string): Promise => { const row = await queryOne<{ visits: number }>( "SELECT visits FROM contact_preferences WHERE contact_hash = ?", diff --git a/src/shared/db/contact-tokens.ts b/src/shared/db/contact-tokens.ts index e8b7679751..ac7bf04c5d 100644 --- a/src/shared/db/contact-tokens.ts +++ b/src/shared/db/contact-tokens.ts @@ -24,6 +24,11 @@ import { nowMs } from "#shared/now.ts"; * counted in its own plaintext column so the split survives without the owner * key. */ export type BookingSource = "admin" | "public"; +type BookingActivityWriter = ( + hash: string, + source: BookingSource, + ticketToken: string, +) => Promise; /** A booked ticket token linked to a contact, with the source that booked it. */ export type BookingToken = { source: BookingSource; token: string }; @@ -107,67 +112,80 @@ const parseTokenEntry = async ( return { source: TAG_SOURCE[decoded[0]!]!, token: decoded.slice(1) }; }; -/** The conflict update for appending one encrypted ticket token. */ -const tokenAppendUpdate = (column: BookingCountColumn | null): string => - [ - ...(column === null ? [] : [`${column} = ${column} + 1`]), - "last_activity = excluded.last_activity", - "attendee_tokens_blob = attendee_tokens_blob || excluded.attendee_tokens_blob", - ].join(",\n "); - -/** Append one encrypted ticket token, optionally bumping a source count too. */ -const appendBookingToken = async ( +/** Append a ticket token to a contact's encrypted list without touching counts. */ +const addBookingToken = async ( hash: string, - source: BookingSource, ticketToken: string, - column: BookingCountColumn | null, + source: BookingSource, ): Promise => { - const countColumn = column === null ? "" : `, ${column}`; - const countValue = column === null ? "" : ", 1"; await execute( - `INSERT INTO contact_preferences (contact_hash, last_activity${countColumn}, attendee_tokens_blob) - VALUES (?, ?${countValue}, ?) + `INSERT INTO contact_preferences (contact_hash, last_activity, attendee_tokens_blob) + VALUES (?, ?, ?) ON CONFLICT(contact_hash) DO UPDATE SET - ${tokenAppendUpdate(column)}`, + last_activity = excluded.last_activity, + attendee_tokens_blob = attendee_tokens_blob || excluded.attendee_tokens_blob`, [hash, nowMs(), await encryptTokenEntry(hash, source, ticketToken)], ); }; -/** - * Record one booking against a contact: bump its source's plaintext count and - * append the booked ticket token to the encrypted, append-only token list. - */ -export const recordBooking = async ( - hash: string, - source: BookingSource, - ticketToken: string, -): Promise => { - await appendBookingToken(hash, source, ticketToken, BOOKING_COLUMN[source]); -}; - -/** Reverse a {@link recordBooking}'s count, e.g. when an order is rolled back. - * The token entry is left in place: a rollback deletes the attendee, so its - * token resolves to nothing and is filtered out on read. */ -export const unrecordBooking = async ( - hash: string, - source: BookingSource, -): Promise => { +/** Record one visit and booking against a contact. The token marker guards the + * whole write, so retrying after a lost database result changes nothing. */ +export const recordBookingActivity: BookingActivityWriter = async ( + hash, + source, + ticketToken, +) => { const column = BOOKING_COLUMN[source]; + const marker = `${await tokenMarkerFor(hash, ticketToken)}${TOKEN_LINE_SEPARATOR}`; await execute( - `UPDATE contact_preferences SET ${column} = MAX(${column} - 1, 0), last_activity = ? WHERE contact_hash = ?`, - [nowMs(), hash], + `INSERT INTO contact_preferences + (contact_hash, last_activity, visits, ${column}, attendee_tokens_blob) + VALUES (?, ?, 1, 1, ?) + ON CONFLICT(contact_hash) DO UPDATE SET + visits = visits + 1, + ${column} = ${column} + 1, + last_activity = excluded.last_activity, + attendee_tokens_blob = attendee_tokens_blob || excluded.attendee_tokens_blob + WHERE INSTR(attendee_tokens_blob, ?) = 0`, + [hash, nowMs(), await encryptTokenEntry(hash, source, ticketToken), marker], ); }; -/** Append a ticket token to a contact's encrypted list without touching counts. */ -const addBookingToken = async ( - hash: string, - ticketToken: string, - source: BookingSource, -): Promise => { - await appendBookingToken(hash, source, ticketToken, null); +/** Collect the contact hashes for the non-empty email and phone on an order. */ +const orderContactHashes = ( + email: unknown, + phone: unknown, +): Promise => { + const contacts: [ContactChannel, string][] = []; + if (typeof email === "string" && email.trim()) { + contacts.push(["email", email]); + } + if (typeof phone === "string" && phone.trim()) { + contacts.push(["sms", phone]); + } + return Promise.all( + contacts.map(([channel, value]) => contactHash(channel, value)), + ); }; +/** Run one effect against every contact identity on an order. */ +const forEachOrderContact = + (run: (hash: string) => Promise) => + async (email: unknown, phone: unknown): Promise => { + await Promise.all((await orderContactHashes(email, phone)).map(run)); + }; + +/** Record one replay-safe visit and booking for every contact on an order. */ +export const recordOrderActivity = ( + email: unknown, + phone: unknown, + source: BookingSource, + ticketToken: string, +): Promise => + forEachOrderContact((hash) => + recordBookingActivity(hash, source, ticketToken), + )(email, phone); + /** Load a contact's encrypted token blob, or null when no row exists. */ const loadTokenBlob = async (hash: string): Promise => { const row = await queryOne<{ attendee_tokens_blob: string }>( diff --git a/src/shared/db/migrations/2026-07-12_checkout_stages.ts b/src/shared/db/migrations/2026-07-12_checkout_stages.ts new file mode 100644 index 0000000000..25b7dff069 --- /dev/null +++ b/src/shared/db/migrations/2026-07-12_checkout_stages.ts @@ -0,0 +1,10 @@ +import { schemaMigration } from "./define.ts"; + +export default schemaMigration( + "2026-07-12_checkout_stages", + "Add checkout_stages so paid orders are stored at quantity zero before the buyer leaves for the payment provider and the same attendee can later be booked or retained after a refund.", + { + indexes: ["idx_checkout_stages_attendee_id"], + newTables: ["checkout_stages"], + }, +); diff --git a/src/shared/db/migrations/registry.ts b/src/shared/db/migrations/registry.ts index 917353ed2f..83f320bf6d 100644 --- a/src/shared/db/migrations/registry.ts +++ b/src/shared/db/migrations/registry.ts @@ -329,6 +329,11 @@ export const MIGRATION_REGISTRY: MigrationRegistryEntry[] = [ "2026-07-10_processed_payments_attendee_index", () => import("./2026-07-10_processed_payments_attendee_index.ts"), ), + // Store fresh paid orders at quantity zero before leaving for the provider. + entry( + "2026-07-12_checkout_stages", + () => import("./2026-07-12_checkout_stages.ts"), + ), ]; /* jscpd:ignore-end */ diff --git a/src/shared/db/migrations/schema/tables-attendees.ts b/src/shared/db/migrations/schema/tables-attendees.ts index a28cbc5ade..f28e6cbcde 100644 --- a/src/shared/db/migrations/schema/tables-attendees.ts +++ b/src/shared/db/migrations/schema/tables-attendees.ts @@ -160,6 +160,27 @@ export const attendeeTables: [name: string, table: Table][] = [ }, ], + [ + "checkout_stages", + { + columns: [ + ["payment_session_id", "TEXT PRIMARY KEY"], + ["attendee_id", "INTEGER NOT NULL"], + ["provider", "TEXT NOT NULL"], + ["ticket_tokens", "TEXT NOT NULL"], + ["state", "TEXT NOT NULL"], + ["created_at", "TEXT NOT NULL"], + ], + indexes: [ + { + columns: ["attendee_id"], + name: "idx_checkout_stages_attendee_id", + unique: true, + }, + ], + }, + ], + [ "processed_payments", { diff --git a/src/shared/db/migrations/schema/version.ts b/src/shared/db/migrations/schema/version.ts index 688244f848..df07e69813 100644 --- a/src/shared/db/migrations/schema/version.ts +++ b/src/shared/db/migrations/schema/version.ts @@ -1,6 +1,6 @@ /** Schema version label and the migrations bookkeeping table name. */ export const LATEST_UPDATE = - "Index processed_payments by attendee for roster, export, and refund lookups."; + "Stage paid orders at quantity zero before payment."; export const SCHEMA_MIGRATIONS_TABLE = "schema_migrations"; diff --git a/src/shared/db/payment-finalize.ts b/src/shared/db/payment-finalize.ts index 5f3629ccd9..c9e99c3e34 100644 --- a/src/shared/db/payment-finalize.ts +++ b/src/shared/db/payment-finalize.ts @@ -2,7 +2,10 @@ import type { InValue } from "@libsql/client"; import { attendeeOwedSubquery } from "#shared/accounting/projection-sql.ts"; import type { SqlStatement } from "#shared/db/client.ts"; import { encryptPaymentReference } from "#shared/db/payment-references.ts"; -import { UNRESOLVED_RESERVATION } from "#shared/db/processed-payments.ts"; +import { + encryptTicketTokens, + UNRESOLVED_RESERVATION, +} from "#shared/db/processed-payments.ts"; const buildFinalizeStatement = async ( attendeeId: number, @@ -30,14 +33,16 @@ export const batchFinalizeStatement = async ( attendeeIdArg: InValue, guard: SqlStatement, paymentReference: string, + ticketToken: string, ): Promise => ({ args: [ attendeeIdArg, + await encryptTicketTokens([ticketToken]), await encryptPaymentReference(paymentReference), sessionId, ...guard.args, ], - sql: `UPDATE processed_payments SET attendee_id = ${attendeeIdSql}, ticket_tokens = '', payment_reference = ? + sql: `UPDATE processed_payments SET attendee_id = ${attendeeIdSql}, ticket_tokens = ?, payment_reference = ? WHERE payment_session_id = ? AND ${UNRESOLVED_RESERVATION} AND ${guard.sql}`, }); diff --git a/src/shared/db/processed-payments.ts b/src/shared/db/processed-payments.ts index 6d53224a86..693d37313a 100644 --- a/src/shared/db/processed-payments.ts +++ b/src/shared/db/processed-payments.ts @@ -4,9 +4,8 @@ * Uses a two-phase locking pattern to prevent duplicate attendee creation: * 1. reserveSession() - Claims the session with NULL attendee_id * 2. createBookingAtomic() with batchFinalizeStatement() inside the same batch - * - Creates the attendee and sets attendee_id atomically, closing the crash - * window between creation and a separate finalize call. - * 3. (webhook only) setSessionTicketTokens() - Persists replay tokens. + * - Creates the attendee and stores its id and token atomically, closing the + * crash window before a separate finalize call. * * If reserveSession fails (session already claimed), we check if it's: * - Finalized (attendee_id set) → return success with existing attendee @@ -196,30 +195,10 @@ export const reserveSession = async ( }; /** Encrypt a list of ticket tokens for storage, joining with "+". */ -const encryptTicketTokens = ( +export const encryptTicketTokens = ( ticketTokens: string[], ): Promise => encrypt(ticketTokens.join("+")); -/** - * Finalize a reserved session with the created attendee ID (second phase) - */ -export const finalizeSession = async ( - sessionId: string, - attendeeId: number, - ticketTokens: string[], - paymentReference: string, -): Promise => { - await execute( - "UPDATE processed_payments SET attendee_id = ?, ticket_tokens = ?, payment_reference = ? WHERE payment_session_id = ?", - [ - attendeeId, - await encryptTicketTokens(ticketTokens), - await encryptPaymentReference(paymentReference), - sessionId, - ], - ); -}; - /** * Heal a still-unresolved reservation by stamping `attendee_id`, leaving * `ticket_tokens` untouched. The ledger-replay path uses this: when a late @@ -238,7 +217,7 @@ export const finalizeSession = async ( export const finalizeSessionIfUnresolved = async ( sessionId: string, attendeeId: number, - paymentReference = "", + paymentReference: string, ): Promise => { const refClause = paymentReference ? ", payment_reference = ?" : ""; const refParams = paymentReference @@ -292,22 +271,6 @@ export const parseSessionFailure = async ( } }; -/** - * Store encrypted ticket tokens on an already-finalized session so later - * webhook replays can return them. Separated from batchFinalizeStatement so - * token encryption never holds the write lock open. No-op if the session was - * pruned. - */ -export const setSessionTicketTokens = async ( - sessionId: string, - ticketTokens: string[], -): Promise => { - await execute( - "UPDATE processed_payments SET ticket_tokens = ? WHERE payment_session_id = ?", - [await encryptTicketTokens(ticketTokens), sessionId], - ); -}; - /** * Decrypt the ticket_tokens field from a processed payment record. * Returns the plaintext token string (e.g. "tok1+tok2") or empty string. @@ -328,14 +291,3 @@ export const clearSessionTokens = async (sessionId: string): Promise => { "UPDATE processed_payments SET ticket_tokens = '' WHERE payment_session_id = ?", ); }; - -/** - * Get the attendee ID for an already-processed session - * Used to return success for idempotent webhook retries - */ -export const getProcessedAttendeeId = async ( - sessionId: string, -): Promise => { - const result = await isSessionProcessed(sessionId); - return result?.attendee_id ?? null; -}; diff --git a/src/shared/db/prune.ts b/src/shared/db/prune.ts index 673a703ba1..7ecc110c1a 100644 --- a/src/shared/db/prune.ts +++ b/src/shared/db/prune.ts @@ -34,6 +34,7 @@ * pruned only when PRUNE_INTERVAL_MS has elapsed since its last run. */ +import { prunePendingCheckoutStages } from "#shared/db/checkout-stages.ts"; import { execute } from "#shared/db/client.ts"; import { purgeOrphanedAttendees } from "#shared/db/orphan-attendees.ts"; import { writeRawBatch } from "#shared/db/settings/raw-writes.ts"; @@ -42,6 +43,7 @@ import { settings } from "#shared/db/settings.ts"; import { pruneExpiredInvites } from "#shared/db/users.ts"; import { ADDRESS_CACHE_MS, + PRUNE_CHECKOUT_STAGES_RETENTION_MS, PRUNE_CONTACTS_RETENTION_MS, PRUNE_INTERVAL_MS, PRUNE_LOGINS_RETENTION_MS, @@ -110,6 +112,12 @@ export const pruneSumupCheckouts = isoAgePruner( PRUNE_SUMUP_RETENTION_MS, ); +/** Delete old unpaid checkout attendees. A claimed payment row blocks cleanup. */ +export const pruneCheckoutStages = (): Promise => + prunePendingCheckoutStages( + new Date(nowMs() - PRUNE_CHECKOUT_STAGES_RETENTION_MS).toISOString(), + ); + /** Delete unreferenced encrypted free-text strings older than retention. */ export const pruneUnusedStrings = isoAgePruner( "DELETE FROM strings WHERE used_count = 0 AND created < ?", @@ -204,6 +212,13 @@ type PruneTask = { }; const PRUNE_TASKS = (): PruneTask[] => [ + { + field: "last_pruned_checkout_stages", + key: CONFIG_KEYS.LAST_PRUNED_CHECKOUT_STAGES, + lastRaw: settings.lastPrunedCheckoutStages, + name: "checkout_stages", + run: pruneCheckoutStages, + }, { field: "last_pruned_payments", key: CONFIG_KEYS.LAST_PRUNED_PAYMENTS, diff --git a/src/shared/limits.ts b/src/shared/limits.ts index 2d4ee5d912..fe9f306316 100644 --- a/src/shared/limits.ts +++ b/src/shared/limits.ts @@ -378,6 +378,15 @@ export const PRUNE_SUMUP_RETENTION_HOURS = limit( "hours", ); +/** Retention (days) for unpaid quantity-zero checkout stages (default: 7). + * Provider callbacks after cleanup use the normal no-stage booking path. */ +export const PRUNE_CHECKOUT_STAGES_RETENTION_DAYS = limit( + "PRUNE_CHECKOUT_STAGES_RETENTION_DAYS", + 7, + "Prune: pending checkout stage retention", + "days", +); + /** * Retention (days) for encrypted string rows that have not been attached to an * attendee answer (default: 7). These are usually abandoned paid checkouts: @@ -484,6 +493,8 @@ export const PRUNE_LOGINS_RETENTION_MS = PRUNE_LOGINS_RETENTION_DAYS * DAY_MS; export const PRUNE_TOKENS_RETENTION_MS = PRUNE_TOKENS_RETENTION_DAYS * DAY_MS; export const PRUNE_SUMUP_RETENTION_MS = PRUNE_SUMUP_RETENTION_HOURS * 60 * 60 * 1000; +export const PRUNE_CHECKOUT_STAGES_RETENTION_MS = + PRUNE_CHECKOUT_STAGES_RETENTION_DAYS * DAY_MS; export const PRUNE_UNUSED_STRINGS_RETENTION_MS = PRUNE_UNUSED_STRINGS_RETENTION_DAYS * DAY_MS; export const PRUNE_CONTACTS_RETENTION_MS = diff --git a/src/shared/seeds.ts b/src/shared/seeds.ts index df312ec403..b1783de7da 100644 --- a/src/shared/seeds.ts +++ b/src/shared/seeds.ts @@ -6,6 +6,7 @@ import { map, sum } from "#fp"; import { encrypt } from "#shared/crypto/encryption.ts"; import { hmacHash } from "#shared/crypto/hashing.ts"; +import { generateTicketToken } from "#shared/crypto/utils.ts"; import { buildAttendeeInsert } from "#shared/db/attendees/create.ts"; import { encryptAttendeeFields } from "#shared/db/attendees/pii.ts"; import { executeBatch, insert, queryAll, rawSql } from "#shared/db/client.ts"; @@ -138,15 +139,17 @@ const prepareAttendee = async ( const pricePaid = unitPrice * quantity; const paymentId = unitPrice > 0 ? `seed_${listingId}_${quantity}_${pricePaid}` : ""; - const enc = (await encryptAttendeeFields({ - address: randomChoice(DEMO_ADDRESSES), - email: randomChoice(DEMO_EMAILS), - name: randomChoice(DEMO_NAMES), - paymentId, - phone: randomChoice(DEMO_PHONES), - pricePaid, - special_instructions: randomChoice(DEMO_SPECIAL_INSTRUCTIONS), - }))!; + const enc = (await encryptAttendeeFields( + { + address: randomChoice(DEMO_ADDRESSES), + email: randomChoice(DEMO_EMAILS), + name: randomChoice(DEMO_NAMES), + paymentId, + phone: randomChoice(DEMO_PHONES), + special_instructions: randomChoice(DEMO_SPECIAL_INSTRUCTIONS), + }, + generateTicketToken(), + ))!; return [ buildAttendeeInsert(enc, { remainingBalance: 0, statusId: null }), diff --git a/src/shared/settings/keys.ts b/src/shared/settings/keys.ts index 1537fd9c31..a9b690f9f9 100644 --- a/src/shared/settings/keys.ts +++ b/src/shared/settings/keys.ts @@ -42,6 +42,7 @@ export const CONFIG_KEY_NAMES = [ "LAST_ACTIVITY_LOG_BACKFILL", "LAST_PRUNED_ADDRESSES", "LAST_PRUNED_CONTACTS", + "LAST_PRUNED_CHECKOUT_STAGES", "LAST_PRUNED_INVITES", "LAST_PRUNED_LOGINS", "LAST_PRUNED_ORPHANS", diff --git a/src/shared/settings/registry.ts b/src/shared/settings/registry.ts index 73ae27e537..abef686dfa 100644 --- a/src/shared/settings/registry.ts +++ b/src/shared/settings/registry.ts @@ -115,6 +115,12 @@ export const STRING_SETTING_DEFINITIONS = [ key: CONFIG_KEYS.LAST_PRUNED_SESSIONS, storage: "plaintext", }), + setting({ + accessor: { name: "lastPrunedCheckoutStages" }, + key: CONFIG_KEYS.LAST_PRUNED_CHECKOUT_STAGES, + storage: "plaintext", + tags: ["prune"], + }), setting({ accessor: { name: "lastPrunedSumup" }, key: CONFIG_KEYS.LAST_PRUNED_SUMUP, diff --git a/src/shared/types.ts b/src/shared/types.ts index fae389382b..a72e47e329 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -190,8 +190,8 @@ export const MAX_DURATION_DAYS = 90; * agree by construction. Idempotent, so applying it to an already-normalized * value (e.g. a column-clamped `listing.duration_days`) is a safe no-op. */ -export const normalizeDurationDays = (value: number): number => - Number.isFinite(value) +export const normalizeDurationDays = (value: number | undefined): number => + typeof value === "number" && Number.isFinite(value) ? Math.max(1, Math.min(MAX_DURATION_DAYS, Math.floor(value))) : 1; diff --git a/test/features/public/ticket-payment.test.ts b/test/features/public/ticket-payment.test.ts index bef6a581e6..a34d5c68dc 100644 --- a/test/features/public/ticket-payment.test.ts +++ b/test/features/public/ticket-payment.test.ts @@ -2,7 +2,6 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; import { parseQuantityValue } from "#routes/public/ticket-form.ts"; import { - bookingDateFields, computeSharedDates, createFreeReservation, foldSelectedChildren, @@ -19,20 +18,11 @@ import { buildTicketListing, type TicketListing, } from "#shared/booking/model.ts"; +import { bookingDateFields } from "#shared/booking-date-fields.ts"; import type { PricedLine, PricedOrder } from "#shared/checkout-pricing.ts"; import { addDays } from "#shared/dates.ts"; import { createAttendeeAtomic } from "#shared/db/attendees/api.ts"; -import { - ensureAllBookings, - reverseOrderActivity, -} from "#shared/db/attendees/create.ts"; import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; -import { getDb } from "#shared/db/client.ts"; -import { - getContactRecord, - getVisits, - hashEmail, -} from "#shared/db/contact-preferences.ts"; import { getListingWithCount } from "#shared/db/listings.ts"; import { modifiersTable } from "#shared/db/modifiers.ts"; import { FormParams } from "#shared/form-data.ts"; @@ -106,100 +96,6 @@ describeWithEnv("routes > public > ticket-payment", { db: true }, () => { }); }); - describe("ensureAllBookings", () => { - test("ok when every booking in the cart succeeded", async () => { - const e1 = await createTestListing({ maxAttendees: 10, name: "ok-a" }); - const e2 = await createTestListing({ maxAttendees: 10, name: "ok-b" }); - const result = await createAttendeeAtomic({ - bookings: [ - { listingId: e1.id, quantity: 1 }, - { listingId: e2.id, quantity: 1 }, - ], - email: contact.email, - name: contact.name, - }); - const check = await ensureAllBookings(result, 2, "public"); - expect(check.ok).toBe(true); - expect((await getAttendeesRaw(e1.id)).length).toBe(1); - expect((await getAttendeesRaw(e2.id)).length).toBe(1); - // A kept order leaves the recorded public booking in place. - const { getTestPrivateKey } = await import("#test-utils/crypto.ts"); - const record = await getContactRecord( - await hashEmail(contact.email), - await getTestPrivateKey(), - ); - expect(record.publicBookingCount).toBe(1); - }); - - test("rolls back a partially-fulfilled cart and reports capacity_exceeded", async () => { - // Group cap 3 forces the second line to fail; createAttendeeAtomic books - // the first greedily, leaving a partial attendee. ensureAllBookings must - // delete it so the customer is never left with half a cart. - const group = await createTestGroup({ - maxAttendees: 3, - name: "rollback", - slug: "rollback", - }); - const e1 = await createTestListing({ - groupId: group.id, - maxAttendees: 10, - name: "rollback-a", - }); - const e2 = await createTestListing({ - groupId: group.id, - maxAttendees: 10, - name: "rollback-b", - }); - const result = await createAttendeeAtomic({ - bookings: [ - { listingId: e1.id, quantity: 2 }, - { listingId: e2.id, quantity: 2 }, - ], - email: contact.email, - name: contact.name, - }); - // Sanity: the atomic layer fulfilled only the first line. - expect(result.success).toBe(true); - if (result.success) expect(result.attendees.length).toBe(1); - - const check = await ensureAllBookings(result, 2, "public"); - expect(check.ok).toBe(false); - if (!check.ok) expect(check.reason).toBe("capacity_exceeded"); - // Full rollback: even the first line's row is gone. - expect((await getAttendeesRaw(e1.id)).length).toBe(0); - expect((await getAttendeesRaw(e2.id)).length).toBe(0); - // ...and the visit + booking the greedy create recorded are undone, so a - // rolled-back order leaves no phantom history on the contact. - const emailHash = await hashEmail(contact.email); - expect(await getVisits(emailHash)).toBe(0); - const { getTestPrivateKey } = await import("#test-utils/crypto.ts"); - const record = await getContactRecord( - emailHash, - await getTestPrivateKey(), - ); - expect(record.publicBookingCount).toBe(0); - }); - - test("propagates the failure reason when the whole cart failed", async () => { - const failure = { - reason: "encryption_error" as const, - success: false as const, - }; - const check = await ensureAllBookings(failure, 1, "public"); - expect(check).toEqual({ ok: false, reason: "encryption_error" }); - }); - - test("reverseOrderActivity is a no-op for a contact with no email or phone", async () => { - // An order with neither identity yields no contact hashes, so the - // compensation loop never runs and nothing is written or thrown. - await reverseOrderActivity("", "", "public"); - const { rows } = await getDb().execute( - "SELECT COUNT(*) AS c FROM contact_preferences", - ); - expect(Number(rows[0]!.c)).toBe(0); - }); - }); - describe("createFreeReservation (all-or-nothing)", () => { test("rejects the whole cart and persists nothing when a group cap is partially exceeded", async () => { const group = await createTestGroup({ diff --git a/test/lib/code-quality.test.ts b/test/lib/code-quality.test.ts index 76adab8e99..4fe27856bb 100644 --- a/test/lib/code-quality.test.ts +++ b/test/lib/code-quality.test.ts @@ -213,11 +213,6 @@ const ALLOWED_TEST_HOOKS: string[] = [ "shared/square.ts:resetSquareClient", // Test helper for creating signed Square webhook payloads "shared/square.ts:constructTestWebhookEvent", - // Convenience wrapper for idempotency checks (production uses isSessionProcessed directly) - "shared/db/processed-payments.ts:getProcessedAttendeeId", - // Test setup helper for creating finalized sessions; production now uses - // finalizeSessionStatement inside the attendee-creation transaction instead. - "shared/db/processed-payments.ts:finalizeSession", // Raw attendee fetch for testing encrypted data (production uses batched getListingWithAttendeesRaw) "shared/db/attendees/queries.ts:getAttendeesRaw", // Single attendee fetch for tests (production uses batched getListingWithAttendeeRaw) diff --git a/test/lib/db/attendees/availability-consistency.test.ts b/test/lib/db/attendees/availability-consistency.test.ts index 44a19f9ffc..10bcec0ab4 100644 --- a/test/lib/db/attendees/availability-consistency.test.ts +++ b/test/lib/db/attendees/availability-consistency.test.ts @@ -8,7 +8,6 @@ import { checkBatchAvailability, createAttendeeAtomic, } from "#shared/db/attendees/api.ts"; -import { queryAll } from "#shared/db/client.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { bookAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; import { createTestGroup } from "#test-utils/db-helpers/groups.ts"; @@ -25,12 +24,8 @@ import { * capacity rules; this test pins them together so a future change to one that * diverges from the other fails CI rather than silently over/under-offering. * - * The oracle is the real write: run the cart through `createAttendeeAtomic` - * and check whether EVERY line landed (the unpaid batch path commits whatever - * fits and reports success on a partial cart, so "all lines committed" — not - * the success flag — is the fit question the preflight answers). The - * fully-booked outcome must equal the preflight verdict for the same cart - * against the same pre-write state. + * The oracle is the real all-or-nothing write. Its result must equal the + * preflight verdict for the same cart against the same pre-write state. */ describeWithEnv( "db > attendees > availability preflight matches the write", @@ -53,18 +48,7 @@ describeWithEnv( email: "x@example.com", name: "X", }); - // "Fully booked" — every cart line landed a row — is the fit question. - // The unpaid batch path commits whatever fits and still reports success - // on a partial cart, so the success flag alone would over-report. - let fullyBooked = false; - if (write.success) { - const rows = await queryAll<{ c: number }>( - "SELECT COUNT(*) AS c FROM listing_attendees WHERE attendee_id = ?", - [write.attendees[0]!.id], - ); - fullyBooked = rows[0]!.c === bookings.length; - } - expect(fullyBooked).toBe(preflight); + expect(write.success).toBe(preflight); return preflight; }; diff --git a/test/lib/db/attendees/create-attendee-atomic.test.ts b/test/lib/db/attendees/create-attendee-atomic.test.ts index 5bd9c53e12..23bbf8c5b4 100644 --- a/test/lib/db/attendees/create-attendee-atomic.test.ts +++ b/test/lib/db/attendees/create-attendee-atomic.test.ts @@ -43,6 +43,20 @@ const expectCartRows = async ( } }; +const expectCartRejected = async ( + result: Awaited>, + listingIds: number[], +): Promise => { + expect(result).toEqual({ reason: "capacity_exceeded", success: false }); + for (const listingId of listingIds) { + const rows = await getDb().execute({ + args: [listingId], + sql: "SELECT quantity FROM listing_attendees WHERE listing_id = ?", + }); + expect(rows.rows).toEqual([]); + } +}; + const setupBookedOutListing = async () => { const listing = await createTestListing({ maxAttendees: 1 }); await updateListingAggregateValues(listing.id, { @@ -527,10 +541,8 @@ describeWithEnv("db > attendees > createAttendeeAtomic", { db: true }, () => { test("intra-cart group cap: a sibling insert earlier in the same batch counts (no oversell)", async () => { // Two listings share a group capped at 3. A single cart asks for 2 + 2 = 4. // The second INSERT's capacity check must see the first INSERT from the - // same atomic batch, so it is refused — booking the first line (2) and - // declining the second rather than overselling the group to 4. The - // all-or-nothing policy lives one layer up (ensureAllBookings); this layer - // fulfils greedily but must never exceed the cap. + // same atomic batch. The missed second row aborts the batch, so neither the + // attendee nor the first booking ever commits. const group = await createTestGroup({ maxAttendees: 3, name: "cart-accum", @@ -554,10 +566,7 @@ describeWithEnv("db > attendees > createAttendeeAtomic", { db: true }, () => { email: "cart@example.com", name: "Cart", }); - expect(result.success).toBe(true); - if (result.success) expect(result.attendees.length).toBe(1); - expect((await getAttendeesRaw(e1.id))[0]!.quantity).toBe(2); - expect((await getAttendeesRaw(e2.id)).length).toBe(0); + await expectCartRejected(result, [e1.id, e2.id]); }); test("intra-cart group cap: a cart that exactly fills the group across listings succeeds", async () => { @@ -614,11 +623,8 @@ describeWithEnv("db > attendees > createAttendeeAtomic", { db: true }, () => { email: "daily-cart@example.com", name: "DailyCart", }); - // 2 + 2 = 4 on the same date > cap 3: first fits, second refused. - expect(result.success).toBe(true); - if (result.success) expect(result.attendees.length).toBe(1); - expect((await getAttendeesRaw(e1.id)).length).toBe(1); - expect((await getAttendeesRaw(e2.id)).length).toBe(0); + // 2 + 2 = 4 on the same date > cap 3: the whole order is refused. + await expectCartRejected(result, [e1.id, e2.id]); }); test("intra-cart daily group cap is independent across different dates", async () => { diff --git a/test/lib/db/attendees/delete-attendee.test.ts b/test/lib/db/attendees/delete-attendee.test.ts index 91355f670c..f06cd6e7ca 100644 --- a/test/lib/db/attendees/delete-attendee.test.ts +++ b/test/lib/db/attendees/delete-attendee.test.ts @@ -2,6 +2,7 @@ import { expect } from "@std/expect"; import { it as test } from "@std/testing/bdd"; import { deleteAttendee } from "#shared/db/attendees/delete.ts"; import { getAttendee } from "#shared/db/attendees/queries.ts"; +import { getCheckoutStage } from "#shared/db/checkout-stages.ts"; import { getDb, queryOne } from "#shared/db/client.ts"; import { getListingWithCount, @@ -10,7 +11,6 @@ import { import { modifierUsedQuantities } from "#shared/db/modifier-usage.ts"; import { getAllModifiers, modifiersTable } from "#shared/db/modifiers.ts"; import { - finalizeSession as finalizePaymentSession, isSessionProcessed, reserveSession, } from "#shared/db/processed-payments.ts"; @@ -20,6 +20,10 @@ import { describeWithEnv } from "#test-utils/db.ts"; import { createPaidTestAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; import { createTestAttendee } from "#test-utils/db-helpers/attendees.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { + finalizeTestPaymentSession as finalizePaymentSession, + stageTestCheckout, +} from "#test-utils/db-helpers/processed-payments.ts"; import { consumeModifierStock } from "#test-utils/modifiers.ts"; describeWithEnv("db > attendees > deleteAttendee", { db: true }, () => { @@ -68,6 +72,16 @@ describeWithEnv("db > attendees > deleteAttendee", { db: true }, () => { expect(processed).toBeNull(); }); + test("removes checkout stage records", async () => { + const listing = await createTestListing({ maxAttendees: 50 }); + const sessionId = "sess_stage_attendee_delete"; + const stage = await stageTestCheckout(sessionId, listing); + + await deleteAttendee(stage.attendeeId); + + expect(await getCheckoutStage(sessionId)).toBeNull(); + }); + test("releases listing aggregate totals by default", async () => { const listing = await createTestListing({ maxAttendees: 50 }); const attendee = await createPaidTestAttendee( diff --git a/test/lib/db/migration-schema-guard.test.ts b/test/lib/db/migration-schema-guard.test.ts index 6e908a2493..e8e985e66a 100644 --- a/test/lib/db/migration-schema-guard.test.ts +++ b/test/lib/db/migration-schema-guard.test.ts @@ -83,8 +83,9 @@ describe("db > migrations > schema change guard", () => { "2026-07-07_contact_attendee_tokens", "2026-07-09_listing_attributes", "2026-07-10_processed_payments_attendee_index", + "2026-07-12_checkout_stages", ], - schemaHash: "n2axyb", + schemaHash: "17f1vns", }); }); }); diff --git a/test/lib/processed-payments/locking.test.ts b/test/lib/processed-payments/locking.test.ts index feaffd60db..f8654b1f48 100644 --- a/test/lib/processed-payments/locking.test.ts +++ b/test/lib/processed-payments/locking.test.ts @@ -3,9 +3,6 @@ import { describe, it as test } from "@std/testing/bdd"; import { getDb, insert } from "#shared/db/client.ts"; import { clearSessionTokens, - decryptSessionTokens, - finalizeSession, - getProcessedAttendeeId, isSessionProcessed, reserveSession, STALE_RESERVATION_MS, @@ -14,6 +11,7 @@ import { describeWithEnv } from "#test-utils/db.ts"; import { useProcessedPaymentsAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; import { createTestAttendee } from "#test-utils/db-helpers/attendees.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { finalizeTestPaymentSession as finalizeSession } from "#test-utils/db-helpers/processed-payments.ts"; /** Perform the full two-phase reserve+finalize as production code does */ const processSession = async ( @@ -126,37 +124,6 @@ describeWithEnv("processed-payments / locking", { db: true }, () => { }); }); - describe("finalizeSession", () => { - test("sets attendee_id on reserved session", async () => { - await reserveSession("cs_to_finalize"); - await finalizeSession( - "cs_to_finalize", - ctx.attendeeId, - ["tok-test"], - "pi_cs_to_finalize", - ); - - const record = await isSessionProcessed("cs_to_finalize"); - expect(record?.attendee_id).toBe(ctx.attendeeId); - }); - - test("stores ticket tokens encrypted when provided", async () => { - await reserveSession("cs_with_tokens"); - await finalizeSession( - "cs_with_tokens", - ctx.attendeeId, - ["tok_abc", "tok_def"], - "pi_cs_with_tokens", - ); - - const record = await isSessionProcessed("cs_with_tokens"); - expect(record?.ticket_tokens).toMatch(/^enc:1:/); - expect(await decryptSessionTokens(record!.ticket_tokens)).toBe( - "tok_abc+tok_def", - ); - }); - }); - describe("clearSessionTokens", () => { test("clears stored tokens while preserving attendee_id", async () => { await reserveSession("cs_clear_test"); @@ -189,30 +156,6 @@ describeWithEnv("processed-payments / locking", { db: true }, () => { }); }); - describe("getProcessedAttendeeId", () => { - test("returns null for unprocessed session", async () => { - expect(await getProcessedAttendeeId("cs_never_processed")).toBeNull(); - }); - - test("returns null for reserved-but-not-finalized session", async () => { - await reserveSession("cs_reserved_only"); - expect(await getProcessedAttendeeId("cs_reserved_only")).toBeNull(); - }); - - test("returns attendee ID after finalization", async () => { - await reserveSession("cs_finalized_attendee"); - await finalizeSession( - "cs_finalized_attendee", - ctx.attendeeId, - ["tok-test"], - "pi_cs_finalized_attendee", - ); - expect(await getProcessedAttendeeId("cs_finalized_attendee")).toBe( - ctx.attendeeId, - ); - }); - }); - describe("idempotency", () => { test("concurrent processing attempts only create one record", async () => { const listing = await createTestListing(); diff --git a/test/lib/server-attendee-refresh-payment.test.ts b/test/lib/server-attendee-refresh-payment.test.ts index 9a13c48acc..e69ff6be6a 100644 --- a/test/lib/server-attendee-refresh-payment.test.ts +++ b/test/lib/server-attendee-refresh-payment.test.ts @@ -6,16 +6,14 @@ import { mapBooking } from "#shared/accounting/mappers.ts"; import { postTransfers } from "#shared/accounting/store.ts"; import { balanceEventGroup } from "#shared/db/attendees/balance.ts"; import { execute } from "#shared/db/client.ts"; -import { - finalizeSession, - reserveSession, -} from "#shared/db/processed-payments.ts"; +import { reserveSession } from "#shared/db/processed-payments.ts"; import type { Attendee } from "#shared/types.ts"; import { expectErrorFlash, expectFlash } from "#test-utils/assertions.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { createPaidAttendeeWithoutLedger } from "#test-utils/db-helpers/attendee-payments.ts"; import { bookTestAttendee } from "#test-utils/db-helpers/attendees.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { finalizeTestPaymentSession as finalizeSession } from "#test-utils/db-helpers/processed-payments.ts"; import { withRefreshPaymentProbe } from "#test-utils/refund-routes.ts"; import { adminFormPost } from "#test-utils/session.ts"; diff --git a/test/lib/server-attendees/delete-incomplete.test.ts b/test/lib/server-attendees/delete-incomplete.test.ts index 4a59b7a408..3e37f63340 100644 --- a/test/lib/server-attendees/delete-incomplete.test.ts +++ b/test/lib/server-attendees/delete-incomplete.test.ts @@ -2,10 +2,7 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; import { getListingWithCount } from "#shared/db/listings.ts"; -import { - finalizeSession, - reserveSession, -} from "#shared/db/processed-payments.ts"; +import { reserveSession } from "#shared/db/processed-payments.ts"; import { expectFlashRedirect, testRequiresAuth, @@ -16,6 +13,7 @@ import { createTestAttendee, getAttendeesRaw, } from "#test-utils/db-helpers/attendees.ts"; +import { finalizeTestPaymentSession as finalizeSession } from "#test-utils/db-helpers/processed-payments.ts"; import { setupListingAndLogin } from "#test-utils/session.ts"; // jscpd:ignore-end diff --git a/test/lib/server-balance-payment-replay.test.ts b/test/lib/server-balance-payment-replay.test.ts index 8f715d9e9b..f1943f19a6 100644 --- a/test/lib/server-balance-payment-replay.test.ts +++ b/test/lib/server-balance-payment-replay.test.ts @@ -95,5 +95,8 @@ describeWithEnv("server (balance payment replay)", { db: true }, () => { expect(references.map((reference) => reference.reference)).toContain( `pi_${sessionId}`, ); + expect( + references.filter(({ reference }) => reference === `pi_${sessionId}`), + ).toHaveLength(1); }); }); diff --git a/test/lib/server-balance-webhook.test.ts b/test/lib/server-balance-webhook.test.ts index 0f187dceda..5f19d444a2 100644 --- a/test/lib/server-balance-webhook.test.ts +++ b/test/lib/server-balance-webhook.test.ts @@ -9,6 +9,7 @@ import { } from "#shared/accounting/queries.ts"; import { getAttendeeBalanceState } from "#shared/db/attendees/balance.ts"; import { execute } from "#shared/db/client.ts"; +import { isSessionProcessed } from "#shared/db/processed-payments.ts"; import { prunePayments } from "#shared/db/prune.ts"; import { resetStripeClient, stripeApi } from "#shared/stripe.ts"; import { describeWithEnv } from "#test-utils/db.ts"; @@ -104,6 +105,9 @@ describeWithEnv("server (public balance page) > webhook", { db: true }, () => { expect( (await getAttendeeBalanceState(attendeeId))?.remainingBalance, ).toBe(0); + expect((await isSessionProcessed("cs_balance_replay"))?.attendee_id).toBe( + attendeeId, + ); } finally { second.restore(); refund.restore(); diff --git a/test/lib/server-bulk-email/notes-and-history.test.ts b/test/lib/server-bulk-email/notes-and-history.test.ts index daee949f3e..aa5251991c 100644 --- a/test/lib/server-bulk-email/notes-and-history.test.ts +++ b/test/lib/server-bulk-email/notes-and-history.test.ts @@ -10,7 +10,7 @@ import { saveContactRecord, toContactHashParam, } from "#shared/db/contact-preferences.ts"; -import { recordBooking } from "#shared/db/contact-tokens.ts"; +import { recordBookingActivity } from "#shared/db/contact-tokens.ts"; import { settings } from "#shared/db/settings.ts"; import { expectHtmlResponse, expectRedirect } from "#test-utils/assertions.ts"; import { getTestPrivateKey } from "#test-utils/crypto.ts"; @@ -163,8 +163,8 @@ describeWithEnv("server bulk email > notes and history", { db: true }, () => { // ...and we seed split booking counts plus a private markdown note on each // contact record (preserving the counts already recorded for the email). - await recordBooking(emailHash, "public", "tok-bulk-pub"); - await recordBooking(emailHash, "admin", "tok-bulk-adm"); + await recordBookingActivity(emailHash, "public", "tok-bulk-pub"); + await recordBookingActivity(emailHash, "admin", "tok-bulk-adm"); await saveContactRecord(emailHash, { ...(await getContactRecord(emailHash, pk)), adminNotes: "**Email VIP** customer", diff --git a/test/lib/server-payment-staging.test.ts b/test/lib/server-payment-staging.test.ts new file mode 100644 index 0000000000..bf323f7b7a --- /dev/null +++ b/test/lib/server-payment-staging.test.ts @@ -0,0 +1,377 @@ +import { expect } from "@std/expect"; +import { afterEach, it as test } from "@std/testing/bdd"; +import { stub } from "@std/testing/mock"; +import { handleRequest } from "#routes"; +import { priceCheckout } from "#shared/checkout-pricing.ts"; +import { decryptAttendeeFields } from "#shared/db/attendees/pii.ts"; +import { getAttendeeRaw } from "#shared/db/attendees/queries.ts"; +import { getDb } from "#shared/db/client.ts"; +import { modifiersTable } from "#shared/db/modifiers.ts"; +import { settings } from "#shared/db/settings.ts"; +import { assembleCheckoutMetadata } from "#shared/payment-helpers.ts"; +import { CONFIG_KEYS } from "#shared/settings/keys.ts"; +import { resetStripeClient, stripeApi } from "#shared/stripe.ts"; +import { expectRedirect } from "#test-utils/assertions.ts"; +import { stubCheckout } from "#test-utils/checkout.ts"; +import { getTestPrivateKey } from "#test-utils/crypto.ts"; +import { submitTicketForm } from "#test-utils/csrf.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; +import { bookAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; +import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { mockRequest } from "#test-utils/mocks.ts"; +import { setupStripe } from "#test-utils/settings.ts"; +import { stubRetrieveCheckoutSession } from "#test-utils/webhooks.ts"; + +const paidReturn = async ( + sessionId: string, + intent: Parameters[1], + total: number, +): Promise => { + using _retrieve = stubRetrieveCheckoutSession({ + amountTotal: total, + metadata: await assembleCheckoutMetadata("stripe", intent, total), + paymentIntent: `pi_${sessionId}`, + sessionId, + }); + return handleRequest(mockRequest(`/payment/success?session_id=${sessionId}`)); +}; + +const stubSuccessfulRefund = (refundId: string) => + stub(stripeApi, "refundPayment", () => + Promise.resolve({ id: refundId } as unknown as Awaited< + ReturnType + >), + ); + +const expectStage = async ( + sessionId: string, + state: string, + quantity: number, +): Promise => { + const result = await getDb().execute( + `SELECT stage.state, booking.quantity + FROM checkout_stages AS stage + JOIN listing_attendees AS booking + ON booking.attendee_id = stage.attendee_id + WHERE stage.payment_session_id = ?`, + [sessionId], + ); + expect(result.rows.map((row) => [row.state, row.quantity])).toEqual([ + [state, quantity], + ]); +}; + +describeWithEnv("paid checkout staging", { db: true }, () => { + afterEach(() => resetStripeClient()); + + test("claims the staged order only after Stripe confirms payment", async () => { + await setupStripe(); + const listing = await createTestListing({ + maxAttendees: 2, + maxQuantity: 2, + unitPrice: 1000, + }); + const { checkout, getCaptured } = stubCheckout("cs_staged_order"); + + try { + const response = await submitTicketForm(listing.slug, { + [`quantity_${listing.id}`]: "2", + email: "stage@example.com", + name: "Stage Buyer", + }); + expectRedirect(response, "https://stripe.example/checkout"); + + const rows = await getDb().execute({ + args: ["cs_staged_order"], + sql: `SELECT stage.attendee_id, booking.quantity + FROM checkout_stages AS stage + JOIN listing_attendees AS booking + ON booking.attendee_id = stage.attendee_id + WHERE stage.payment_session_id = ?`, + }); + expect(rows.rows.length).toBe(1); + expect(Number(rows.rows[0]!.quantity)).toBe(0); + const stagedAttendeeId = Number(rows.rows[0]!.attendee_id); + + const intent = getCaptured(); + if (!intent) throw new Error("Expected captured checkout intent"); + const retrieve = stubRetrieveCheckoutSession({ + amountTotal: 2000, + metadata: await assembleCheckoutMetadata("stripe", intent, 2000), + paymentIntent: "pi_staged_order", + sessionId: "cs_staged_order", + }); + try { + const paid = await handleRequest( + mockRequest("/payment/success?session_id=cs_staged_order"), + ); + expectRedirect(paid, /^\/payment\/success\?tokens=.+$/); + + const activated = await getDb().execute({ + args: ["cs_staged_order"], + sql: `SELECT stage.attendee_id, stage.state, booking.quantity + FROM checkout_stages AS stage + JOIN listing_attendees AS booking + ON booking.attendee_id = stage.attendee_id + WHERE stage.payment_session_id = ?`, + }); + expect( + activated.rows.map((row) => [ + row.attendee_id, + row.state, + row.quantity, + ]), + ).toEqual([[stagedAttendeeId, "booked", 2]]); + } finally { + retrieve.restore(); + } + } finally { + checkout.restore(); + } + }); + + test("keeps the same quantity-zero order when capacity is gone after payment", async () => { + await setupStripe(); + const listing = await createTestListing({ + maxAttendees: 1, + unitPrice: 1000, + }); + const { checkout, getCaptured } = stubCheckout("cs_staged_full"); + const refund = stubSuccessfulRefund("re_staged_full"); + + try { + await submitTicketForm(listing.slug, { + [`quantity_${listing.id}`]: "1", + email: "late@example.com", + name: "Late Buyer", + }); + const staged = await getDb().execute( + "SELECT attendee_id FROM checkout_stages WHERE payment_session_id = ?", + ["cs_staged_full"], + ); + const stagedAttendeeId = Number(staged.rows[0]!.attendee_id); + const filler = await bookAttendee(listing, { + email: "filler@example.com", + name: "Filler", + }); + if (!filler.success) throw new Error("Expected filler booking"); + + const intent = getCaptured(); + if (!intent) throw new Error("Expected captured checkout intent"); + const retrieve = stubRetrieveCheckoutSession({ + amountTotal: 1000, + metadata: await assembleCheckoutMetadata("stripe", intent, 1000), + paymentIntent: "pi_staged_full", + sessionId: "cs_staged_full", + }); + try { + const response = await handleRequest( + mockRequest("/payment/success?session_id=cs_staged_full"), + ); + expect(await response.text()).toContain("automatically refunded"); + + const retained = await getDb().execute({ + args: ["cs_staged_full"], + sql: `SELECT stage.attendee_id, stage.state, booking.quantity + FROM checkout_stages AS stage + JOIN listing_attendees AS booking + ON booking.attendee_id = stage.attendee_id + WHERE stage.payment_session_id = ?`, + }); + expect( + retained.rows.map((row) => [ + row.attendee_id, + row.state, + row.quantity, + ]), + ).toEqual([[stagedAttendeeId, "failed", 0]]); + const failedAttendee = await getAttendeeRaw(stagedAttendeeId); + if (!failedAttendee) throw new Error("Expected failed staged attendee"); + expect( + ( + await decryptAttendeeFields( + failedAttendee, + await getTestPrivateKey(), + true, + ) + ).payment_id, + ).toBe("pi_staged_full"); + expect(refund.calls.length).toBe(1); + } finally { + retrieve.restore(); + } + } finally { + checkout.restore(); + refund.restore(); + } + }); + + test("keeps the staged order at zero when an extra sells out", async () => { + await setupStripe(); + const listing = await createTestListing({ + maxAttendees: 2, + unitPrice: 1000, + }); + const modifier = await modifiersTable.insert({ + calcKind: "fixed", + calcValue: 1, + direction: "charge", + name: "Limited extra", + stock: 1, + }); + const { checkout, getCaptured } = stubCheckout("cs_staged_extra"); + const refund = stubSuccessfulRefund("re_staged_extra"); + + try { + await submitTicketForm(listing.slug, { + [`quantity_${listing.id}`]: "1", + email: "extra@example.com", + name: "Extra Buyer", + }); + await getDb().execute({ + args: [modifier.id], + sql: `INSERT INTO modifier_usages + (modifier_id, attendee_id, quantity, amount_applied, created) + VALUES (?, 999999, 1, 100, '2026-07-12T00:00:00.000Z')`, + }); + const intent = getCaptured(); + if (!intent) throw new Error("Expected captured checkout intent"); + const total = priceCheckout(intent).total; + const retrieve = stubRetrieveCheckoutSession({ + amountTotal: total, + metadata: await assembleCheckoutMetadata("stripe", intent, total), + paymentIntent: "pi_staged_extra", + sessionId: "cs_staged_extra", + }); + try { + const response = await handleRequest( + mockRequest("/payment/success?session_id=cs_staged_extra"), + ); + expect(await response.text()).toContain("automatically refunded"); + await expectStage("cs_staged_extra", "failed", 0); + expect(refund.calls.length).toBe(1); + } finally { + retrieve.restore(); + } + } finally { + checkout.restore(); + refund.restore(); + } + }); + + test("fails loudly when a staged row was activated outside payment", async () => { + await setupStripe(); + const listing = await createTestListing({ unitPrice: 1000 }); + const { checkout, getCaptured } = stubCheckout("cs_staged_active"); + + try { + await submitTicketForm(listing.slug, { + [`quantity_${listing.id}`]: "1", + email: "active@example.com", + name: "Active Buyer", + }); + await getDb().execute( + `UPDATE listing_attendees SET quantity = 1 + WHERE attendee_id = (SELECT attendee_id FROM checkout_stages + WHERE payment_session_id = ?)`, + ["cs_staged_active"], + ); + const intent = getCaptured(); + if (!intent) throw new Error("Expected captured checkout intent"); + + const response = await paidReturn("cs_staged_active", intent, 1000); + expect(response.status).toBe(400); + await expectStage("cs_staged_active", "pending", 1); + } finally { + checkout.restore(); + } + }); + + test("fails loudly when the staged booking paths changed", async () => { + await setupStripe(); + const listing = await createTestListing({ unitPrice: 1000 }); + const { checkout, getCaptured } = stubCheckout("cs_staged_changed"); + + try { + await submitTicketForm(listing.slug, { + [`quantity_${listing.id}`]: "1", + email: "changed@example.com", + name: "Changed Buyer", + }); + await getDb().execute( + `UPDATE listing_attendees SET package_group_id = 999999 + WHERE attendee_id = (SELECT attendee_id FROM checkout_stages + WHERE payment_session_id = ?)`, + ["cs_staged_changed"], + ); + const intent = getCaptured(); + if (!intent) throw new Error("Expected captured checkout intent"); + + const response = await paidReturn("cs_staged_changed", intent, 1000); + expect(response.status).toBe(400); + const stage = await getDb().execute( + "SELECT state FROM checkout_stages WHERE payment_session_id = ?", + ["cs_staged_changed"], + ); + expect(stage.rows.map((row) => row.state)).toEqual(["pending"]); + } finally { + checkout.restore(); + } + }); + + test("rolls activation back when payment finalization is lost", async () => { + await setupStripe(); + const listing = await createTestListing({ unitPrice: 1000 }); + const { checkout, getCaptured } = stubCheckout("cs_staged_finalize"); + + try { + await submitTicketForm(listing.slug, { + [`quantity_${listing.id}`]: "1", + email: "finalize@example.com", + name: "Finalize Buyer", + }); + await getDb().execute( + `CREATE TRIGGER lose_payment_finalize + BEFORE UPDATE OF quantity ON listing_attendees + BEGIN + DELETE FROM processed_payments + WHERE payment_session_id = 'cs_staged_finalize'; + END`, + ); + const intent = getCaptured(); + if (!intent) throw new Error("Expected captured checkout intent"); + + const response = await paidReturn("cs_staged_finalize", intent, 1000); + expect(response.status).toBe(400); + await expectStage("cs_staged_finalize", "pending", 0); + } finally { + checkout.restore(); + } + }); + + test("fails loudly when staged attendee encryption is unavailable", async () => { + await setupStripe(); + const listing = await createTestListing({ unitPrice: 1000 }); + const { checkout, getCaptured } = stubCheckout("cs_staged_encryption"); + + try { + await submitTicketForm(listing.slug, { + [`quantity_${listing.id}`]: "1", + email: "encryption@example.com", + name: "Encryption Buyer", + }); + await getDb().execute("DELETE FROM settings WHERE key = ?", [ + CONFIG_KEYS.PUBLIC_KEY, + ]); + settings.invalidateCache(); + const intent = getCaptured(); + if (!intent) throw new Error("Expected captured checkout intent"); + + const response = await paidReturn("cs_staged_encryption", intent, 1000); + expect(response.status).toBe(400); + await expectStage("cs_staged_encryption", "pending", 0); + } finally { + checkout.restore(); + } + }); +}); diff --git a/test/lib/server-payments-success-replay.test.ts b/test/lib/server-payments-success-replay.test.ts index 73337fe7d1..64ada8c6c2 100644 --- a/test/lib/server-payments-success-replay.test.ts +++ b/test/lib/server-payments-success-replay.test.ts @@ -165,8 +165,7 @@ describeWithEnv("server (payment flow: ticket success)", { db: true }, () => { ); try { - // First request redirects with tokens (no stored tokens — a hidden package - // carries no explicit thank-you URL, so storeTokens is false). + // First request redirects with tokens, then clears their persisted copy. const response1 = await handleRequest( mockRequest("/payment/success?session_id=cs_hidden_replay"), ); diff --git a/test/lib/server-payments/cancel.test.ts b/test/lib/server-payments/cancel.test.ts index 8b02d49678..9cc209fcb6 100644 --- a/test/lib/server-payments/cancel.test.ts +++ b/test/lib/server-payments/cancel.test.ts @@ -2,6 +2,7 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; import { handleRequest } from "#routes"; +import { getCheckoutStage } from "#shared/db/checkout-stages.ts"; import { getDb } from "#shared/db/client.ts"; import { resetStripeClient } from "#shared/stripe.ts"; import { @@ -12,6 +13,7 @@ import { johnCheckoutSession } from "#test-utils/checkout.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { createTestGroup } from "#test-utils/db-helpers/groups.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { stageTestCheckout } from "#test-utils/db-helpers/processed-payments.ts"; import { singleItem } from "#test-utils/factories.ts"; import { mockRequest, withMocks } from "#test-utils/mocks.ts"; import { makeParent } from "#test-utils/parents.ts"; @@ -114,6 +116,34 @@ describeWithEnv("server (payment flow)", { db: true, triggers: true }, () => { ); }); + test("removes the unpaid staged attendee", async () => { + await setupStripe(); + const listing = await createTestListing({ + maxAttendees: 50, + unitPrice: 1000, + }); + const sessionId = "cs_staged_cancel"; + const stage = await stageTestCheckout(sessionId, listing); + + await withMocks( + () => cancelSession(sessionId, singleItem(listing.id, 1, 1000)), + async () => { + const response = await handleRequest( + mockRequest(`/payment/cancel?session_id=${sessionId}`), + ); + expect(response.status).toBe(200); + }, + resetStripeClient, + ); + + expect(await getCheckoutStage(sessionId)).toBeNull(); + const attendee = await getDb().execute({ + args: [stage.attendeeId], + sql: "SELECT id FROM attendees WHERE id = ?", + }); + expect(attendee.rows).toEqual([]); + }); + test("a cancelled checkout for a now-non-standalone child suppresses the retry link", async () => { await setupStripe(); diff --git a/test/lib/server-payments/confirm.test.ts b/test/lib/server-payments/confirm.test.ts index 260ab9b8e0..06d471b0b4 100644 --- a/test/lib/server-payments/confirm.test.ts +++ b/test/lib/server-payments/confirm.test.ts @@ -2,6 +2,7 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; import { handleRequest } from "#routes"; +import { reserveSession } from "#shared/db/processed-payments.ts"; import { resetStripeClient } from "#shared/stripe.ts"; import { expectHtmlResponse, @@ -12,6 +13,7 @@ import { johnCheckoutSession } from "#test-utils/checkout.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { bookAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { finalizeTestPaymentSession as finalizeSession } from "#test-utils/db-helpers/processed-payments.ts"; import { signMeta, singleItem } from "#test-utils/factories.ts"; import { mockRequest, withMocks } from "#test-utils/mocks.ts"; import { makeParent } from "#test-utils/parents.ts"; @@ -118,6 +120,39 @@ describeWithEnv("server (payment flow)", { db: true, triggers: true }, () => { ); }); + test("returns the ticket redirect once then renders a replay directly", async () => { + await setupStripe(); + const listing = await createTestListing({ + maxAttendees: 50, + unitPrice: 1000, + }); + + await withMocks( + () => + johnSession( + "cs_single_token_clear", + singleItem(listing.id, 1, 1000), + 1000, + ), + async () => { + const first = await handleRequest( + mockRequest("/payment/success?session_id=cs_single_token_clear"), + ); + expectRedirect(first, /^\/payment\/success\?tokens=.+$/); + const ticketPage = await followRedirect(first, handleRequest); + expect(await ticketPage.text()).toContain("View your ticket"); + + const replay = await handleRequest( + mockRequest("/payment/success?session_id=cs_single_token_clear"), + ); + expect(await expectHtmlResponse(replay, 200)).not.toContain( + "View your ticket", + ); + }, + resetStripeClient, + ); + }); + /** A parent with a configured thank-you URL folding one required paid child, * whose signed checkout metadata carries that explicit thank_you_url and two * listing ids. Returns the `withMocks` stub factory for the given provider @@ -231,12 +266,20 @@ describeWithEnv("server (payment flow)", { db: true, triggers: true }, () => { unitPrice: 1000, }); - // Create attendee as if payment was already processed (using atomic to simulate production flow) - await bookAttendee(listing, { + const booked = await bookAttendee(listing, { email: "john@example.com", name: "John", paymentId: "pi_test_123", }); + if (!booked.success) throw new Error("Expected replay fixture booking"); + const attendee = booked.attendees[0]!; + await reserveSession("cs_test_paid"); + await finalizeSession( + "cs_test_paid", + attendee.id, + [attendee.ticket_token], + "pi_test_123", + ); await withMocks( () => @@ -246,12 +289,7 @@ describeWithEnv("server (payment flow)", { db: true, triggers: true }, () => { mockRequest("/payment/success?session_id=cs_test_paid"), ); - // Capacity check will now fail since we already have the attendee - // This is expected - in the new flow, replaying creates a duplicate attempt - // which fails the capacity check if listing is near full - // For idempotent behavior, we'd need to check payment_intent uniqueness - // Response is either a 302 redirect (with tokens) or 200 (direct render for replay) - expect([200, 302]).toContain(response.status); + expectRedirect(response, /^\/payment\/success\?tokens=.+$/); }, resetStripeClient, ); diff --git a/test/lib/server-payments/replay.test.ts b/test/lib/server-payments/replay.test.ts index bc1d05c3a5..8e0fa409c7 100644 --- a/test/lib/server-payments/replay.test.ts +++ b/test/lib/server-payments/replay.test.ts @@ -278,10 +278,11 @@ describeWithEnv("server (payment flow)", { db: true, triggers: true }, () => { expect(await isSessionProcessed("cs_refund_failed")).toBeNull(); // The next retry re-attempts the refund (proof the lock was released). - await handleRequest( + const retry = await handleRequest( mockRequest("/payment/success?session_id=cs_refund_failed"), ); expect(mockRefund.calls.length).toBe(2); + expect(await retry.text()).toContain("contact support"); }, resetStripeClient, ); diff --git a/test/lib/server-privacy.test.ts b/test/lib/server-privacy.test.ts index 9d62ae5ec8..2ea767af5b 100644 --- a/test/lib/server-privacy.test.ts +++ b/test/lib/server-privacy.test.ts @@ -15,11 +15,7 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; import { parseFlashValue } from "#shared/cookies.ts"; import { queryOne } from "#shared/db/client.ts"; -import { - hashEmail, - hashPhone, - recordVisit, -} from "#shared/db/contact-preferences.ts"; +import { hashEmail, hashPhone } from "#shared/db/contact-preferences.ts"; import { settings } from "#shared/db/settings.ts"; import { nowMs } from "#shared/now.ts"; import { @@ -34,6 +30,7 @@ import { attendeeExists as attendeeExistsHelper, insertOrphanAttendee, } from "#test-utils/db-helpers/attendees.ts"; +import { seedContactVisits } from "#test-utils/db-helpers/contacts.ts"; import { awaitTestRequest } from "#test-utils/mocks.ts"; import { adminFormPost, @@ -191,7 +188,7 @@ describeWithEnv("server (admin privacy)", { db: true }, () => { test("erases a contact record found by email", async () => { const hash = await hashEmail("erase-me@example.com"); - await recordVisit(hash); + await seedContactVisits(hash); const { response } = await adminFormPost("/admin/privacy/erase", { contact_type: "email", @@ -207,7 +204,7 @@ describeWithEnv("server (admin privacy)", { db: true }, () => { test("erases a contact record found by phone", async () => { const hash = await hashPhone("07700 900222"); - await recordVisit(hash); + await seedContactVisits(hash); const { response } = await adminFormPost("/admin/privacy/erase", { contact_type: "sms", diff --git a/test/lib/server-public/ticket-additional-coverage.test.ts b/test/lib/server-public/ticket-additional-coverage.test.ts index 4270442a0f..ce08ffd997 100644 --- a/test/lib/server-public/ticket-additional-coverage.test.ts +++ b/test/lib/server-public/ticket-additional-coverage.test.ts @@ -3,7 +3,7 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; import { stub } from "@std/testing/mock"; import { handleRequest } from "#routes"; -import { hashPhone, recordVisit } from "#shared/db/contact-preferences.ts"; +import { hashPhone } from "#shared/db/contact-preferences.ts"; import { modifiersTable } from "#shared/db/modifiers.ts"; import { settings } from "#shared/db/settings.ts"; import { resetStripeClient } from "#shared/stripe.ts"; @@ -21,6 +21,7 @@ import { } from "#test-utils/csrf.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { bookAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; +import { seedContactVisits } from "#test-utils/db-helpers/contacts.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; import { mockFormRequest, mockRequest } from "#test-utils/mocks.ts"; import { setupStripe } from "#test-utils/settings.ts"; @@ -32,7 +33,7 @@ import { setupStripe } from "#test-utils/settings.ts"; * setup behind both returning-customer Square tests below (one records the * visit without an email configured, the other with). */ const setupReturningCustomerFeeListing = async () => { - await recordVisit(await hashPhone("555-1234")); + await seedContactVisits(await hashPhone("555-1234")); const listing = await createTestListing({ fields: "phone", maxAttendees: 50, diff --git a/test/lib/server-refunds-balance-payments.test.ts b/test/lib/server-refunds-balance-payments.test.ts index 1223860459..cf900b5a90 100644 --- a/test/lib/server-refunds-balance-payments.test.ts +++ b/test/lib/server-refunds-balance-payments.test.ts @@ -4,10 +4,7 @@ import { attendeeStatuses } from "#shared/db/attendee-statuses.ts"; import { createAttendeeAtomic } from "#shared/db/attendees/api.ts"; import { settleAttendeeBalance } from "#shared/db/attendees/balance.ts"; import { balanceFinalizeStatement } from "#shared/db/payment-finalize.ts"; -import { - finalizeSession as finalizePaymentSession, - reserveSession, -} from "#shared/db/processed-payments.ts"; +import { reserveSession } from "#shared/db/processed-payments.ts"; import type { Attendee, Listing } from "#shared/types.ts"; import { expectFlashRedirect, @@ -16,6 +13,7 @@ import { import { settle } from "#test-utils/balance.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { finalizeTestPaymentSession as finalizePaymentSession } from "#test-utils/db-helpers/processed-payments.ts"; import { setupErrorSpy } from "#test-utils/error-spy.ts"; import { postListingSale } from "#test-utils/ledger.ts"; import { diff --git a/test/lib/server-reservation/helpers.ts b/test/lib/server-reservation/helpers.ts index 88b68d9b47..ca4e637d7a 100644 --- a/test/lib/server-reservation/helpers.ts +++ b/test/lib/server-reservation/helpers.ts @@ -100,12 +100,9 @@ export const setupSoldOutModifierRace = async ( stock: 1, }); // Simulate the race: a *different*, concurrent order consumes the modifier's - // last unit between pricing and our commit. Its usage lands on that other - // order's attendee (a sentinel id, never ours), so it stands after our order - // rolls back — exactly as a real competing booking's stock would. Firing on our - // attendee INSERT (just before the booking insert in the batch) is only the - // hook for "stock gone by commit time"; it must not attach to NEW.id, or it - // would look like our own consumption. + // last unit between pricing and our commit. It lands on a sentinel attendee, + // never ours. The test trigger itself rolls back with this transaction; its + // purpose is only to make the in-transaction stock guard observe the race. await getDb().execute( `CREATE TRIGGER test_consume_modifier_before_order AFTER INSERT ON attendees diff --git a/test/lib/server-reservation/public-default-modifiers.test.ts b/test/lib/server-reservation/public-default-modifiers.test.ts index a959a6b044..e7130cdd50 100644 --- a/test/lib/server-reservation/public-default-modifiers.test.ts +++ b/test/lib/server-reservation/public-default-modifiers.test.ts @@ -1,7 +1,7 @@ import { expect } from "@std/expect"; import { afterEach, it as test } from "@std/testing/bdd"; -import { hashEmail, recordVisit } from "#shared/db/contact-preferences.ts"; -import { recordBooking } from "#shared/db/contact-tokens.ts"; +import { hashEmail } from "#shared/db/contact-preferences.ts"; +import { recordBookingActivity } from "#shared/db/contact-tokens.ts"; import { modifierUsedQuantities } from "#shared/db/modifier-usage.ts"; import { modifiersTable } from "#shared/db/modifiers.ts"; import { resetStripeClient } from "#shared/stripe.ts"; @@ -122,12 +122,10 @@ describeWithEnv( "An extra you selected sold out while you were checking out. Please try again.", false, ); - // The racing order's single consumption stands (it really got the stock); - // our rejected order added none of its own — the batch's stock-guarded - // booking insert never landed, so its gated usage insert never fired. - expect(await modifierUsedQuantities([modifier.id])).toEqual( - new Map([[modifier.id, 1]]), - ); + // The test trigger runs inside this order's transaction, so its simulated + // stock consume rolls back with the refused order. A real competing order + // commits separately and would remain; either way this order adds no use. + expect(await modifierUsedQuantities([modifier.id])).toEqual(new Map()); expect(await attendeeCount()).toBe(0); // A sold-out free order leaves no phantom contact history: the batch never // reaches the success path that records a visit + public booking, so there @@ -135,7 +133,7 @@ describeWithEnv( expect(await totalContactActivity()).toEqual({ bookings: 0, visits: 0 }); }); - test("reverses a phone contact's counters when a free order's stock rolls back", async () => { + test("leaves a phone contact unchanged when a free order rolls back", async () => { await setupStripe(); // A phone-only listing identifies the buyer by phone hash, exercising the // SMS-reachable contact path rather than email. @@ -153,8 +151,8 @@ describeWithEnv( false, ); expect(await attendeeCount()).toBe(0); - // The phone identity must be compensated just like email: a sold-out free - // order leaves no visit or booking on the texted contact. + // The atomic write never reaches contact activity, so there is nothing to + // reverse and the texted contact remains unchanged. expect(await totalContactActivity()).toEqual({ bookings: 0, visits: 0 }); }); @@ -162,8 +160,7 @@ describeWithEnv( await setupStripe(); // This contact already has one genuine public booking + visit on record. const emailHash = await hashEmail("buyer@example.com"); - await recordVisit(emailHash); - await recordBooking(emailHash, "public", "tok-earlier"); + await recordBookingActivity(emailHash, "public", "tok-earlier"); const { listing } = await setupSoldOutModifierRace(); const response = await submitBuyerOrder(listing); @@ -174,8 +171,8 @@ describeWithEnv( false, ); expect(await attendeeCount()).toBe(0); - // The rollback decrements by exactly one (clamped at zero), so the earlier - // booking survives — a rejected order must never wipe real history. + // The rejected order never writes contact activity, so the earlier + // booking survives unchanged. expect(await totalContactActivity()).toEqual({ bookings: 1, visits: 1 }); }); }, diff --git a/test/lib/server-webhooks/already-processed-rollback.test.ts b/test/lib/server-webhooks/already-processed-rollback.test.ts index 30437ce7c1..80a13e0d64 100644 --- a/test/lib/server-webhooks/already-processed-rollback.test.ts +++ b/test/lib/server-webhooks/already-processed-rollback.test.ts @@ -138,10 +138,12 @@ describeWithEnv( if (!attResult.success) throw new Error("Failed to create attendee"); // Reserve and finalize the session with the real attendee - const { - reserveSession: reserveSessionFn, - finalizeSession: finalizeSessionFn, - } = await import("#shared/db/processed-payments.ts"); + const { reserveSession: reserveSessionFn } = await import( + "#shared/db/processed-payments.ts" + ); + const { finalizeTestPaymentSession: finalizeSessionFn } = await import( + "#test-utils/db-helpers/processed-payments.ts" + ); await reserveSessionFn("cs_del_listing_wh"); await finalizeSessionFn( "cs_del_listing_wh", diff --git a/test/lib/server-webhooks/concurrent-processing.test.ts b/test/lib/server-webhooks/concurrent-processing.test.ts index ab27ee90a3..c6c5e3466e 100644 --- a/test/lib/server-webhooks/concurrent-processing.test.ts +++ b/test/lib/server-webhooks/concurrent-processing.test.ts @@ -64,6 +64,9 @@ describeWithEnv("server webhooks > concurrent processing", { db: true }, () => { mockWebhookRequest({}, { "stripe-signature": "sig_valid" }), ); expect(response.status).toBe(409); + expect(await response.text()).toContain( + "Payment is being processed. Please wait a moment and refresh.", + ); } finally { mockVerify.restore(); } @@ -174,10 +177,12 @@ describeWithEnv("server webhooks > concurrent processing", { db: true }, () => { if (!result.success) throw new Error("Failed to create test attendee"); const attendee = result.attendees[0]!; - const { - reserveSession: reserveSessionFn, - finalizeSession: finalizeSessionFn, - } = await import("#shared/db/processed-payments.ts"); + const { reserveSession: reserveSessionFn } = await import( + "#shared/db/processed-payments.ts" + ); + const { finalizeTestPaymentSession: finalizeSessionFn } = await import( + "#test-utils/db-helpers/processed-payments.ts" + ); await reserveSessionFn("cs_multi_already_done"); await finalizeSessionFn( "cs_multi_already_done", diff --git a/test/lib/server-webhooks/custom-questions-multi.test.ts b/test/lib/server-webhooks/custom-questions-multi.test.ts index c1a4fc0d61..8d620f2da0 100644 --- a/test/lib/server-webhooks/custom-questions-multi.test.ts +++ b/test/lib/server-webhooks/custom-questions-multi.test.ts @@ -205,7 +205,7 @@ describeWithEnv( 1000, ), paymentIntent: "pi_multi_q", - sessionId: "cs_multi_q", + sessionId: "cs_multi_q_stub", }), ); @@ -290,7 +290,7 @@ describeWithEnv( 1000, ), paymentIntent: "pi_text_q", - sessionId: "cs_text_q", + sessionId: "cs_text_q_stub", }), ); diff --git a/test/lib/server-webhooks/modifier-refunds.test.ts b/test/lib/server-webhooks/modifier-refunds.test.ts index 248d001010..3e3e4d6aab 100644 --- a/test/lib/server-webhooks/modifier-refunds.test.ts +++ b/test/lib/server-webhooks/modifier-refunds.test.ts @@ -134,9 +134,8 @@ describeWithEnv( "cs_modifier_soldout", mockRefund, ); - // The greedy create's visit + booking are reversed, and the quantity-0 - // placeholder records neither, so the refunded order leaves no phantom - // history on the buyer's contact. + // The failed atomic create and quantity-0 placeholder record no activity, + // so the refunded order leaves no phantom history on the buyer's contact. const { getContactRecord, getVisits, hashEmail } = await import( "#shared/db/contact-preferences.ts" ); diff --git a/test/lib/server-webhooks/refund-helper-functions.test.ts b/test/lib/server-webhooks/refund-helper-functions.test.ts index 47adc41a2b..f47e4a57bf 100644 --- a/test/lib/server-webhooks/refund-helper-functions.test.ts +++ b/test/lib/server-webhooks/refund-helper-functions.test.ts @@ -60,6 +60,7 @@ describeWithEnv( ); // Should show "contact support" since refund failed (no payment reference) expect(html).toContain("contact support"); + expect(html).not.toContain("automatically refunded"); } finally { mockRetrieve.restore(); } diff --git a/test/lib/server-webhooks/registration-closed.test.ts b/test/lib/server-webhooks/registration-closed.test.ts index b491634798..5eccf5cb71 100644 --- a/test/lib/server-webhooks/registration-closed.test.ts +++ b/test/lib/server-webhooks/registration-closed.test.ts @@ -129,8 +129,7 @@ describeWithEnv( const { getAttendeesRaw } = await import( "#shared/db/attendees/queries.ts" ); - const attendees1 = await getAttendeesRaw(listing1.id); - expect(attendees1.length).toBe(0); + expect(await getAttendeesRaw(listing1.id)).toEqual([]); }); test("multi-ticket webhook passes date to daily listings only", async () => { diff --git a/test/lib/test-utils/factories.test.ts b/test/lib/test-utils/factories.test.ts index 1759a5f576..5c52652c15 100644 --- a/test/lib/test-utils/factories.test.ts +++ b/test/lib/test-utils/factories.test.ts @@ -32,25 +32,22 @@ describe("test-utils — listing & attendee factories", () => { expect(attendee.id).toBeGreaterThan(0); }); - test("rolls back the partial booking and fails loudly when a listing can't take it", async () => { + test("fails loudly and leaves no booking when a listing can't take the order", async () => { const open = await createTestListing({ maxAttendees: 10 }); const full = await createTestListing({ maxAttendees: 1, name: "Full" }); await bookTestAttendee([full.id], "Filler"); // uses the only spot await expect( bookTestAttendee([open.id, full.id], "Partial"), - ).rejects.toThrow("Failed to book test attendee onto all 2 listing(s)"); + ).rejects.toThrow("Failed to create attendee: capacity_exceeded"); - // The booking that DID land on `open` is rolled back, so no stray - // attendee is left occupying capacity or skewing later assertions. + // The atomic batch leaves no attendee occupying capacity. const { getAttendeesRaw } = await import( "#shared/db/attendees/queries.ts" ); expect((await getAttendeesRaw(open.id)).length).toBe(0); - // Regression: the rollback must also reverse the contact-activity count - // that the greedy create recorded — under the same source — or the - // contact keeps a booking that no longer exists. + // Regression: an order that did not commit records no contact activity. const { getContactCountFields, hashEmail } = await import( "#shared/db/contact-preferences.ts" ); diff --git a/test/lib/webhook-price-signature/helpers.ts b/test/lib/webhook-price-signature/helpers.ts index 792aee97e6..dd19650bfe 100644 --- a/test/lib/webhook-price-signature/helpers.ts +++ b/test/lib/webhook-price-signature/helpers.ts @@ -37,7 +37,9 @@ export const signedMeta = ( items: string; name?: string; email?: string; + phone?: string; modifiers?: string; + answer_ids?: string; }, ): Record => signMeta( diff --git a/test/lib/webhook-price-signature/post-commit.test.ts b/test/lib/webhook-price-signature/post-commit.test.ts new file mode 100644 index 0000000000..90539784b6 --- /dev/null +++ b/test/lib/webhook-price-signature/post-commit.test.ts @@ -0,0 +1,274 @@ +import { expect } from "@std/expect"; +import { afterEach, it as test } from "@std/testing/bdd"; +import { stub } from "@std/testing/mock"; +import { handleRequest } from "#routes"; +import { validatePaidSession } from "#routes/api/payment-processing/classify.ts"; +import { validateAllItems } from "#routes/api/payment-processing/items.ts"; +import { parseTokens } from "#routes/tickets/token-utils.ts"; +import { attendeesApi } from "#shared/db/attendees/api.ts"; +import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; +import { getDb } from "#shared/db/client.ts"; +import { getContactRecord, hashEmail } from "#shared/db/contact-preferences.ts"; +import { getRecentBookingTokens } from "#shared/db/contact-tokens.ts"; +import { listingsTable } from "#shared/db/listings.ts"; +import { modifiersTable } from "#shared/db/modifiers.ts"; +import { + isSessionProcessed, + releaseReservation, +} from "#shared/db/processed-payments.ts"; +import { getAttendeeAnswersBatch } from "#shared/db/questions/attendee-answers/reads.ts"; +import { listingQuestions } from "#shared/db/questions/queries.ts"; +import { answersTable, questionsTable } from "#shared/db/questions/tables.ts"; +import { resetStripeClient } from "#shared/stripe.ts"; +import { getAllActivityLog } from "#test-utils/activity-log.ts"; +import { + assertJson, + expectRedirect, + followRedirect, +} from "#test-utils/assertions.ts"; +import { getTestPrivateKey } from "#test-utils/crypto.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; +import { singleItem } from "#test-utils/factories.ts"; +import { stubRetrieveCheckoutSession } from "#test-utils/webhooks.ts"; +import { + redirectRequest, + runWebhook, + setupWithListing, + signedMeta, + webhookRequest, +} from "./helpers.ts"; + +const STORED_BOOKING_FAILURE = { + error: + "We couldn't complete your booking, so we've saved your details and a member of our team can help you rebook.", + processed: false, + received: true, +}; + +const expectStoredBookingFailure = () => + assertJson(webhookRequest(), 200, (json) => { + expect(json).toEqual(STORED_BOOKING_FAILURE); + }); + +const expectPlaceholders = async (listingIds: number[]): Promise => { + for (const listingId of listingIds) { + expect( + (await getAttendeesRaw(listingId)).map(({ quantity }) => quantity), + ).toEqual([0]); + } +}; + +describeWithEnv( + "webhook signed price oracle - post-commit failures", + { db: true }, + () => { + afterEach(() => { + resetStripeClient(); + }); + + test("recovers the ticket when post-commit processing throws", async () => { + const listing = await setupWithListing(); + const sessionId = "cs_post_commit_failure"; + const question = await questionsTable.insert({ + displayType: "radio", + text: "Recovery answer?", + }); + const answer = await answersTable.insert({ + questionId: question.id, + sortOrder: 1, + text: "Recovered", + }); + await listingQuestions.setIds(listing.id, [question.id]); + const modifier = await modifiersTable.insert({ + calcKind: "fixed", + calcValue: 1, + direction: "discount", + name: "RECOVER", + trigger: "code", + }); + const metadata = signedMeta(900, { + answer_ids: JSON.stringify({ + [String(listing.id)]: [answer.id], + }), + items: singleItem(listing.id, 1, 1000), + modifiers: JSON.stringify([{ i: modifier.id, q: 1 }]), + }); + const createBooking = attendeesApi.createBookingAtomic; + let racingRedirect: Response | null = null; + const failAfterCommit = stub( + attendeesApi, + "createBookingAtomic", + async (...args) => { + const result = await createBooking(...args); + if (result === "sold-out" || !result.success) { + throw new Error("Expected the synthetic booking to commit"); + } + + racingRedirect = await redirectRequest(sessionId); + + Object.defineProperty(result, "attendees", { + get: () => { + throw new Error("synthetic post-commit failure"); + }, + }); + return result; + }, + ); + const retrieve = stubRetrieveCheckoutSession({ + amountTotal: 900, + metadata, + paymentIntent: `pi_${sessionId}`, + sessionId, + }); + + try { + await runWebhook( + { amount_total: 900, id: sessionId, metadata }, + async (refund) => { + await assertJson(webhookRequest(), 200, (json) => { + expect(json.processed).toBe(true); + }); + expect(refund.calls.length).toBe(0); + const attendees = await getAttendeesRaw(listing.id); + expect(attendees.map(({ quantity }) => quantity)).toEqual([1]); + const attendeeId = attendees[0]!.id; + const answers = await getAttendeeAnswersBatch([attendeeId], { + texts: false, + }); + expect(answers.get(attendeeId)).toEqual([answer.id]); + const activity = await getAllActivityLog(); + expect(activity.map(({ message }) => message)).toEqual( + expect.arrayContaining([ + `Attendee registered for '${listing.name}'`, + "Promo code 'RECOVER' used: £1 off", + ]), + ); + + const validation = await validatePaidSession(sessionId); + if (!validation.ok) + throw new Error("Expected a valid paid session"); + const validated = await validateAllItems( + validation.data.session, + validation.data.intent, + ); + if ("success" in validated) { + throw new Error("Expected valid recovery items"); + } + const processed = await isSessionProcessed(sessionId); + expect(processed?.ticket_tokens).toBe(""); + expect(racingRedirect).not.toBeNull(); + const redirect = racingRedirect!; + expectRedirect(redirect, /^\/payment\/success\?tokens=.+$/); + const location = redirect.headers.get("location"); + expect(location).not.toBeNull(); + const ticketTokens = parseTokens( + new URL(location!, "http://localhost").searchParams.get( + "tokens", + )!, + ); + const contactHash = await hashEmail("buyer@example.com"); + const privateKey = await getTestPrivateKey(); + const contactRecord = await getContactRecord( + contactHash, + privateKey, + ); + expect({ + publicBookingCount: contactRecord.publicBookingCount, + visits: contactRecord.visits, + }).toEqual({ publicBookingCount: 1, visits: 1 }); + expect( + await getRecentBookingTokens(contactHash, privateKey, 1), + ).toEqual([{ source: "public", token: ticketTokens[0] }]); + const page = await followRedirect(redirect, handleRequest); + expect(await page.text()).toContain("View your ticket"); + }, + ); + } finally { + retrieve.restore(); + failAfterCommit.restore(); + } + }); + + test("refunds only after an incomplete cart leaves no live booking", async () => { + const first = await setupWithListing(); + const second = await setupWithListing(); + const sessionId = "cs_partial_commit_failure"; + const items = JSON.stringify([ + { e: first.id, p: 1000, q: 1 }, + { e: second.id, p: 1000, q: 1 }, + ]); + const createBooking = attendeesApi.createBookingAtomic; + const failAfterIncompleteCreate = stub( + attendeesApi, + "createBookingAtomic", + async (...args) => { + await listingsTable.update(second.id, { active: false }); + await createBooking(...args); + throw new Error("synthetic partial post-commit failure"); + }, + ); + + try { + await runWebhook( + { + amount_total: 2000, + id: sessionId, + metadata: signedMeta(2000, { items }), + }, + async (refund) => { + await expectStoredBookingFailure(); + expect(refund.calls.length).toBe(1); + await expectPlaceholders([first.id, second.id]); + + const processed = await isSessionProcessed(sessionId); + expect(processed).not.toBeNull(); + expect(processed!.failure_data).not.toBe(""); + await getDb().execute({ + args: ["2000-01-01T00:00:00.000Z", sessionId], + sql: "UPDATE processed_payments SET processed_at = ? WHERE payment_session_id = ?", + }); + + await expectStoredBookingFailure(); + await expectPlaceholders([first.id, second.id]); + expect(refund.calls.length).toBe(1); + }, + ); + } finally { + failAfterIncompleteCreate.restore(); + } + }); + + test("does not refund when an unexpected failure loses its reservation", async () => { + const listing = await setupWithListing(); + const sessionId = "cs_missing_reservation_failure"; + const failWithoutReservation = stub( + attendeesApi, + "createBookingAtomic", + async () => { + await releaseReservation(sessionId); + throw new Error("synthetic ambiguous failure"); + }, + ); + + try { + await runWebhook( + { + id: sessionId, + metadata: signedMeta(1000, { + items: singleItem(listing.id, 1, 1000), + }), + }, + async (refund) => { + await expect(webhookRequest()).rejects.toThrow( + "synthetic ambiguous failure", + ); + expect(refund.calls.length).toBe(0); + expect(await getAttendeesRaw(listing.id)).toEqual([]); + }, + ); + } finally { + failWithoutReservation.restore(); + } + }); + }, +); diff --git a/test/lib/webhook-price-signature/recovery-decision.test.ts b/test/lib/webhook-price-signature/recovery-decision.test.ts new file mode 100644 index 0000000000..a0986d7606 --- /dev/null +++ b/test/lib/webhook-price-signature/recovery-decision.test.ts @@ -0,0 +1,43 @@ +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { decideUnexpectedCreate } from "#routes/api/payment-processing/recovery-decision.ts"; + +test("recovers the attendee finalized for this ticket token", () => { + expect( + decideUnexpectedCreate({ + finalizedAttendeeId: 42, + tokenAttendeeId: 42, + unresolved: false, + }), + ).toEqual({ attendeeId: 42, kind: "recover" }); +}); + +test("refunds an unresolved reservation with no committed attendee", () => { + expect( + decideUnexpectedCreate({ + finalizedAttendeeId: null, + tokenAttendeeId: null, + unresolved: true, + }), + ).toEqual({ kind: "refund" }); +}); + +test("rethrows when another outcome already resolved the reservation", () => { + expect( + decideUnexpectedCreate({ + finalizedAttendeeId: null, + tokenAttendeeId: null, + unresolved: false, + }), + ).toEqual({ kind: "rethrow" }); +}); + +test("rethrows an attendee beside an unresolved reservation", () => { + expect( + decideUnexpectedCreate({ + finalizedAttendeeId: null, + tokenAttendeeId: 42, + unresolved: true, + }), + ).toEqual({ kind: "rethrow" }); +}); diff --git a/test/lib/webhook-price-signature/stored-refund-and-ignore.test.ts b/test/lib/webhook-price-signature/stored-refund-and-ignore.test.ts index 354c4f17b8..93bd1bcbbd 100644 --- a/test/lib/webhook-price-signature/stored-refund-and-ignore.test.ts +++ b/test/lib/webhook-price-signature/stored-refund-and-ignore.test.ts @@ -175,6 +175,13 @@ describeWithEnv( async (refund) => { await expectStoredRefund(listing.id); expect(refund.calls.length).toBe(1); + const [attendee] = await getAttendeesRaw(listing.id); + const legs = await transfersByAccount( + attendeeAccount(attendee!.id), + ); + expect(legs.find(({ kind }) => kind === "refund_cash")?.memo).toBe( + "unexpected_error", + ); const record = await isSessionProcessed("cs_crash_store"); expect(record?.attendee_id).toBeNull(); expect(record?.failure_data).not.toBe(""); diff --git a/test/lib/webhook-price-signature/trusted-and-mismatch.test.ts b/test/lib/webhook-price-signature/trusted-and-mismatch.test.ts index 48d5a605ca..aa1597dbb9 100644 --- a/test/lib/webhook-price-signature/trusted-and-mismatch.test.ts +++ b/test/lib/webhook-price-signature/trusted-and-mismatch.test.ts @@ -6,24 +6,26 @@ import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; import { execute } from "#shared/db/client.ts"; import { listingChildren } from "#shared/db/listing-parents.ts"; import { deleteListing, listingsTable } from "#shared/db/listings.ts"; +import { getRefundPaymentReferences } from "#shared/db/payment-references.ts"; import { isSessionProcessed } from "#shared/db/processed-payments.ts"; import { prunePayments } from "#shared/db/prune.ts"; import { resetStripeClient } from "#shared/stripe.ts"; -import { assertJson } from "#test-utils/assertions.ts"; +import { getTestPrivateKey } from "#test-utils/crypto.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; import { signMeta, singleItem, webhookMeta } from "#test-utils/factories.ts"; import { setupStripe } from "#test-utils/settings.ts"; +import { stubRetrieveCheckoutSession } from "#test-utils/webhooks.ts"; import { expectProcessed, expectReplayOutcome, expectStoredRefund, expectStoredRefundRecord, + redirectRequest, runWebhook, setupPackage, setupWithListing, signedMeta, - webhookRequest, } from "./helpers.ts"; const pruneReplayRowWithoutRefundReference = async (sessionId: string) => { @@ -110,9 +112,20 @@ describeWithEnv( const legsAfter = await transfersByAccount(attendeeAccount(original!.id)); expect(legsAfter.length).toBe(legsBefore.length); expect(legsAfter.some((leg) => leg.kind === "refund_cash")).toBe(false); - expect((await isSessionProcessed(session.id))!.attendee_id).toBe( - original!.id, + const processed = (await isSessionProcessed(session.id))!; + expect(processed.attendee_id).toBe(original!.id); + const references = await getRefundPaymentReferences( + [original!], + await getTestPrivateKey(), ); + const replayReference = references + .get(original!.id)! + .find(({ sessionIds }) => sessionIds.includes(session.id)); + expect(replayReference).toEqual({ + providerRefunded: false, + reference: "pi_cs_replay_after_prune", + sessionIds: [session.id], + }); }); test("a pruned replay whose listing price changed is recovered, not refunded", async () => { @@ -154,13 +167,27 @@ describeWithEnv( // acknowledge without refunding or recreating a ghost. await deleteListing(listing.id); - await runWebhook(session, async (refund) => { - await assertJson(webhookRequest(), 200, (json) => { - expect(json.processed).toBe(false); - expect(json.error).toContain("already been processed"); - }); - expect(refund.calls.length).toBe(0); + const retrieve = stubRetrieveCheckoutSession({ + amountTotal: 1000, + metadata: session.metadata, + paymentIntent: `pi_${session.id}`, + sessionId: session.id, }); + try { + await runWebhook(session, async (refund) => { + const first = await redirectRequest(session.id); + expect(first.status).toBe(200); + expect(await first.text()).toContain("already been processed"); + + const replay = await redirectRequest(session.id); + expect(replay.status).toBe(200); + expect(await replay.text()).toContain("already been processed"); + + expect(refund.calls.length).toBe(0); + }); + } finally { + retrieve.restore(); + } // No placeholder ghost was created for the orphaned replay. expect((await getAttendeesRaw(listing.id)).length).toBe(0); }); diff --git a/test/routes/unsubscribe.test.ts b/test/routes/unsubscribe.test.ts index ded97d1ab0..cf1db21e0e 100644 --- a/test/routes/unsubscribe.test.ts +++ b/test/routes/unsubscribe.test.ts @@ -6,7 +6,6 @@ import { getVisits, hashEmail, isHashUnsubscribed, - recordVisit, unsubscribeHash, } from "#shared/db/contact-preferences.ts"; import { settings } from "#shared/db/settings.ts"; @@ -16,6 +15,7 @@ import { followRedirectWithFlash, } from "#test-utils/assertions.ts"; import { describeWithEnv } from "#test-utils/db.ts"; +import { seedContactVisits } from "#test-utils/db-helpers/contacts.ts"; import { mockFormRequest, mockRequest } from "#test-utils/mocks.ts"; const getUnsubscribe = (query = ""): Promise => @@ -131,7 +131,7 @@ describeWithEnv("routes (unsubscribe)", { db: true }, () => { test("forgets the contact row", async () => { const hash = await hashEmail("forgetme@example.com"); - await recordVisit(hash); + await seedContactVisits(hash); const response = await postUnsubscribe({ action: "forget", email: hash, diff --git a/test/shared/booking-lines.test.ts b/test/shared/booking-lines.test.ts new file mode 100644 index 0000000000..3026f3eae3 --- /dev/null +++ b/test/shared/booking-lines.test.ts @@ -0,0 +1,55 @@ +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { orderBookings } from "#shared/booking-lines.ts"; +import { testListingWithCount } from "#test-utils/factories.ts"; + +const listing = testListingWithCount({ id: 10, slug: "parent" }); +const item = { e: listing.id, p: 100, q: 1 }; + +test("leaves payment amount out of an unpaid staged path", () => { + const [booking] = orderBookings([{ item, listing }], { + allocations: [], + date: null, + dayCount: undefined, + items: [item], + }); + + expect(Object.hasOwn(booking!, "pricePaid")).toBe(false); +}); + +test("leaves an empty allocation list as a standalone path", () => { + const [booking] = orderBookings([{ item, listing, pricePaid: 100 }], { + allocations: [], + date: null, + dayCount: undefined, + items: [item], + }); + + expect(booking).toEqual({ + date: null, + durationDays: 1, + listingId: listing.id, + packageGroupId: undefined, + pricePaid: 100, + quantity: 1, + }); +}); + +test("expands a child allocation onto its parent path", () => { + const childListing = testListingWithCount({ id: 20, slug: "child" }); + const child = { e: childListing.id, p: 100, q: 1 }; + const [booking] = orderBookings( + [{ item: child, listing: childListing, pricePaid: 100 }], + { + allocations: [{ childId: childListing.id, parentId: listing.id, qty: 1 }], + date: null, + dayCount: undefined, + items: [item, child], + }, + ); + + expect(booking?.parentListingId).toBe(listing.id); + expect(booking?.orderToken).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); +}); diff --git a/test/shared/db/attendees/activate.test.ts b/test/shared/db/attendees/activate.test.ts new file mode 100644 index 0000000000..a4e5167a52 --- /dev/null +++ b/test/shared/db/attendees/activate.test.ts @@ -0,0 +1,249 @@ +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import type { OrderBooking } from "#shared/booking-lines.ts"; +import { activateStagedBooking } from "#shared/db/attendees/activate.ts"; +import type { FinalizedBookingBatchPlan } from "#shared/db/attendees/create-batch.ts"; +import { decryptAttendeeFields } from "#shared/db/attendees/pii.ts"; +import { getAttendeeRaw } from "#shared/db/attendees/queries.ts"; +import { stageCheckout } from "#shared/db/checkout-stages.ts"; +import { execute } from "#shared/db/client.ts"; +import { modifierUsedQuantities } from "#shared/db/modifier-usage.ts"; +import { modifiersTable } from "#shared/db/modifiers.ts"; +import { reserveSession } from "#shared/db/processed-payments.ts"; +import { settings } from "#shared/db/settings.ts"; +import { CONFIG_KEYS } from "#shared/settings/keys.ts"; +import { checkoutIntent, checkoutItem } from "#test-utils/checkout.ts"; +import { getTestPrivateKey } from "#test-utils/crypto.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; +import { bookAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; +import { createTestListing } from "#test-utils/db-helpers/listings.ts"; + +const setupStage = async (sessionId: string, maxAttendees = 2) => { + const listing = await createTestListing({ maxAttendees, unitPrice: 1000 }); + const intent = checkoutIntent({ + items: [ + checkoutItem({ + listingId: listing.id, + name: listing.name, + slug: listing.slug, + }), + ], + }); + const stage = await stageCheckout(sessionId, "stripe", intent); + const bookings: OrderBooking[] = [ + { + date: null, + durationDays: 1, + listingId: listing.id, + pricePaid: 1000, + quantity: 1, + }, + ]; + const input = { + address: intent.address, + bookings, + email: intent.email, + name: intent.name, + paymentId: `pi_${sessionId}`, + phone: intent.phone, + special_instructions: intent.special_instructions, + }; + const plan: FinalizedBookingBatchPlan = { + finalize: { + paymentReference: `pi_${sessionId}`, + sessionId, + }, + legs: [], + usages: [], + }; + return { input, listing, plan, stage }; +}; + +const activate = async (sessionId: string) => { + const setup = await setupStage(sessionId); + await reserveSession(sessionId); + return { + ...setup, + run: () => + activateStagedBooking( + sessionId, + setup.stage.attendeeId, + setup.stage.ticketToken, + setup.input, + setup.plan, + ), + }; +}; + +describeWithEnv("db > staged booking activation", { db: true }, () => { + test("activates the staged row", async () => { + const setup = await activate("cs_activate_ok"); + + expect(await setup.run()).toEqual({ success: true }); + const row = await execute( + `SELECT stage.state, booking.quantity + FROM checkout_stages AS stage + JOIN listing_attendees AS booking + ON booking.attendee_id = stage.attendee_id + WHERE stage.payment_session_id = ?`, + ["cs_activate_ok"], + ); + expect(row.rows.map((value) => [value.state, value.quantity])).toEqual([ + ["booked", 1], + ]); + const attendee = await getAttendeeRaw(setup.stage.attendeeId); + if (!attendee) throw new Error("Expected activated attendee"); + expect( + (await decryptAttendeeFields(attendee, await getTestPrivateKey(), true)) + .payment_id, + ).toBe("pi_cs_activate_ok"); + }); + + test("activates a nonzero parent and package path", async () => { + const setup = await activate("cs_activate_path"); + await execute( + `UPDATE listing_attendees + SET parent_listing_id = 123, package_group_id = 456 + WHERE attendee_id = ?`, + [setup.stage.attendeeId], + ); + setup.input.bookings[0]!.parentListingId = 123; + setup.input.bookings[0]!.packageGroupId = 456; + + expect(await setup.run()).toEqual({ success: true }); + const row = await execute( + `SELECT parent_listing_id, package_group_id + FROM listing_attendees WHERE attendee_id = ?`, + [setup.stage.attendeeId], + ); + expect( + row.rows.map((value) => [ + value.parent_listing_id, + value.package_group_id, + ]), + ).toEqual([[123, 456]]); + }); + + test("rejects a stage whose row is already live", async () => { + const setup = await activate("cs_activate_live"); + await execute( + "UPDATE listing_attendees SET quantity = 1 WHERE attendee_id = ?", + [setup.stage.attendeeId], + ); + + await expect(setup.run()).rejects.toThrow( + `Checkout stage ${setup.stage.attendeeId} is already active`, + ); + }); + + test("rejects a stage whose booking paths changed", async () => { + const setup = await activate("cs_activate_changed"); + await execute( + "UPDATE listing_attendees SET package_group_id = 999999 WHERE attendee_id = ?", + [setup.stage.attendeeId], + ); + + await expect(setup.run()).rejects.toThrow( + `Checkout stage ${setup.stage.attendeeId} booking lines changed`, + ); + }); + + test("leaves the stage at zero when capacity was taken", async () => { + const sessionId = "cs_activate_full"; + const setup = await setupStage(sessionId, 1); + const filler = await bookAttendee(setup.listing, { + email: "filler@example.com", + name: "Filler", + }); + if (!filler.success) throw new Error("Expected filler booking"); + await reserveSession(sessionId); + + expect( + await activateStagedBooking( + sessionId, + setup.stage.attendeeId, + setup.stage.ticketToken, + setup.input, + setup.plan, + ), + ).toEqual({ reason: "capacity_exceeded", success: false }); + }); + + test("reports an extra that sold out", async () => { + const setup = await activate("cs_activate_extra"); + const modifier = await modifiersTable.insert({ + calcKind: "fixed", + calcValue: 1, + direction: "charge", + name: "Last extra", + stock: 1, + }); + await execute( + `INSERT INTO modifier_usages + (modifier_id, attendee_id, quantity, amount_applied, created) + VALUES (?, 999999, 1, 100, '2026-07-12T00:00:00.000Z')`, + [modifier.id], + ); + setup.plan.usages.push({ + amountApplied: 100, + modifierId: modifier.id, + quantity: 1, + }); + + expect(await setup.run()).toEqual({ reason: "sold-out", success: false }); + }); + + test("records available extra usage during activation", async () => { + const setup = await activate("cs_activate_extra_ok"); + const modifier = await modifiersTable.insert({ + calcKind: "fixed", + calcValue: 1, + direction: "charge", + name: "Available extra", + stock: 1, + }); + setup.plan.usages.push({ + amountApplied: 100, + modifierId: modifier.id, + quantity: 1, + }); + + expect(await setup.run()).toEqual({ success: true }); + expect(await modifierUsedQuantities([modifier.id])).toEqual( + new Map([[modifier.id, 1]]), + ); + }); + + test("fails loudly when attendee encryption is unavailable", async () => { + const setup = await activate("cs_activate_encryption"); + await execute("DELETE FROM settings WHERE key = ?", [ + CONFIG_KEYS.PUBLIC_KEY, + ]); + settings.invalidateCache(); + + await expect(setup.run()).rejects.toThrow( + "Could not encrypt staged attendee", + ); + }); + + test("rolls back when payment finalization is lost", async () => { + const setup = await activate("cs_activate_finalize"); + await execute( + `CREATE TRIGGER lose_activation_finalize + BEFORE UPDATE OF quantity ON listing_attendees + BEGIN + DELETE FROM processed_payments + WHERE payment_session_id = 'cs_activate_finalize'; + END`, + ); + + await expect(setup.run()).rejects.toThrow( + "Payment session cs_activate_finalize was not finalized", + ); + const row = await execute( + "SELECT quantity FROM listing_attendees WHERE attendee_id = ?", + [setup.stage.attendeeId], + ); + expect(row.rows.map((value) => value.quantity)).toEqual([0]); + }); +}); diff --git a/test/shared/db/attendees/create-errors.test.ts b/test/shared/db/attendees/create-errors.test.ts new file mode 100644 index 0000000000..93ebc3413f --- /dev/null +++ b/test/shared/db/attendees/create-errors.test.ts @@ -0,0 +1,86 @@ +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { createBookingAtomic } from "#shared/db/attendees/api.ts"; +import { createAttendeeAtomicImpl } from "#shared/db/attendees/create.ts"; +import { execute, queryOne } from "#shared/db/client.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; +import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { tx } from "#test-utils/transfer-factory.ts"; + +describeWithEnv("db > create booking errors", { db: true }, () => { + test("propagates an unexpected database write failure", async () => { + const listing = await createTestListing({ maxAttendees: 2 }); + await execute(`CREATE TRIGGER fail_attendee_create + BEFORE INSERT ON attendees + BEGIN + SELECT RAISE(ABORT, 'unexpected create failure'); + END`); + + await expect( + createBookingAtomic( + { + bookings: [{ listingId: listing.id, pricePaid: 1000, quantity: 1 }], + email: "failure@example.com", + name: "Failure", + ticketToken: "FAILURETOKEN", + }, + { finalize: null, legs: [], usages: [] }, + ), + ).rejects.toThrow("unexpected create failure"); + }); + + test("stamps a single batch ledger leg on the booking", async () => { + const listing = await createTestListing({ maxAttendees: 2 }); + const result = await createBookingAtomic( + { + bookings: [{ listingId: listing.id, pricePaid: 1000, quantity: 1 }], + email: "one-leg@example.com", + name: "One leg", + ticketToken: "ONELEGTOKEN", + }, + { + finalize: null, + legs: [tx({ eventGroup: "one-leg", reference: "one-leg-ref" })], + usages: [], + }, + ); + if (result === "sold-out" || !result.success) { + throw new Error("Expected one-leg booking"); + } + + const row = await queryOne<{ ledger_event_group: string }>( + "SELECT ledger_event_group FROM listing_attendees WHERE attendee_id = ?", + [result.attendees[0]!.id], + ); + expect(row?.ledger_event_group).toBe("one-leg"); + }); + + test("rolls back an interactive partial booking before posting the ledger", async () => { + const open = await createTestListing({ maxAttendees: 2 }); + const full = await createTestListing({ maxAttendees: 0 }); + let posted = false; + + const result = await createAttendeeAtomicImpl( + { + bookings: [ + { listingId: open.id, quantity: 1 }, + { listingId: full.id, quantity: 1 }, + ], + email: "interactive@example.com", + name: "Interactive", + }, + () => { + posted = true; + return Promise.resolve(); + }, + ); + + expect(result).toEqual({ reason: "capacity_exceeded", success: false }); + expect(posted).toBe(false); + const rows = await execute( + "SELECT COUNT(*) AS count FROM listing_attendees WHERE listing_id IN (?, ?)", + [open.id, full.id], + ); + expect(Number(rows.rows[0]!.count)).toBe(0); + }); +}); diff --git a/test/shared/db/attendees/create.test.ts b/test/shared/db/attendees/create.test.ts index 9cef8f9b95..b6d5029d52 100644 --- a/test/shared/db/attendees/create.test.ts +++ b/test/shared/db/attendees/create.test.ts @@ -17,8 +17,8 @@ import type { PricedLine, PricedOrder, } from "#shared/checkout-pricing.ts"; -import { createBookingAtomic } from "#shared/db/attendees/api.ts"; -import type { BookingBatchPlan } from "#shared/db/attendees/create.ts"; +import { createBookingAtomic as createBookingAtomicImpl } from "#shared/db/attendees/api.ts"; +import type { BookingBatchPlan } from "#shared/db/attendees/create-batch.ts"; import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; import { queryOne, withTransaction } from "#shared/db/client.ts"; import { modifierUsedQuantities } from "#shared/db/modifier-usage.ts"; @@ -30,6 +30,11 @@ import { import { describeWithEnv } from "#test-utils/db.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +const createBookingAtomic = ( + input: Parameters[0], + plan: Parameters[1], +) => createBookingAtomicImpl({ ...input, ticketToken: "BATCHTOKEN" }, plan); + /** Narrow a createBookingAtomic result to the successful shape, or fail the test. */ const expectBookingOk = ( result: Awaited>, @@ -104,11 +109,12 @@ const buildPlan = async (opts: { total: opts.total ?? 0, }); const plan = await bookingBatchPlan( + null, usages, { eventId: opts.eventId, occurredAt: OCCURRED_AT, pricedOrder }, opts.sessionId ? { paymentReference: `pi_${opts.sessionId}`, sessionId: opts.sessionId } - : undefined, + : null, ); return { plan, pricedOrder }; }; @@ -366,6 +372,7 @@ describeWithEnv("db > createBookingAtomic", { db: true }, () => { sessionId: "sess_batch_existing_ledger", total: 500, }); + plan.legs = [plan.legs[0]!]; await postTransfers(plan.legs); await expectCapacityExceeded(plan, listing.id, 500, plan.legs.length); @@ -374,7 +381,7 @@ describeWithEnv("db > createBookingAtomic", { db: true }, () => { ).toBe(null); }); - test("posts no legs and does not finalize when a multi-listing cart only partly lands", async () => { + test("rolls back every row when a multi-listing cart only partly lands", async () => { const open = await createTestListing({ maxAttendees: 5, unitPrice: 500 }); const full = await createTestListing({ maxAttendees: 0, unitPrice: 500 }); const { plan } = await buildPlan({ @@ -397,10 +404,9 @@ describeWithEnv("db > createBookingAtomic", { db: true }, () => { plan, ); - // Greedy create: the open listing's booking landed, the full one didn't. - expect(expectBookingOk(result).attendees.length).toBe(1); - // The all-bookings-landed guard held back every leg and the finalize, so the - // caller's ensureAllBookings can roll the partial booking back cleanly. + expect(result).toEqual({ reason: "capacity_exceeded", success: false }); + expect(await getAttendeesRaw(open.id)).toEqual([]); + expect(await getAttendeesRaw(full.id)).toEqual([]); expect((await allTransfers()).length).toBe(0); expect((await isSessionProcessed("sess_batch_partial"))!.attendee_id).toBe( null, diff --git a/test/shared/db/attendees/pii.test.ts b/test/shared/db/attendees/pii.test.ts index a21dce1d4a..3efa3c56fb 100644 --- a/test/shared/db/attendees/pii.test.ts +++ b/test/shared/db/attendees/pii.test.ts @@ -164,8 +164,8 @@ describeWithEnv("PII crypto", { db: true }, () => { expect(pii.lng).toBe(""); }); - test("encryptAttendeeFields encrypts blank coordinates that decrypt back to empty", async () => { - const result = await encryptAttendeeFields(encInput); + test("encryptAttendeeFields encrypts the exact supplied ticket token", async () => { + const result = await encryptAttendeeFields(encInput, "PIITOKEN01"); expect(result).not.toBeNull(); const pii = await decryptPiiBlob( result!.encryptedPiiBlob, @@ -175,6 +175,8 @@ describeWithEnv("PII crypto", { db: true }, () => { expect(pii.lat).toBe(""); expect(pii.lng).toBe(""); expect(pii.payment_id).toBe("pay_pii"); + expect(pii.ticket_token).toBe("PIITOKEN01"); + expect(result!.ticketToken).toBe("PIITOKEN01"); }); test("encryptAttendeeFields returns null when no public key is configured", async () => { @@ -184,7 +186,7 @@ describeWithEnv("PII crypto", { db: true }, () => { }); settings.invalidateCache(); - const result = await encryptAttendeeFields(encInput); + const result = await encryptAttendeeFields(encInput, "PIITOKEN02"); expect(result).toBeNull(); }); diff --git a/test/shared/db/attendees/servicing/editing.test.ts b/test/shared/db/attendees/servicing/editing.test.ts index c14fa1dd2a..7f4c3bd647 100644 --- a/test/shared/db/attendees/servicing/editing.test.ts +++ b/test/shared/db/attendees/servicing/editing.test.ts @@ -16,6 +16,7 @@ import { expect } from "@std/expect"; import { it as test } from "@std/testing/bdd"; import { ATTENDEE_KIND, SERVICING_KIND } from "#shared/db/attendees/kind.ts"; +import { getDb } from "#shared/db/client.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { createDailyTestListing } from "#test-utils/db-helpers/listings.ts"; import { @@ -45,6 +46,32 @@ describeWithEnv("servicing §4 — editing", { db: true }, () => { expect(await tokenIndexOf(event.id)).toBe(before); }); + test("editing preserves the booking row and its line state", async () => { + const { event, listing } = await createServicingHold({ quantity: 2 }); + await getDb().execute({ + args: [event.id, listing.id], + sql: `UPDATE listing_attendees + SET checked_in = 1, attachment_downloads = 7 + WHERE attendee_id = ? AND listing_id = ?`, + }); + const readLine = () => + getDb().execute({ + args: [event.id, listing.id], + sql: `SELECT id, checked_in, attachment_downloads + FROM listing_attendees + WHERE attendee_id = ? AND listing_id = ?`, + }); + const before = (await readLine()).rows[0]!; + + await updateServicingEvent(event.id, { + bookings: [{ listingId: listing.id, quantity: 2 }], + name: "Renamed service", + }); + + const after = (await readLine()).rows[0]!; + expect(after).toEqual(before); + }); + test("editing updates name and bookings (changed qty, removed listing)", async () => { const [a, b] = await createDailyListingPair("A", "B"); const { createTestServicingEvent } = await import( diff --git a/test/shared/db/checkout-stages.test.ts b/test/shared/db/checkout-stages.test.ts new file mode 100644 index 0000000000..17d7ef5e0a --- /dev/null +++ b/test/shared/db/checkout-stages.test.ts @@ -0,0 +1,183 @@ +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { stub } from "@std/testing/mock"; +import { + createStagedCheckout, + discardPendingCheckoutSessions, + getCheckoutStage, + markCheckoutStage, + prunePendingCheckoutStages, + stageCheckout, +} from "#shared/db/checkout-stages.ts"; +import { getDb } from "#shared/db/client.ts"; +import { reserveSession } from "#shared/db/processed-payments.ts"; +import { stripePaymentProvider } from "#shared/stripe-provider.ts"; +import { checkoutIntent, checkoutItem } from "#test-utils/checkout.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; +import { createTestListing } from "#test-utils/db-helpers/listings.ts"; + +const intentFor = (listing: { id: number; name: string; slug: string }) => + checkoutIntent({ + items: [ + checkoutItem({ + listingId: listing.id, + name: listing.name, + slug: listing.slug, + }), + ], + }); + +describeWithEnv("db > checkout stages", { db: true }, () => { + test("fails loudly when the listing vanishes before local staging", async () => { + const listing = await createTestListing({ unitPrice: 1000 }); + using _checkout = stub( + stripePaymentProvider, + "createCheckoutSession", + async () => { + await getDb().execute("DELETE FROM listings WHERE id = ?", [ + listing.id, + ]); + return { + checkoutUrl: "https://stripe.example/vanished", + sessionId: "cs_stage_vanished", + }; + }, + ); + + await expect( + createStagedCheckout( + stripePaymentProvider, + intentFor(listing), + "https://example.com", + ), + ).rejects.toThrow(`Listing ${listing.id} vanished before checkout`); + }); + + test("fails loudly when a quantity-zero stage cannot be written", async () => { + const listing = await createTestListing({ unitPrice: 1000 }); + using _checkout = stub( + stripePaymentProvider, + "createCheckoutSession", + async () => { + await getDb().execute("UPDATE listings SET active = 0 WHERE id = ?", [ + listing.id, + ]); + return { + checkoutUrl: "https://stripe.example/inactive", + sessionId: "cs_stage_inactive", + }; + }, + ); + + await expect( + createStagedCheckout( + stripePaymentProvider, + intentFor(listing), + "https://example.com", + ), + ).rejects.toThrow("Could not stage checkout: capacity_exceeded"); + }); + + test("fails loudly when a staged ticket token is missing", async () => { + const listing = await createTestListing({ unitPrice: 1000 }); + using _checkout = stub(stripePaymentProvider, "createCheckoutSession", () => + Promise.resolve({ + checkoutUrl: "https://stripe.example/corrupt", + sessionId: "cs_stage_corrupt", + }), + ); + await createStagedCheckout( + stripePaymentProvider, + intentFor(listing), + "https://example.com", + ); + await getDb().execute( + "UPDATE checkout_stages SET ticket_tokens = '' WHERE payment_session_id = ?", + ["cs_stage_corrupt"], + ); + + await expect(getCheckoutStage("cs_stage_corrupt")).rejects.toThrow( + "Checkout stage cs_stage_corrupt has no token", + ); + }); + + test("round-trips booked and failed stage states", async () => { + const listing = await createTestListing({ unitPrice: 1000 }); + using _checkout = stub(stripePaymentProvider, "createCheckoutSession", () => + Promise.resolve({ + checkoutUrl: "https://stripe.example/states", + sessionId: "cs_stage_states", + }), + ); + await createStagedCheckout( + stripePaymentProvider, + intentFor(listing), + "https://example.com", + ); + + await markCheckoutStage("cs_stage_states", "booked"); + expect((await getCheckoutStage("cs_stage_states"))?.state).toBe("booked"); + await markCheckoutStage("cs_stage_states", "failed"); + expect((await getCheckoutStage("cs_stage_states"))?.state).toBe("failed"); + }); + + test("discards an array of cancelled pending stages", async () => { + const listing = await createTestListing({ unitPrice: 1000 }); + const first = await stageCheckout( + "cs_stage_discard_first", + "stripe", + intentFor(listing), + ); + const second = await stageCheckout( + "cs_stage_discard_second", + "stripe", + intentFor(listing), + ); + + expect( + await discardPendingCheckoutSessions([ + "cs_stage_discard_first", + "cs_stage_discard_second", + ]), + ).toBe(2); + expect(await getCheckoutStage("cs_stage_discard_first")).toBeNull(); + expect(await getCheckoutStage("cs_stage_discard_second")).toBeNull(); + const attendees = await getDb().execute({ + args: [first.attendeeId, second.attendeeId], + sql: "SELECT id FROM attendees WHERE id IN (?, ?)", + }); + expect(attendees.rows).toEqual([]); + }); + + test("keeps a pending stage once payment processing claims it", async () => { + const listing = await createTestListing({ unitPrice: 1000 }); + await stageCheckout("cs_stage_claimed", "stripe", intentFor(listing)); + await reserveSession("cs_stage_claimed"); + + expect(await discardPendingCheckoutSessions(["cs_stage_claimed"])).toBe(0); + expect((await getCheckoutStage("cs_stage_claimed"))?.state).toBe("pending"); + }); + + test("prunes only old pending stages", async () => { + const listing = await createTestListing({ unitPrice: 1000 }); + await stageCheckout("cs_stage_old", "stripe", intentFor(listing)); + await stageCheckout("cs_stage_recent", "stripe", intentFor(listing)); + await stageCheckout("cs_stage_failed", "stripe", intentFor(listing)); + await getDb().execute( + "UPDATE checkout_stages SET created_at = ? WHERE payment_session_id = ?", + ["2000-01-01T00:00:00.000Z", "cs_stage_old"], + ); + await markCheckoutStage("cs_stage_failed", "failed"); + + expect(await prunePendingCheckoutStages("2020-01-01T00:00:00.000Z")).toBe( + 1, + ); + expect(await getCheckoutStage("cs_stage_old")).toBeNull(); + expect((await getCheckoutStage("cs_stage_recent"))?.state).toBe("pending"); + expect((await getCheckoutStage("cs_stage_failed"))?.state).toBe("failed"); + }); + + test("discarding no sessions is a no-op", async () => { + expect(await discardPendingCheckoutSessions([])).toBe(0); + }); +}); diff --git a/test/shared/db/contact-preferences.test.ts b/test/shared/db/contact-preferences.test.ts index c964ef8210..d653de57a1 100644 --- a/test/shared/db/contact-preferences.test.ts +++ b/test/shared/db/contact-preferences.test.ts @@ -16,16 +16,15 @@ import { hashPhone, isHashUnsubscribed, recordContacts, - recordVisit, resubscribeHash, saveContactRecord, toContactHashParam, - unrecordVisit, unsubscribeHash, } from "#shared/db/contact-preferences.ts"; import { settings } from "#shared/db/settings.ts"; import { getTestPrivateKey } from "#test-utils/crypto.ts"; import { describeWithEnv } from "#test-utils/db.ts"; +import { seedContactVisits } from "#test-utils/db-helpers/contacts.ts"; const rowFor = ( hash: string, @@ -138,7 +137,7 @@ describeWithEnv("contact-preferences: unsubscribe state", { db: true }, () => { const two = await hashEmail("two@example.com"); await unsubscribeHash(one); await unsubscribeHash(two); - await recordVisit(await hashEmail("seeded@example.com")); + await seedContactVisits(await hashEmail("seeded@example.com")); const set = await getUnsubscribedHashSet(); @@ -158,38 +157,18 @@ describeWithEnv("contact-preferences: unsubscribe state", { db: true }, () => { }); describeWithEnv("contact-preferences: visit counter", { db: true }, () => { - test("recordVisit seeds a row at visits 1 and sets last_activity", async () => { - const hash = await hashEmail("first@example.com"); - const before = Date.now(); - await recordVisit(hash); - const row = await rowFor(hash); - expect(row?.visits).toBe(1); - expect(row?.last_activity).toBeGreaterThanOrEqual(before); - expect(row?.last_activity).toBeLessThanOrEqual(Date.now()); - }); - - test("recordVisit increments visits once per call", async () => { - const hash = await hashEmail("repeat@example.com"); - await recordVisit(hash); - await recordVisit(hash); - await recordVisit(hash); - expect((await rowFor(hash))?.visits).toBe(3); - }); - test("getVisits reads the plaintext count, 0 when absent", async () => { const hash = await hashEmail("counted@example.com"); expect(await getVisits(hash)).toBe(0); - await recordVisit(hash); - await recordVisit(hash); + await seedContactVisits(hash, 2); expect(await getVisits(hash)).toBe(2); }); - test("recordVisit on a phone hash counts separately from email", async () => { + test("getVisits counts phone and email hashes separately", async () => { const email = await hashEmail("dual@example.com"); const phone = await hashPhone("07700 900111"); - await recordVisit(email); - await recordVisit(phone); - await recordVisit(phone); + await seedContactVisits(email); + await seedContactVisits(phone, 2); expect(await getVisits(email)).toBe(1); expect(await getVisits(phone)).toBe(2); }); @@ -199,8 +178,8 @@ describeWithEnv("contact-preferences: erasure", { db: true }, () => { test("forgetContact deletes only the targeted hash", async () => { const target = await hashEmail("forget@example.com"); const keep = await hashEmail("keep@example.com"); - await recordVisit(target); - await recordVisit(keep); + await seedContactVisits(target); + await seedContactVisits(keep); await forgetContact(target); @@ -210,7 +189,7 @@ describeWithEnv("contact-preferences: erasure", { db: true }, () => { test("forgetContact reports one deleted row when a record existed", async () => { const hash = await hashEmail("counted@example.com"); - await recordVisit(hash); + await seedContactVisits(hash); expect(await forgetContact(hash)).toBe(1); }); @@ -242,7 +221,7 @@ describeWithEnv("contact-preferences: contact history", { db: true }, () => { test("a visited address has zero contacts", async () => { const pk = await getTestPrivateKey(); const hash = await hashEmail("booked@example.com"); - await recordVisit(hash); + await seedContactVisits(hash); expect((await getContactRecord(hash, pk)).contactCount).toBe(0); }); @@ -306,8 +285,7 @@ describeWithEnv("contact-preferences: contact history", { db: true }, () => { test("recordContacts sets last_activity without touching visits", async () => { const pk = await getTestPrivateKey(); const hash = await hashEmail("outreach@example.com"); - await recordVisit(hash); - await recordVisit(hash); + await seedContactVisits(hash, 2); const before = Date.now(); await recordContacts([hash], "Newsletter", pk); const row = await rowFor(hash); @@ -330,14 +308,6 @@ describeWithEnv("contact-preferences: contact history", { db: true }, () => { await recordContacts([], "Nothing", pk); }); - test("unrecordVisit reverses a recordVisit, clamped at zero", async () => { - const hash = await hashEmail("undovisit@example.com"); - await recordVisit(hash); - await unrecordVisit(hash); - await unrecordVisit(hash); - expect(await getVisits(hash)).toBe(0); - }); - test("saveContactRecord overwrites the counts and the encrypted note", async () => { const pk = await getTestPrivateKey(); const hash = await hashEmail("notes@example.com"); diff --git a/test/shared/db/contact-token-activity.test.ts b/test/shared/db/contact-token-activity.test.ts new file mode 100644 index 0000000000..3d2e2312fa --- /dev/null +++ b/test/shared/db/contact-token-activity.test.ts @@ -0,0 +1,103 @@ +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { queryOne } from "#shared/db/client.ts"; +import { + getContactRecord, + hashEmail, + hashPhone, + recordContacts, +} from "#shared/db/contact-preferences.ts"; +import { + getRecentBookingTokens, + recordBookingActivity, + recordOrderActivity, +} from "#shared/db/contact-tokens.ts"; +import { getTestPrivateKey } from "#test-utils/crypto.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; + +const expectOneBookingVisit = async ( + hash: string, + token: string, +): Promise => { + const privateKey = await getTestPrivateKey(); + const record = await getContactRecord(hash, privateKey); + expect({ + publicBookingCount: record.publicBookingCount, + visits: record.visits, + }).toEqual({ publicBookingCount: 1, visits: 1 }); + expect(await getRecentBookingTokens(hash, privateKey, 1)).toEqual([ + { source: "public", token }, + ]); +}; + +const firstMarkerFor = async (hash: string): Promise => { + const row = await queryOne<{ attendee_tokens_blob: string }>( + "SELECT attendee_tokens_blob FROM contact_preferences WHERE contact_hash = ?", + [hash], + ); + return row!.attendee_tokens_blob.split("\n")[0]!.split("\t")[0]!; +}; + +describeWithEnv("contact booking activity", { db: true }, () => { + test("splits booking counts by source without changing outreach stats", async () => { + const privateKey = await getTestPrivateKey(); + const hash = await hashEmail("bookings@example.com"); + await recordContacts([hash], "Newsletter", privateKey); + await recordBookingActivity(hash, "public", "tok-pub-1"); + await recordBookingActivity(hash, "public", "tok-pub-2"); + await recordBookingActivity(hash, "admin", "tok-adm-1"); + + const record = await getContactRecord(hash, privateKey); + expect({ + adminBookingCount: record.adminBookingCount, + contactCount: record.contactCount, + lastSubject: record.lastSubject, + publicBookingCount: record.publicBookingCount, + }).toEqual({ + adminBookingCount: 1, + contactCount: 1, + lastSubject: "Newsletter", + publicBookingCount: 2, + }); + expect( + await getRecentBookingTokens(hash, privateKey, Number.MAX_SAFE_INTEGER), + ).toEqual([ + { source: "public", token: "tok-pub-1" }, + { source: "public", token: "tok-pub-2" }, + { source: "admin", token: "tok-adm-1" }, + ]); + }); + + test("records missing booking history without an owner key", async () => { + const hash = await hashEmail("keyless@example.com"); + await recordBookingActivity(hash, "public", "tok-keyless"); + await expectOneBookingVisit(hash, "tok-keyless"); + }); + + test("does not duplicate completed booking history", async () => { + const hash = await hashEmail("recover-complete@example.com"); + await recordBookingActivity(hash, "public", "tok-recover-complete"); + await recordBookingActivity(hash, "public", "tok-recover-complete"); + await expectOneBookingVisit(hash, "tok-recover-complete"); + }); + + test("recordOrderActivity records one replay-safe visit for email and phone", async () => { + const emailHash = await hashEmail("linked-token@example.com"); + const phoneHash = await hashPhone("07700 900111"); + const record = () => + recordOrderActivity( + "linked-token@example.com", + "07700 900111", + "public", + "tok-linked-contact", + ); + await record(); + await record(); + + expect(await firstMarkerFor(emailHash)).not.toBe( + await firstMarkerFor(phoneHash), + ); + await expectOneBookingVisit(emailHash, "tok-linked-contact"); + await expectOneBookingVisit(phoneHash, "tok-linked-contact"); + }); +}); diff --git a/test/shared/db/contact-tokens.test.ts b/test/shared/db/contact-tokens.test.ts index 7a5a6b2989..bdcc9aa51a 100644 --- a/test/shared/db/contact-tokens.test.ts +++ b/test/shared/db/contact-tokens.test.ts @@ -6,13 +6,11 @@ import { getContactRecord, hashEmail, hashPhone, - recordContacts, } from "#shared/db/contact-preferences.ts"; import { getRecentBookingTokens, - recordBooking, + recordBookingActivity, syncAttendeeContactTokens, - unrecordBooking, } from "#shared/db/contact-tokens.ts"; import { getTestPrivateKey } from "#test-utils/crypto.ts"; import { describeWithEnv } from "#test-utils/db.ts"; @@ -48,73 +46,7 @@ const tokenBlobFor = async (hash: string): Promise => ) )?.attendee_tokens_blob ?? ""; -const firstMarkerFrom = (blob: string): string => - blob.split("\n")[0]!.split("\t")[0]!; - describeWithEnv("contact-tokens", { db: true }, () => { - test("recordBooking splits the count by source, leaving outreach stats intact", async () => { - const pk = await getTestPrivateKey(); - const hash = await hashEmail("bookings@example.com"); - await recordContacts([hash], "Newsletter", pk); - await recordBooking(hash, "public", "tok-pub-1"); - await recordBooking(hash, "public", "tok-pub-2"); - await recordBooking(hash, "admin", "tok-adm-1"); - - const record = await getContactRecord(hash, pk); - expect(record.publicBookingCount).toBe(2); - expect(record.adminBookingCount).toBe(1); - expect(record.contactCount).toBe(1); - expect(record.lastSubject).toBe("Newsletter"); - }); - - test("recordBooking needs no owner key", async () => { - const pk = await getTestPrivateKey(); - const hash = await hashEmail("keyless@example.com"); - await recordBooking(hash, "public", "tok-keyless"); - expect((await getContactRecord(hash, pk)).publicBookingCount).toBe(1); - }); - - test("unrecordBooking reverses a recordBooking and clamps at zero", async () => { - const pk = await getTestPrivateKey(); - const hash = await hashEmail("undo@example.com"); - await recordBooking(hash, "public", "tok-undo-1"); - await recordBooking(hash, "public", "tok-undo-2"); - await unrecordBooking(hash, "public"); - expect((await getContactRecord(hash, pk)).publicBookingCount).toBe(1); - await unrecordBooking(hash, "public"); - await unrecordBooking(hash, "public"); - expect((await getContactRecord(hash, pk)).publicBookingCount).toBe(0); - }); - - test("recordBooking appends the booked token, tagged by source", async () => { - const pk = await getTestPrivateKey(); - const hash = await hashEmail("tokens@example.com"); - await recordBooking(hash, "public", "tok-online"); - await recordBooking(hash, "admin", "tok-manual"); - expect(await readAllTokens(hash, pk)).toEqual([ - { source: "public", token: "tok-online" }, - { source: "admin", token: "tok-manual" }, - ]); - }); - - test("recordBooking does not reuse one marker across contact rows", async () => { - const pk = await getTestPrivateKey(); - const emailHash = await hashEmail("linked-token@example.com"); - const phoneHash = await hashPhone("07700 900111"); - await recordBooking(emailHash, "public", "tok-linked-contact"); - await recordBooking(phoneHash, "public", "tok-linked-contact"); - - expect(firstMarkerFrom(await tokenBlobFor(emailHash))).not.toBe( - firstMarkerFrom(await tokenBlobFor(phoneHash)), - ); - expect(await readAllTokens(emailHash, pk)).toEqual([ - { source: "public", token: "tok-linked-contact" }, - ]); - expect(await readAllTokens(phoneHash, pk)).toEqual([ - { source: "public", token: "tok-linked-contact" }, - ]); - }); - test("getRecentBookingTokens is empty for a contact with no bookings", async () => { const pk = await getTestPrivateKey(); expect( @@ -129,8 +61,8 @@ describeWithEnv("contact-tokens", { db: true }, () => { "INSERT INTO contact_preferences (contact_hash, last_activity, attendee_tokens_blob) VALUES (?, ?, ?)", [hash, 1, "not-an-owner-key-token\n"], ); - await recordBooking(hash, "public", "tok-newer"); - await recordBooking(hash, "admin", "tok-newest"); + await recordBookingActivity(hash, "public", "tok-newer"); + await recordBookingActivity(hash, "admin", "tok-newest"); expect(await getRecentBookingTokens(hash, pk, 1)).toEqual([ { source: "admin", token: "tok-newest" }, @@ -151,7 +83,7 @@ describeWithEnv("contact-tokens", { db: true }, () => { test("syncAttendeeContactTokens appends without bumping counts", async () => { const pk = await getTestPrivateKey(); const hash = await hashEmail("readd@example.com"); - await recordBooking(hash, "public", "tok-first"); + await recordBookingActivity(hash, "public", "tok-first"); await syncToken( "tok-moved", { email: "readd@example.com", phone: "" }, @@ -171,7 +103,7 @@ describeWithEnv("contact-tokens", { db: true }, () => { const pk = await getTestPrivateKey(); const oldHash = await hashEmail("old@example.com"); const newHash = await hashEmail("new@example.com"); - await recordBooking(oldHash, "admin", "tok-move"); + await recordBookingActivity(oldHash, "admin", "tok-move"); await syncToken( "tok-move", { email: "old@example.com", phone: "" }, @@ -194,7 +126,7 @@ describeWithEnv("contact-tokens", { db: true }, () => { "INSERT INTO contact_preferences (contact_hash, last_activity, attendee_tokens_blob) VALUES (?, ?, ?)", [oldHash, 1, "not-an-owner-key-token\n"], ); - await recordBooking(oldHash, "public", "tok-marker-move"); + await recordBookingActivity(oldHash, "public", "tok-marker-move"); await syncToken( "tok-marker-move", @@ -213,7 +145,7 @@ describeWithEnv("contact-tokens", { db: true }, () => { const pk = await getTestPrivateKey(); const oldHash = await hashPhone("07700 900001"); const newHash = await hashPhone("07700 900002"); - await recordBooking(oldHash, "public", "tok-phone"); + await recordBookingActivity(oldHash, "public", "tok-phone"); await syncToken( "tok-phone", { email: "", phone: "07700 900001" }, @@ -229,7 +161,7 @@ describeWithEnv("contact-tokens", { db: true }, () => { test("syncAttendeeContactTokens does not duplicate an unchanged token", async () => { const pk = await getTestPrivateKey(); const hash = await hashEmail("same@example.com"); - await recordBooking(hash, "public", "tok-same"); + await recordBookingActivity(hash, "public", "tok-same"); await syncToken( "tok-same", { email: "same@example.com", phone: "" }, @@ -244,8 +176,8 @@ describeWithEnv("contact-tokens", { db: true }, () => { test("syncAttendeeContactTokens does not reorder history on an unchanged edit", async () => { const pk = await getTestPrivateKey(); const hash = await hashEmail("order@example.com"); - await recordBooking(hash, "public", "tok-first"); - await recordBooking(hash, "admin", "tok-second"); + await recordBookingActivity(hash, "public", "tok-first"); + await recordBookingActivity(hash, "admin", "tok-second"); // Re-sync tok-first with the contact unchanged (before === after): the // token must stay in place, not be removed-and-re-appended to the end. await syncToken( @@ -264,7 +196,7 @@ describeWithEnv("contact-tokens", { db: true }, () => { const pk = await getTestPrivateKey(); const email = "erased-token@example.com"; const hash = await hashEmail(email); - await recordBooking(hash, "public", "tok-erased"); + await recordBookingActivity(hash, "public", "tok-erased"); expect(await forgetContact(hash)).toBe(1); await syncToken( @@ -285,7 +217,7 @@ describeWithEnv("contact-tokens", { db: true }, () => { test("syncAttendeeContactTokens drops the link when a field is cleared", async () => { const pk = await getTestPrivateKey(); const oldHash = await hashEmail("cleared@example.com"); - await recordBooking(oldHash, "admin", "tok-clear"); + await recordBookingActivity(oldHash, "admin", "tok-clear"); await syncToken( "tok-clear", { email: "cleared@example.com", phone: "" }, @@ -341,8 +273,8 @@ describeWithEnv("contact-tokens", { db: true }, () => { const pk = await getTestPrivateKey(); const oldHash = await hashEmail("race-old@example.com"); const newHash = await hashEmail("race-new@example.com"); - await recordBooking(oldHash, "public", "tok-move"); - await recordBooking(oldHash, "admin", "tok-stay"); + await recordBookingActivity(oldHash, "public", "tok-move"); + await recordBookingActivity(oldHash, "admin", "tok-stay"); await syncToken( "tok-move", { email: "race-old@example.com", phone: "" }, @@ -392,7 +324,7 @@ describeWithEnv("contact-tokens", { db: true }, () => { test("removing the last moved token leaves an empty blob, not a stale newline", async () => { const pk = await getTestPrivateKey(); const hash = await hashEmail("blank-line@example.com"); - await recordBooking(hash, "public", "tok-only"); + await recordBookingActivity(hash, "public", "tok-only"); await syncToken( "tok-only", { email: "blank-line@example.com", phone: "" }, diff --git a/test/shared/db/listing-overview-stats.test.ts b/test/shared/db/listing-overview-stats.test.ts index 4d7c1f52dc..cf6186b77b 100644 --- a/test/shared/db/listing-overview-stats.test.ts +++ b/test/shared/db/listing-overview-stats.test.ts @@ -12,10 +12,7 @@ import { getListingWithCount, listingRevenueBreakdown, } from "#shared/db/listings.ts"; -import { - finalizeSession, - reserveSession, -} from "#shared/db/processed-payments.ts"; +import { reserveSession } from "#shared/db/processed-payments.ts"; import { isPaidListing } from "#shared/types.ts"; import { overviewStatsFromAttendees, @@ -28,6 +25,7 @@ import { createPaidTestAttendee, } from "#test-utils/db-helpers/attendee-payments.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { finalizeTestPaymentSession as finalizeSession } from "#test-utils/db-helpers/processed-payments.ts"; import { postListingSale, postWriteoffAdjustment } from "#test-utils/ledger.ts"; const checkIn = (attendeeId: number, listingId: number): Promise => diff --git a/test/shared/db/listings/delete.test.ts b/test/shared/db/listings/delete.test.ts index 06c7388564..0ed9456a7b 100644 --- a/test/shared/db/listings/delete.test.ts +++ b/test/shared/db/listings/delete.test.ts @@ -17,7 +17,6 @@ import { listingsTable, } from "#shared/db/listings.ts"; import { - finalizeSession as finalizePaymentSession, isSessionProcessed, reserveSession, } from "#shared/db/processed-payments.ts"; @@ -35,6 +34,7 @@ import { createTestAttributeWithOptions, } from "#test-utils/db-helpers/attributes.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { finalizeTestPaymentSession as finalizePaymentSession } from "#test-utils/db-helpers/processed-payments.ts"; import { withTestSession } from "#test-utils/session.ts"; describeWithEnv("db > listings", { db: true, triggers: true }, () => { diff --git a/test/shared/db/modifier-resolve.test.ts b/test/shared/db/modifier-resolve.test.ts index 0cfe33031b..37f1dbbd14 100644 --- a/test/shared/db/modifier-resolve.test.ts +++ b/test/shared/db/modifier-resolve.test.ts @@ -2,11 +2,7 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; import { hmacHash } from "#shared/crypto/hashing.ts"; import { toMinorUnits } from "#shared/currency.ts"; -import { - hashEmail, - hashPhone, - recordVisit, -} from "#shared/db/contact-preferences.ts"; +import { hashEmail, hashPhone } from "#shared/db/contact-preferences.ts"; import { ADDON_MAX_QUANTITY, type AddOnReachabilityCheck, @@ -27,6 +23,7 @@ import { answersTable, questionsTable } from "#shared/db/questions/tables.ts"; import { normalizeCode } from "#shared/price-modifier.ts"; import { checkoutItem } from "#test-utils/checkout.ts"; import { describeWithEnv } from "#test-utils/db.ts"; +import { seedContactVisits } from "#test-utils/db-helpers/contacts.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; import { consumeModifierStock, @@ -519,9 +516,8 @@ describeWithEnv("db > modifier-resolve", { db: true }, () => { }); test("reads the max visit count across email and phone", async () => { - await recordVisit(await hashEmail("seen@example.com")); - await recordVisit(await hashPhone("07700 900123")); - await recordVisit(await hashPhone("07700 900123")); + await seedContactVisits(await hashEmail("seen@example.com")); + await seedContactVisits(await hashPhone("07700 900123"), 2); expect(await buyerVisits("seen@example.com", "07700 900123")).toBe(2); }); diff --git a/test/shared/db/modifier-usage.test.ts b/test/shared/db/modifier-usage.test.ts index 0d6a808d0b..1d2e50ff3d 100644 --- a/test/shared/db/modifier-usage.test.ts +++ b/test/shared/db/modifier-usage.test.ts @@ -1,6 +1,9 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; -import { modifierUsedQuantities } from "#shared/db/modifier-usage.ts"; +import { + anyModifierSoldOut, + modifierUsedQuantities, +} from "#shared/db/modifier-usage.ts"; import { modifiersTable } from "#shared/db/modifiers.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { @@ -68,4 +71,30 @@ describeWithEnv("db > modifier-usage", { db: true }, () => { expect(await modifierUsedQuantities([])).toEqual(new Map()); }); }); + + describe("anyModifierSoldOut", () => { + test("returns false for no usages", async () => { + expect(await anyModifierSoldOut([])).toBe(false); + }); + + test("returns false for an unlimited modifier", async () => { + const modifier = await makeModifier(null); + expect(await anyModifierSoldOut([usage(modifier.id)])).toBe(false); + }); + + test("returns false for an unknown modifier", async () => { + expect(await anyModifierSoldOut([usage(999_999)])).toBe(false); + }); + + test("returns false while limited stock remains", async () => { + const modifier = await makeModifier(1); + expect(await anyModifierSoldOut([usage(modifier.id)])).toBe(false); + }); + + test("detects exhausted stock", async () => { + const modifier = await makeModifier(1); + await consumeModifierStock(100, [usage(modifier.id)]); + expect(await anyModifierSoldOut([usage(modifier.id)])).toBe(true); + }); + }); }); diff --git a/test/shared/db/payment-references.test.ts b/test/shared/db/payment-references.test.ts index 64688da011..7afe89dd8e 100644 --- a/test/shared/db/payment-references.test.ts +++ b/test/shared/db/payment-references.test.ts @@ -10,14 +10,12 @@ import { legacyMergePaymentReferenceStatement, markPaymentReferencesProviderRefunded, } from "#shared/db/payment-references.ts"; -import { - finalizeSession as finalizePaymentSession, - reserveSession, -} from "#shared/db/processed-payments.ts"; +import { reserveSession } from "#shared/db/processed-payments.ts"; import { getTestPrivateKey } from "#test-utils/crypto.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { bookAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { finalizeTestPaymentSession as finalizePaymentSession } from "#test-utils/db-helpers/processed-payments.ts"; describeWithEnv("db > payment references", { db: true }, () => { test("encrypts non-empty references and leaves empty references empty", async () => { diff --git a/test/shared/db/processed-payments.test.ts b/test/shared/db/processed-payments.test.ts index 2c09efe83a..e207d0bfb6 100644 --- a/test/shared/db/processed-payments.test.ts +++ b/test/shared/db/processed-payments.test.ts @@ -1,23 +1,24 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; +import { encrypt } from "#shared/crypto/encryption.ts"; import type { EnvKeyEncrypted } from "#shared/crypto/sealed.ts"; import { getDb, insert } from "#shared/db/client.ts"; import { batchFinalizeStatement } from "#shared/db/payment-finalize.ts"; import { decryptSessionTokens, - finalizeSession as finalizePaymentSession, finalizeSessionIfUnresolved, isSessionProcessed, + isUnresolvedReservation, markSessionFailed, parseSessionFailure, reserveSession, STALE_RESERVATION_MS, - setSessionTicketTokens, } from "#shared/db/processed-payments.ts"; import { nowMs } from "#shared/now.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { bookAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { finalizeTestPaymentSession as finalizePaymentSession } from "#test-utils/db-helpers/processed-payments.ts"; import { expectRefundReferences } from "#test-utils/payment-references.ts"; const finalizeSession = ( @@ -186,29 +187,33 @@ describeWithEnv("db > processed payments", { db: true }, () => { }); test("re-throws non-unique-constraint errors", async () => { - await getDb().execute("DROP TABLE processed_payments"); - - try { - await reserveSession("sess_error"); - throw new Error("should not reach here"); - } catch (e) { - expect(String(e)).not.toContain("should not reach here"); - expect(String(e)).not.toContain("UNIQUE constraint"); - } - - // Recreate the table for subsequent tests await getDb().execute(` - CREATE TABLE IF NOT EXISTS processed_payments ( - payment_session_id TEXT PRIMARY KEY, - attendee_id INTEGER, - processed_at TEXT NOT NULL, - ticket_tokens TEXT NOT NULL DEFAULT '', - failure_data TEXT NOT NULL DEFAULT '', - payment_reference TEXT NOT NULL DEFAULT '', - provider_refunded_at TEXT NOT NULL DEFAULT '', - FOREIGN KEY (attendee_id) REFERENCES attendees(id) - ) + CREATE TRIGGER reject_processed_payment_insert + BEFORE INSERT ON processed_payments + BEGIN + SELECT RAISE(ABORT, 'synthetic insert failure'); + END `); + + await expect(reserveSession("sess_error")).rejects.toThrow( + "synthetic insert failure", + ); + }); + + test("recognizes only attendee-less, outcome-less reservations as unresolved", async () => { + await reserveSession("sess_unresolved_shape"); + const reserved = (await isSessionProcessed("sess_unresolved_shape"))!; + + expect(isUnresolvedReservation(reserved)).toBe(true); + expect(isUnresolvedReservation({ ...reserved, attendee_id: 1 })).toBe( + false, + ); + expect( + isUnresolvedReservation({ + ...reserved, + failure_data: "recorded" as EnvKeyEncrypted, + }), + ).toBe(false); }); }); @@ -217,8 +222,7 @@ describeWithEnv("db > processed payments", { db: true }, () => { // unit test binds it as a literal `?` and uses a trivially-true guard, so it // exercises the UNRESOLVED + guard gating without an in-batch attendee row. const trueGuard = { args: [] as never[], sql: "1 = 1" }; - - test("sets attendee_id and clears ticket_tokens on an unresolved reservation", async () => { + test("sets the attendee and encrypted ticket token on an unresolved reservation", async () => { const listing = await createTestListing({ maxAttendees: 50 }); const attendeeResult = await bookAttendee(listing, { email: "fss@example.com", @@ -228,12 +232,17 @@ describeWithEnv("db > processed payments", { db: true }, () => { const attendeeId = attendeeResult.attendees[0]!.id; await reserveSession("sess_fss"); + await getDb().execute( + "UPDATE processed_payments SET ticket_tokens = ? WHERE payment_session_id = ?", + [await encrypt("tok-replacement"), "sess_fss"], + ); const stmt = await batchFinalizeStatement( "sess_fss", "?", attendeeId, trueGuard, "pi_fss", + "tok-fss", ); await getDb().execute(stmt); @@ -241,7 +250,8 @@ describeWithEnv("db > processed payments", { db: true }, () => { expect(row!.attendee_id).toBe(attendeeId); expect(row!.payment_reference).not.toContain("pi_fss"); await expectRefundReferences(attendeeId, ["pi_fss"]); - expect(row!.ticket_tokens).toBe(""); + expect(row!.ticket_tokens).not.toContain("tok-fss"); + expect(await decryptSessionTokens(row!.ticket_tokens)).toBe("tok-fss"); }); test("is a no-op when the session is already finalized", async () => { @@ -263,6 +273,7 @@ describeWithEnv("db > processed payments", { db: true }, () => { attendeeId + 999, trueGuard, "pi_second", + "tok-second", ); await getDb().execute(stmt); @@ -292,6 +303,7 @@ describeWithEnv("db > processed payments", { db: true }, () => { sql: "1 = 0", }, "pi_fss3", + "tok-fss3", ); await getDb().execute(stmt); @@ -301,42 +313,29 @@ describeWithEnv("db > processed payments", { db: true }, () => { }); }); - describe("setSessionTicketTokens", () => { - test("stores encrypted ticket tokens on a finalized session", async () => { - const listing = await createTestListing({ maxAttendees: 50 }); - const attendeeResult = await bookAttendee(listing, { - email: "stt@example.com", - name: "Stt", - }); - if (!attendeeResult.success) throw new Error("setup failed"); - const attendeeId = attendeeResult.attendees[0]!.id; - - await reserveSession("sess_stt"); - await finalizeSession("sess_stt", attendeeId, ["tok-test"]); - await setSessionTicketTokens("sess_stt", ["tok-abc"]); - - const row = await isSessionProcessed("sess_stt"); - // ticket_tokens is stored encrypted, not as plaintext - expect(row!.ticket_tokens).not.toBe(""); - expect(row!.ticket_tokens).not.toContain("tok-abc"); - }); - - test("is a no-op if the session was pruned", async () => { - // Should not throw even when the session row is absent - await setSessionTicketTokens("sess_nonexistent", ["tok-abc"]); - }); - }); - describe("finalizeSessionIfUnresolved", () => { test("stamps attendee_id on an unresolved reservation, leaving tokens untouched", async () => { await reserveSession("sess_heal"); - await finalizeSessionIfUnresolved("sess_heal", 42); + await finalizeSessionIfUnresolved("sess_heal", 42, ""); const row = (await isSessionProcessed("sess_heal"))!; expect(row.attendee_id).toBe(42); // The ledger-replay heal never writes ticket_tokens. expect(row.ticket_tokens).toBe(""); + expect(row.payment_reference).toBe(""); + }); + + test("stores a supplied payment reference on the healed reservation", async () => { + await reserveSession("sess_heal_reference"); + + await finalizeSessionIfUnresolved( + "sess_heal_reference", + 42, + "pi_heal_reference", + ); + + await expectRefundReferences(42, ["pi_heal_reference"]); }); test("is a no-op once resolved — preserves a racing delivery's attendee and tokens", async () => { @@ -347,7 +346,7 @@ describeWithEnv("db > processed payments", { db: true }, () => { // The replaying delivery tries to heal it to a different attendee; the // unresolved guard must make it a no-op so it never clobbers the winner's // ticket_tokens (which would render the success page without the ticket). - await finalizeSessionIfUnresolved("sess_raced", 99); + await finalizeSessionIfUnresolved("sess_raced", 99, ""); const row = (await isSessionProcessed("sess_raced"))!; expect(row.attendee_id).toBe(7); @@ -355,7 +354,11 @@ describeWithEnv("db > processed payments", { db: true }, () => { }); test("is a no-op if the session was pruned", async () => { - await finalizeSessionIfUnresolved("sess_gone", 1); + await finalizeSessionIfUnresolved("sess_gone", 1, ""); }); }); + + test("decryptSessionTokens returns an empty string for an empty field", async () => { + expect(await decryptSessionTokens("")).toBe(""); + }); }); diff --git a/test/shared/db/processed-payments/staleness.test.ts b/test/shared/db/processed-payments/staleness.test.ts index cae0641a44..feeb2b02b3 100644 --- a/test/shared/db/processed-payments/staleness.test.ts +++ b/test/shared/db/processed-payments/staleness.test.ts @@ -1,9 +1,9 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; +import { FakeTime } from "@std/testing/time"; import { getDb, insert } from "#shared/db/client.ts"; import { deleteAllStaleReservations, - finalizeSession as finalizePaymentSession, isReservationStale, isSessionProcessed, releaseReservation, @@ -12,6 +12,7 @@ import { } from "#shared/db/processed-payments.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { useProcessedPaymentsAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; +import { finalizeTestPaymentSession as finalizePaymentSession } from "#test-utils/db-helpers/processed-payments.ts"; const finalizeSession = ( sessionId: string, @@ -27,23 +28,23 @@ const finalizeSession = ( describeWithEnv("processed-payments / staleness", { db: true }, () => { const ctx = useProcessedPaymentsAttendee(); + const now = 2_000_000_000_000; describe("isReservationStale", () => { test("returns false for a recent timestamp", () => { - expect(isReservationStale(new Date().toISOString())).toBe(false); + using _time = new FakeTime(now); + expect(isReservationStale(new Date(now).toISOString())).toBe(false); }); - test("returns false for a timestamp just under the threshold", () => { - const justUnder = new Date( - Date.now() - STALE_RESERVATION_MS + 1000, - ).toISOString(); - expect(isReservationStale(justUnder)).toBe(false); + test("returns false at the threshold", () => { + using _time = new FakeTime(now); + const threshold = new Date(now - STALE_RESERVATION_MS).toISOString(); + expect(isReservationStale(threshold)).toBe(false); }); test("returns true for a timestamp over the threshold", () => { - const stale = new Date( - Date.now() - STALE_RESERVATION_MS - 1000, - ).toISOString(); + using _time = new FakeTime(now); + const stale = new Date(now - STALE_RESERVATION_MS - 1).toISOString(); expect(isReservationStale(stale)).toBe(true); }); }); diff --git a/test/shared/db/prune/helpers.ts b/test/shared/db/prune/helpers.ts index e189d16b4c..69be175200 100644 --- a/test/shared/db/prune/helpers.ts +++ b/test/shared/db/prune/helpers.ts @@ -87,6 +87,24 @@ export const insertSumupCheckout = async ( ); }; +export const insertPendingCheckoutStage = async ( + sessionId: string, + createdAtIso: string, +): Promise => { + const attendeeId = await insertOrphanAttendee(createdAtIso); + await getDb().execute( + insert("checkout_stages", { + attendee_id: attendeeId, + created_at: createdAtIso, + payment_session_id: sessionId, + provider: "stripe", + state: "pending", + ticket_tokens: "ciphertext", + }), + ); + return attendeeId; +}; + export const sumupCheckoutExists = async ( referenceIndex: string, ): Promise => { @@ -210,6 +228,7 @@ export const oldOrphanIso = (): string => /** Every last-pruned stamp the scheduler tracks, one setter per table. */ const LAST_PRUNED_SETTERS = [ settings.update.lastPrunedPayments, + settings.update.lastPrunedCheckoutStages, settings.update.lastPrunedSessions, settings.update.lastPrunedLogins, settings.update.lastPrunedTokens, diff --git a/test/shared/db/prune/scheduler.test.ts b/test/shared/db/prune/scheduler.test.ts index 10766568de..d031228de3 100644 --- a/test/shared/db/prune/scheduler.test.ts +++ b/test/shared/db/prune/scheduler.test.ts @@ -56,6 +56,13 @@ describeWithEnv("db > prune scheduler", { db: true }, () => { ); }); + test("records fresh checkout-stage timestamp after running", async () => { + await clearAllLastPruned(); + await expectFreshPrunedTimestampAfterRun( + () => settings.lastPrunedCheckoutStages, + ); + }); + test("records fresh sessions timestamp after running", async () => { await clearAllLastPruned(); await expectFreshPrunedTimestampAfterRun( diff --git a/test/shared/db/prune/tables.test.ts b/test/shared/db/prune/tables.test.ts index 80eb396bc3..0f46a2481a 100644 --- a/test/shared/db/prune/tables.test.ts +++ b/test/shared/db/prune/tables.test.ts @@ -1,6 +1,7 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; import { + pruneCheckoutStages, pruneContacts, pruneLoginAttempts, pruneOrphanAttendees, @@ -12,6 +13,7 @@ import { import { createSession, getAllSessions } from "#shared/db/sessions.ts"; import { settings } from "#shared/db/settings.ts"; import { + PRUNE_CHECKOUT_STAGES_RETENTION_MS, PRUNE_CONTACTS_RETENTION_MS, PRUNE_LOGINS_RETENTION_MS, PRUNE_SESSIONS_RETENTION_MS, @@ -27,6 +29,7 @@ import { insertContactPreference, insertLoginAttempt, insertOrphanAttendee, + insertPendingCheckoutStage, insertString, insertSumupCheckout, insertTokenAttempt, @@ -38,6 +41,28 @@ import { } from "./helpers.ts"; describeWithEnv("db > table pruning", { db: true }, () => { + describe("pruneCheckoutStages", () => { + test("deletes pending checkout PII older than retention", async () => { + const old = new Date( + nowMs() - PRUNE_CHECKOUT_STAGES_RETENTION_MS - 60_000, + ).toISOString(); + const attendeeId = await insertPendingCheckoutStage("stage_old", old); + + expect(await pruneCheckoutStages()).toBe(1); + expect(await attendeeExists(attendeeId)).toBe(false); + }); + + test("keeps pending checkout PII within retention", async () => { + const attendeeId = await insertPendingCheckoutStage( + "stage_recent", + new Date(nowMs() - 1000).toISOString(), + ); + + expect(await pruneCheckoutStages()).toBe(0); + expect(await attendeeExists(attendeeId)).toBe(true); + }); + }); + describe("pruneSumupCheckouts", () => { test("deletes checkout metadata older than retention window", async () => { const old = new Date( diff --git a/test/shared/limits.test.ts b/test/shared/limits.test.ts index 5f7207572a..b928282818 100644 --- a/test/shared/limits.test.ts +++ b/test/shared/limits.test.ts @@ -158,6 +158,7 @@ describe("limits", () => { "MAX_LOGIN_ATTEMPTS", "MAX_TEXTAREA_LENGTH", "MAX_TOKEN_404S", + "PRUNE_CHECKOUT_STAGES_RETENTION_DAYS", "PRUNE_CONTACTS_RETENTION_DAYS", "PRUNE_INTERVAL_HOURS", "PRUNE_LOGINS_RETENTION_DAYS", diff --git a/test/shared/merge/attendee-merge/repoint.test.ts b/test/shared/merge/attendee-merge/repoint.test.ts index ef9fc6a92d..ec70234c40 100644 --- a/test/shared/merge/attendee-merge/repoint.test.ts +++ b/test/shared/merge/attendee-merge/repoint.test.ts @@ -5,14 +5,12 @@ import { transfersByAccount } from "#shared/accounting/queries.ts"; import { createAttendeeAtomic } from "#shared/db/attendees/api.ts"; import { queryAll } from "#shared/db/client.ts"; import { getRefundPaymentReferences } from "#shared/db/payment-references.ts"; -import { - finalizeSession, - reserveSession, -} from "#shared/db/processed-payments.ts"; +import { reserveSession } from "#shared/db/processed-payments.ts"; import { getTestPrivateKey } from "#test-utils/crypto.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { createTestGroup } from "#test-utils/db-helpers/groups.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { finalizeTestPaymentSession as finalizeSession } from "#test-utils/db-helpers/processed-payments.ts"; import { createAttendee, getBookings, diff --git a/test/shared/settings/registry.test.ts b/test/shared/settings/registry.test.ts index 3aa99534c5..1fcbf1ae65 100644 --- a/test/shared/settings/registry.test.ts +++ b/test/shared/settings/registry.test.ts @@ -69,6 +69,7 @@ const EXPECTED_CONFIG_KEY_NAMES = lines(` LAST_ACTIVITY_LOG_BACKFILL LAST_PRUNED_ADDRESSES LAST_PRUNED_CONTACTS + LAST_PRUNED_CHECKOUT_STAGES LAST_PRUNED_INVITES LAST_PRUNED_LOGINS LAST_PRUNED_ORPHANS @@ -147,6 +148,12 @@ const EXPECTED_SETTING_ROWS = [ ["ATTENDEE_COLUMN_ORDER", "plaintext", "attendeeColumnOrder"], ["LAST_PRUNED_PAYMENTS", "plaintext", "lastPrunedPayments"], ["LAST_PRUNED_SESSIONS", "plaintext", "lastPrunedSessions"], + [ + "LAST_PRUNED_CHECKOUT_STAGES", + "plaintext", + "lastPrunedCheckoutStages", + "prune", + ], ["LAST_PRUNED_SUMUP", "plaintext", "lastPrunedSumup"], ["LAST_PRUNED_STRINGS", "plaintext", "lastPrunedStrings", "prune"], ["LAST_PRUNED_LOGINS", "plaintext", "lastPrunedLogins", "prune"], diff --git a/test/test-utils/db-helpers/attendees.ts b/test/test-utils/db-helpers/attendees.ts index 7e89e8dc19..b3c3073d64 100644 --- a/test/test-utils/db-helpers/attendees.ts +++ b/test/test-utils/db-helpers/attendees.ts @@ -2,7 +2,6 @@ import { expect } from "@std/expect"; import { parseFlashValue } from "#shared/cookies.ts"; import { signCsrfToken } from "#shared/csrf.ts"; import { createAttendeeAtomic } from "#shared/db/attendees/api.ts"; -import { ensureAllBookings } from "#shared/db/attendees/create.ts"; import { decryptAttendees } from "#shared/db/attendees/pii.ts"; import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; import type { ListingInput } from "#shared/db/listings.ts"; @@ -20,30 +19,15 @@ export const bookTestAttendee = async ( name = "Alice", email?: string, ): Promise => { - // Record the booking (and, on rollback, reverse its contact-activity count) - // under one source so the greedy create and the cleanup can't drift. - const source = "public"; const result = await createAttendeeAtomic({ bookings: listingIds.map((listingId) => ({ listingId })), email: email ?? `${name.toLowerCase()}@test.com`, name, - source, + source: "public", }); if (!result.success) { throw new Error(`Failed to create attendee: ${result.reason}`); } - // createAttendeeAtomic is greedy: it reports success once any booking lands, - // so a full/blocked listing would silently leave the attendee booked onto - // fewer listings than asked for. Reuse production's "no half-saved attendee" - // rule, which rolls the partial attendee back (reversing the same source's - // booking count) and reports failure, then fail the setup loudly rather than - // leaving stray bookings to skew the test. - const check = await ensureAllBookings(result, listingIds.length, source); - if (!check.ok) { - throw new Error( - `Failed to book test attendee onto all ${listingIds.length} listing(s): ${check.reason}`, - ); - } return result.attendees[0]!; }; diff --git a/test/test-utils/db-helpers/contacts.ts b/test/test-utils/db-helpers/contacts.ts new file mode 100644 index 0000000000..5d225655d2 --- /dev/null +++ b/test/test-utils/db-helpers/contacts.ts @@ -0,0 +1,13 @@ +import { recordBookingActivity } from "#shared/db/contact-tokens.ts"; + +/** Seed contact visits through the same atomic activity write production uses. */ +export const seedContactVisits = async ( + hash: string, + count = 1, +): Promise => { + await Promise.all( + Array.from({ length: count }, (_, index) => + recordBookingActivity(hash, "public", `test-visit-${index}`), + ), + ); +}; diff --git a/test/test-utils/db-helpers/processed-payments.ts b/test/test-utils/db-helpers/processed-payments.ts new file mode 100644 index 0000000000..16563eb687 --- /dev/null +++ b/test/test-utils/db-helpers/processed-payments.ts @@ -0,0 +1,44 @@ +import { stageCheckout } from "#shared/db/checkout-stages.ts"; +import { execute } from "#shared/db/client.ts"; +import { encryptPaymentReference } from "#shared/db/payment-references.ts"; +import { encryptTicketTokens } from "#shared/db/processed-payments.ts"; +import { checkoutIntent, checkoutItem } from "#test-utils/checkout.ts"; + +/** Create the standard one-listing staged checkout used by DB-backed tests. */ +export const stageTestCheckout = ( + sessionId: string, + listing: { id: number; name: string; slug: string }, +) => + stageCheckout( + sessionId, + "stripe", + checkoutIntent({ + items: [ + checkoutItem({ + listingId: listing.id, + name: listing.name, + slug: listing.slug, + }), + ], + }), + ); + +/** Put a reserved payment into the finalized state needed by a test fixture. */ +export const finalizeTestPaymentSession = async ( + sessionId: string, + attendeeId: number, + ticketTokens: string[], + paymentReference: string, +): Promise => { + await execute( + `UPDATE processed_payments + SET attendee_id = ?, ticket_tokens = ?, payment_reference = ? + WHERE payment_session_id = ?`, + [ + attendeeId, + await encryptTicketTokens(ticketTokens), + await encryptPaymentReference(paymentReference), + sessionId, + ], + ); +};