From 4dfe3a6924b391513f06318dc7612ab29f9b564c Mon Sep 17 00:00:00 2001 From: Stefan Date: Thu, 6 Aug 2026 16:11:51 +0100 Subject: [PATCH 1/5] Finish paid bookings in a fixed number of database calls --- .../mutation/equivalent-mutants/features.txt | 30 +- .../equivalent-mutants/shared-a-l.txt | 8 +- .../mutation/equivalent-mutants/shared-db.txt | 11 +- .../equivalent-mutants/shared-m-z.txt | 5 + .../api/payment-processing/completion.ts | 33 +- src/features/api/payment-processing/create.ts | 48 ++- src/features/api/payment-processing/index.ts | 28 +- src/features/api/payment-processing/items.ts | 63 ++-- .../api/payment-processing/package-pricing.ts | 206 ++---------- .../api/payment-processing/recovery.ts | 3 + .../api/payment-processing/snapshot/fold.ts | 156 +++++++++ .../api/payment-processing/snapshot/io.ts | 317 ++++++++++++++++++ .../api/payment-processing/snapshot/types.ts | 61 ++++ .../api/payment-processing/store-refund.ts | 3 +- src/shared/db/activity-log.ts | 2 +- src/shared/db/attendee-types.ts | 1 + src/shared/db/attendees/create.ts | 5 +- src/shared/db/attendees/order-parents.ts | 8 +- src/shared/db/attendees/queries.ts | 10 - src/shared/db/modifier-resolve.ts | 25 -- src/shared/db/processed-payments.ts | 127 +++---- .../db/questions/attendee-answers/save.ts | 87 ++++- src/shared/email-renderer.ts | 8 +- src/shared/email.ts | 52 ++- src/shared/registration-package-facts.ts | 57 ++++ src/shared/session-ledger.ts | 12 - src/shared/webhook.ts | 103 +++--- .../admin/questions/listing-questions.test.ts | 4 +- .../api/payment-processing/completion.test.ts | 98 +++++- .../api/payment-processing/create.test.ts | 17 +- .../payment-processing/create/answers.test.ts | 90 +++++ .../payment-processing/index/balance.test.ts | 8 +- .../payment-processing/index/booking.test.ts | 42 ++- .../api/payment-processing/index/helpers.ts | 8 +- .../payment-processing/index/refunds.test.ts | 4 +- .../api/payment-processing/items.test.ts | 2 +- .../items/boundaries.test.ts | 86 ++++- .../payment-processing/items/budget.test.ts | 2 +- .../api/payment-processing/items/helpers.ts | 12 + .../package-pricing.test.ts | 2 +- .../package-pricing/database.test.ts | 72 +++- .../api/payment-processing/recovery.test.ts | 2 + .../payment-processing/snapshot/fold.test.ts | 207 ++++++++++++ .../payment-processing/snapshot/io.test.ts | 239 +++++++++++++ .../payment-processing/store-refund.test.ts | 3 + test/integration/email/config.test.ts | 7 +- test/integration/email/registration.test.ts | 45 ++- .../processed-payments/locking.test.ts | 84 ++++- .../questions-attendee-answers.test.ts | 6 +- .../server/balance-payment-replay.test.ts | 8 +- .../server/payments/confirm.test.ts | 11 +- .../server/payments/replay.test.ts | 6 +- .../server/payments/success.test.ts | 2 +- .../server/reservation-edge-cases.test.ts | 6 +- .../can-pay-more-multi-ticket.test.ts | 2 +- .../webhooks/concurrent-processing.test.ts | 4 +- .../webhooks/multi-ticket-refunds.test.ts | 2 +- .../price-signature-package-overrides.test.ts | 4 +- ...ice-signature-post-commit-recovery.test.ts | 12 +- ...signature-stored-refund-and-ignore.test.ts | 12 +- .../webhooks/refund-helper-functions.test.ts | 8 +- test/integration/servicing/atomicity.test.ts | 6 +- ...ice-signature-trusted-and-mismatch.test.ts | 6 +- .../checkout-pricing/consistency.test.ts | 29 +- .../db/attendees/api/create-rollback.test.ts | 8 +- test/shared/db/attendees/balance.test.ts | 10 +- test/shared/db/attendees/create.test.ts | 12 +- test/shared/db/attendees/delete.test.ts | 12 +- test/shared/db/listings/delete.test.ts | 14 +- test/shared/db/modifier-resolve.test.ts | 58 ---- test/shared/db/processed-payments.test.ts | 106 +++--- .../processed-payments/finalize-guard.test.ts | 10 +- .../db/processed-payments/staleness.test.ts | 43 +-- .../save/group-listings.test.ts | 50 +++ .../save/stored-ids-behavior.test.ts | 216 ++++++++++++ .../attendee-answers/save/stored-ids.test.ts | 119 +++++++ .../shared/registration-package-facts.test.ts | 61 ++++ test/shared/session-ledger.test.ts | 67 +--- test/shared/webhook/budget.test.ts | 48 ++- test/shared/webhook/payload-fields.test.ts | 29 +- test/shared/webhook/payload.test.ts | 8 +- test/specs/steps/payment-capacity.ts | 12 +- test/test-utils/db-poison.ts | 28 +- test/test-utils/email.ts | 19 +- test/test-utils/processed-payments.ts | 23 +- test/test-utils/webhooks.ts | 12 +- 86 files changed, 2697 insertions(+), 895 deletions(-) create mode 100644 src/features/api/payment-processing/snapshot/fold.ts create mode 100644 src/features/api/payment-processing/snapshot/io.ts create mode 100644 src/features/api/payment-processing/snapshot/types.ts create mode 100644 src/shared/registration-package-facts.ts create mode 100644 test/features/api/payment-processing/create/answers.test.ts create mode 100644 test/features/api/payment-processing/snapshot/fold.test.ts create mode 100644 test/features/api/payment-processing/snapshot/io.test.ts create mode 100644 test/shared/db/questions/attendee-answers/save/group-listings.test.ts create mode 100644 test/shared/db/questions/attendee-answers/save/stored-ids-behavior.test.ts create mode 100644 test/shared/db/questions/attendee-answers/save/stored-ids.test.ts create mode 100644 test/shared/registration-package-facts.test.ts diff --git a/scripts/mutation/equivalent-mutants/features.txt b/scripts/mutation/equivalent-mutants/features.txt index da733c1b1f..694422a446 100644 --- a/scripts/mutation/equivalent-mutants/features.txt +++ b/scripts/mutation/equivalent-mutants/features.txt @@ -7,6 +7,11 @@ src/ui/templates/admin/holidays.tsx:87:45 ?? → || # values is a render-valu src/ui/client/admin/manual-checkin.ts:59:36 ?? → || # textContent is string|null; its only falsy string is the empty fallback itself src/ui/client/admin/order-gallery.ts:105:34 ?? → || # states is a record object when present, so it is always truthy +# Paid snapshot collection fallbacks: every present value is an object or array, +# which remains truthy even when empty; only undefined reaches the empty fallback. +src/features/api/payment-processing/snapshot/fold.ts:95:39 ?? → || # scopes.get(): SnapshotModifierScopeRow[]|undefined — arrays are always truthy +src/features/api/payment-processing/snapshot/io.ts:81:25 ?? → || # values is Record|undefined — record objects are always truthy + # ticket-payment values whose present form is always truthy, whose falsy value # equals the fallback, or whose mutated constant is normalized before use. src/features/public/ticket-payment.ts:192:11 ?? → || # date is nullish or a non-empty ISO date; only the nullish values use the null fallback @@ -139,18 +144,20 @@ src/ui/client/dom.ts:13:19 = → += # createElement returns a new button with # callers; optional collections are arrays (always truthy); parsed day counts and # stored package quantities are positive; equal zero fallbacks stay equal. src/features/api/payment-processing/index.ts:101:22 → "mutated" # replaySuccess is private and both callers always pass session.paymentReference, so the default is never evaluated -src/features/api/payment-processing/items.ts:89:41 ?? → || # intent.allocations is an array or undefined, and arrays (including []) are truthy -src/features/api/payment-processing/items.ts:175:24 ?? → || # extractIntent emits a positive dayCount or undefined, so every present value is truthy +src/features/api/payment-processing/items.ts:73:41 ?? → || # intent.allocations is an array or undefined, and arrays (including []) are truthy +src/features/api/payment-processing/items.ts:151:59 ?? → || # a parent list length is non-negative; its only falsy value is 0, which equals the fallback +src/features/api/payment-processing/items.ts:177:24 ?? → || # extractIntent emits a positive dayCount or undefined, so every present value is truthy src/features/api/payment-processing/pricing.ts:84:40 ?? → || # paidByItem stores numeric charged totals; the only falsy present total is 0, and both operators keep the 0 fallback -src/features/api/payment-processing/package-pricing.ts:49:46 ?? → || # intent.allocations is an array or undefined, and arrays (including []) are truthy -src/features/api/payment-processing/package-pricing.ts:50:59 ?? → || # the running allocated quantity is non-negative and the fallback is 0, so 0 ?? 0 and 0 || 0 agree -src/features/api/payment-processing/package-pricing.ts:166:56 ?? → || # stored per-package quantities are at least 1, so every present value is truthy -src/features/api/payment-processing/package-pricing.ts:222:37 ?? → || # the allocated quantity is non-negative and the fallback is 0, so both operators agree for 0 and undefined -src/features/api/payment-processing/package-pricing.ts:228:49 false → true # orderEdgeDrifted uses buildTicketListing only to pass its listing row into buildBookingTree; the closed/availability fields never affect node keys -src/features/api/payment-processing/package-pricing.ts:238:45 false → true # child TicketListing availability is likewise unread by buildBookingTree's node-key construction -src/features/api/payment-processing/package-pricing.ts:253:19 false → true # hideListings changes node visibility only, while edgeDrifted reads node keys and child structure, never visibility -src/features/api/payment-processing/package-pricing.ts:270:60 ?? → || # intent.allocations is an array or undefined, and arrays (including []) are truthy -src/features/api/payment-processing/package-pricing.ts:306:51 ?? → || # the allocated quantity is non-negative and the fallback is 0, so both operators agree for 0 and undefined +src/features/api/payment-processing/package-pricing.ts:36:46 ?? → || # intent.allocations is an array or undefined, and arrays (including []) are truthy +src/features/api/payment-processing/package-pricing.ts:37:59 ?? → || # the running allocated quantity is non-negative and the fallback is 0, so 0 ?? 0 and 0 || 0 agree +src/features/api/payment-processing/package-pricing.ts:75:56 ?? → || # stored per-package quantities are at least 1, so every present value is truthy +src/features/api/payment-processing/package-pricing.ts:106:37 ?? → || # the allocated quantity is non-negative and the fallback is 0, so both operators agree for 0 and undefined +src/features/api/payment-processing/package-pricing.ts:110:49 false → true # orderEdgeDrifted uses buildTicketListing only to pass its listing row into buildBookingTree; the closed/availability fields never affect node keys +src/features/api/payment-processing/package-pricing.ts:115:46 ?? → || # child id lists are arrays (always truthy), while a missing parent selects [] under either operator +src/features/api/payment-processing/package-pricing.ts:118:42 false → true # child TicketListing availability is likewise unread by buildBookingTree's node-key construction +src/features/api/payment-processing/package-pricing.ts:126:19 false → true # hideListings changes node visibility only, while edgeDrifted reads node keys and child structure, never visibility +src/features/api/payment-processing/package-pricing.ts:143:60 ?? → || # intent.allocations is an array or undefined, and arrays (including []) are truthy +src/features/api/payment-processing/package-pricing.ts:159:51 ?? → || # the allocated quantity is non-negative and the fallback is 0, so both operators agree for 0 and undefined # Payment success replay only loads a listing fallback while thankYouUrl is the # empty string, so assignment and append produce the same string for every path. @@ -185,7 +192,6 @@ src/features/public/cart.ts:76:7 continue; → (removed) # without it a slug # Listing detail group context (features/admin/listings-view.ts). src/features/admin/listings-view.ts:148:7 Missing group remaining → "" # getGroupRemainingByGroupId returns an entry for every group id whose cap is positive, and only those ids are looked up, so requiredMapValue never raises this message -src/features/api/payment-processing/package-pricing.ts:100:11 Missing package pricing → "" # loadPackageMemberPricingByGroupIds is given exactly the order's group ids, and only those are looked up, so requiredMapValue never raises this message # Page packages (shared/booking/page-packages.ts): nullish fallbacks on values # that are only ever a number-or-undefined with a matching fallback. diff --git a/scripts/mutation/equivalent-mutants/shared-a-l.txt b/scripts/mutation/equivalent-mutants/shared-a-l.txt index 486f3e5788..cfdf49cee3 100644 --- a/scripts/mutation/equivalent-mutants/shared-a-l.txt +++ b/scripts/mutation/equivalent-mutants/shared-a-l.txt @@ -12,10 +12,10 @@ src/shared/uptime-kuma/matching.ts:58:25 1000 → 1001 # timeout is a positiv src/shared/checkout-pricing.ts:324:41 ?? → || # intent.modifiers: an array is always truthy src/shared/booking-lines.ts:71:42 ?? → || # packageGroupId is absent or a positive database group id; an explicit 0 also has the same 0 fallback src/features/api/payment-processing/create.ts:63:36 ?? → || # lineGroupId returns undefined or a positive package group id; an explicit 0 would also keep the same 0 fallback -src/features/api/payment-processing/create.ts:200:28 ?? → || # listingAnswerIds is an object when present, and objects are always truthy -src/features/api/payment-processing/create.ts:203:67 ?? → || # a listing's text refs are an array when present, and arrays are always truthy -src/features/api/payment-processing/create.ts:206:46 ?? → || # a grouped answer set is an object when present, and objects are always truthy -src/features/api/payment-processing/create.ts:210:35 ?? → || # existing text answer ids are an array when present, and arrays are always truthy +src/features/api/payment-processing/create.ts:200:43 ?? → || # listingAnswerIds is an object when present, and objects are always truthy +src/features/api/payment-processing/create.ts:212:56 ?? → || # a listing's text refs are an array when present, and arrays are always truthy +src/features/api/payment-processing/create.ts:216:46 ?? → || # a grouped answer set is an object when present, and objects are always truthy +src/features/api/payment-processing/create.ts:220:35 ?? → || # existing text answer ids are an array when present, and arrays are always truthy src/shared/logistics-filter.ts:22:35 ?? → || # raw: string|null, only falsy string "" === fallback "" src/shared/config.ts:170:39 ?? → || # getEnv(): string|undefined, only falsy string "" === fallback "" src/shared/config.ts:36:5 ?? → || # providerValue returns boolean or null, and false stays false with either fallback operator diff --git a/scripts/mutation/equivalent-mutants/shared-db.txt b/scripts/mutation/equivalent-mutants/shared-db.txt index 34d8d12915..6edba9267b 100644 --- a/scripts/mutation/equivalent-mutants/shared-db.txt +++ b/scripts/mutation/equivalent-mutants/shared-db.txt @@ -102,7 +102,16 @@ src/shared/db/attendees/select.ts:355:28 ?? → || # query.join: AttendeeJoin # observable assertion. The write-path label is killed by a direct test that # passes an invalid StoredPaymentFailure to markSessionFailed and asserts the # error message carries the label. -src/shared/db/processed-payments.ts:287:7 processed_payments.failure_data → "" # read-path label: read errors are caught and replaced with CORRUPT_FAILURE before the message can be observed +src/shared/db/processed-payments.ts:250:7 processed_payments.failure_data → "" # read-path label: read errors are caught and replaced with CORRUPT_FAILURE before the message can be observed + +# Attendee answer collection fallbacks: all present values are arrays or answer +# objects, so they are truthy; only undefined reaches each empty fallback. +src/shared/db/questions/attendee-answers/save.ts:232:66 ?? → || # textAnswerIds is TextAnswerId[]|undefined — arrays are always truthy +src/shared/db/questions/attendee-answers/save.ts:233:62 ?? → || # textAnswers is TextAnswer[]|undefined — arrays are always truthy +src/shared/db/questions/attendee-answers/save.ts:373:44 ?? → || # listingAnswerIds[key] is number[]|undefined — arrays are always truthy +src/shared/db/questions/attendee-answers/save.ts:374:48 ?? → || # listingTextAnswers[key] is TextAnswer[]|undefined — arrays are always truthy +src/shared/db/questions/attendee-answers/save.ts:376:56 ?? → || # Map.get() returns AttendeeAnswerSet|undefined — answer-set objects are always truthy +src/shared/db/questions/attendee-answers/save.ts:380:33 ?? → || # existing.textAnswers is TextAnswer[]|undefined — arrays are always truthy # The news card query names a single table, so its columns need no alias to # resolve. diff --git a/scripts/mutation/equivalent-mutants/shared-m-z.txt b/scripts/mutation/equivalent-mutants/shared-m-z.txt index 678194146d..b0ff21a1db 100644 --- a/scripts/mutation/equivalent-mutants/shared-m-z.txt +++ b/scripts/mutation/equivalent-mutants/shared-m-z.txt @@ -1,5 +1,10 @@ # Known-equivalent mutants — see README.txt in this directory. +# Registration package facts are objects when supplied, so they are always +# truthy; undefined selects the loader under either nullish or OR fallback. +src/shared/webhook.ts:257:30 ?? → || # suppliedFacts is RegistrationPackageFacts|undefined +src/shared/webhook.ts:364:28 ?? → || # suppliedPackageFacts is RegistrationPackageFacts|undefined + # CRUD configuration values are functions or objects when present, and hydrated # map values are records, so none can be falsy-but-non-null. src/shared/rest/crud-api.ts:343:31 ?? → || # policy is an AuthPolicy object or undefined diff --git a/src/features/api/payment-processing/completion.ts b/src/features/api/payment-processing/completion.ts index 9bd08df940..9e5648305d 100644 --- a/src/features/api/payment-processing/completion.ts +++ b/src/features/api/payment-processing/completion.ts @@ -1,34 +1,43 @@ import { type CreatedEntry, - logPromoCodeModifiers, + promoCodeActivities, saveSessionAnswers, sessionSuccess, } from "#routes/api/payment-processing/create.ts"; +import type { PaidQuestionFacts } from "#routes/api/payment-processing/snapshot/types.ts"; import type { PaymentResult } from "#routes/api/webhook-types.ts"; import type { BookingIntent } from "#shared/booking-intent.ts"; import type { ModifierApplication } from "#shared/checkout-pricing.ts"; import type { ModifierSpec } from "#shared/payments.ts"; +import type { RegistrationPackageFacts } from "#shared/registration-package-facts.ts"; import { logAndNotifyRegistration } from "#shared/webhook.ts"; -/** Finish every effect after a paid booking has definitely committed. */ export const completePaidBooking = async ( createdEntries: CreatedEntry[], intent: BookingIntent, codeSpecs: ModifierSpec[], modifierApplications: ModifierApplication[], ticketTokens: string[], + questionFacts: PaidQuestionFacts, + notificationPackages: RegistrationPackageFacts, ): Promise => { - await saveSessionAnswers(createdEntries, intent); + await saveSessionAnswers(createdEntries, intent, questionFacts); const firstEntry = createdEntries[0]!; - if (codeSpecs.length > 0) { - await logPromoCodeModifiers( - codeSpecs, - modifierApplications, - firstEntry.listing, - firstEntry.attendee.id, - ); - } - await logAndNotifyRegistration(createdEntries, intent.siteTokenIndex); + const promoActivities = + codeSpecs.length > 0 + ? promoCodeActivities( + codeSpecs, + modifierApplications, + firstEntry.listing, + firstEntry.attendee.id, + ) + : []; + await logAndNotifyRegistration( + createdEntries, + intent.siteTokenIndex, + promoActivities, + notificationPackages, + ); return sessionSuccess( firstEntry.attendee.id, firstEntry.listing.id, diff --git a/src/features/api/payment-processing/create.ts b/src/features/api/payment-processing/create.ts index 557497cf15..5a050e4787 100644 --- a/src/features/api/payment-processing/create.ts +++ b/src/features/api/payment-processing/create.ts @@ -14,6 +14,7 @@ import { orderLineTotal, paidByItem, } from "#routes/api/payment-processing/pricing.ts"; +import type { PaidQuestionFacts } from "#routes/api/payment-processing/snapshot/types.ts"; import type { PaymentResult } from "#routes/api/webhook-types.ts"; /* jscpd:ignore-start */ import { lineGroupId } from "#shared/booking/signed-metadata.ts"; @@ -35,8 +36,7 @@ import type { } from "#shared/checkout-pricing.ts"; /* jscpd:ignore-end */ import { formatCurrency } from "#shared/currency.ts"; -import { logActivity } from "#shared/db/activity-log.ts"; -import { requirePublicStatusId } from "#shared/db/attendee-statuses.ts"; +import type { ActivityToLog } from "#shared/db/activity-log.ts"; import { attendeesApi } from "#shared/db/attendees/api.ts"; import { decryptSessionTokens, @@ -193,14 +193,24 @@ const textRefsWithStringId = ( export const saveSessionAnswers = async ( createdEntries: CreatedEntry[], intent: BookingIntent, + questionFacts: PaidQuestionFacts, ): Promise => { if (!intent.listingAnswerIds && !intent.listingTextAnswerIds) return; - const grouped = groupListingAnswerSets( - createdEntries, - intent.listingAnswerIds ?? {}, + const listingAnswerIds = Object.fromEntries( + Object.entries(intent.listingAnswerIds ?? {}).map( + ([listingId, answerIds]) => [ + listingId, + answerIds.filter((answerId) => + questionFacts.questionIdByAnswerId.has(answerId), + ), + ], + ), ); + const grouped = groupListingAnswerSets(createdEntries, listingAnswerIds); for (const { attendee, listing } of createdEntries) { - const refs = intent.listingTextAnswerIds?.[String(listing.id)] ?? []; + const refs = ( + intent.listingTextAnswerIds?.[String(listing.id)] ?? [] + ).filter((ref) => questionFacts.textQuestionIds.has(ref.q)); const resolvedRefs = textRefsWithStringId(refs, listing.id); if (resolvedRefs.length === 0) continue; const existing = grouped.get(attendee.id) ?? { answerIds: [] }; @@ -215,9 +225,10 @@ export const saveSessionAnswers = async ( await saveAttendeeAnswers(grouped); }; -export const attendeeBaseFields = async ( +export const attendeeBaseFields = ( session: ValidatedPaymentSession, intent: BookingIntent, + publicStatusId: number, ) => ({ address: intent.address, email: intent.email, @@ -225,26 +236,26 @@ export const attendeeBaseFields = async ( paymentId: session.paymentReference, phone: intent.phone, special_instructions: intent.special_instructions, - statusId: await requirePublicStatusId(), + statusId: publicStatusId, }); -export const logPromoCodeModifiers = async ( +export const promoCodeActivities = ( specs: ModifierSpec[], applications: ModifierApplication[], listing: ListingWithCount, attendeeId: number, -): Promise => { +): ActivityToLog[] => { const byId = new Map(applications.map((a) => [a.modifierId, a])); - for (const spec of specs) { + return specs.map((spec) => { const delta = byId.get(spec.id)!.delta; const effect = delta < 0 ? `${formatCurrency(-delta)} off` : `+${formatCurrency(delta)}`; - await logActivity( - `Promo code '${spec.name}' used: ${effect}`, - listing, + return { attendeeId, - ); - } + listing, + message: `Promo code '${spec.name}' used: ${effect}`, + }; + }); }; /** @@ -263,6 +274,8 @@ export const createAttendeeForSession = async ( pricingIntent: CheckoutIntent, pricedOrder: PricedOrder, ticketToken: string, + publicStatusId: number, + parentIdsByChild: ReadonlyMap, ): Promise => { let prepared: { attendeeInput: Parameters[0]; @@ -302,8 +315,9 @@ export const createAttendeeForSession = async ( ); prepared = { attendeeInput: { - ...(await attendeeBaseFields(session, intent)), + ...attendeeBaseFields(session, intent, publicStatusId), bookings, + parentIdsByChild, remainingBalance, ticketToken, }, diff --git a/src/features/api/payment-processing/index.ts b/src/features/api/payment-processing/index.ts index 159ae46803..3331c44a4c 100644 --- a/src/features/api/payment-processing/index.ts +++ b/src/features/api/payment-processing/index.ts @@ -24,6 +24,7 @@ import { deletedListingSpec, refuseMismatch, } from "#routes/api/payment-processing/refunds.ts"; +import { loadPaidOrderSnapshot } from "#routes/api/payment-processing/snapshot/io.ts"; import { datelessGhostBookings, placeholderBookings, @@ -41,7 +42,6 @@ import type { BookingIntent } from "#shared/booking-intent.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 { buyerVisits, specsFromRefs } from "#shared/db/modifier-resolve.ts"; import { finalizeSessionIfUnresolved, markSessionFailed, @@ -51,7 +51,7 @@ import { reserveSession, } from "#shared/db/processed-payments.ts"; import { logDebug } from "#shared/logger.ts"; -import { bookingLedgerDisposition } from "#shared/session-ledger.ts"; +import type { BookingLedgerDisposition } from "#shared/session-ledger.ts"; /** 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 @@ -138,8 +138,8 @@ const replaySessionFromLedger = async ( sessionId: string, listingId: number, paymentReference: string, + disposition: BookingLedgerDisposition, ): Promise => { - const disposition = await bookingLedgerDisposition(sessionId); switch (disposition.status) { case "unrecorded": return null; @@ -178,6 +178,7 @@ const processNewBookingSession = async ( signedListingId: number, ): Promise => { const { session, intent, verdict } = data; + const snapshot = await loadPaidOrderSnapshot(sessionId, intent); // Preflight: the durable ledger is the source of truth for "already honoured". // Replay a session the ledger already records BEFORE any validation, pricing, @@ -188,11 +189,12 @@ const processNewBookingSession = async ( sessionId, signedListingId, session.paymentReference, + snapshot.ledger, ); if (replay) return replay; // Phase 2: Validate listings. - const validated = await validateAllItems(session, intent); + const validated = await validateAllItems(session, intent, snapshot); if ("success" in validated) { // A trusted session (we signed it) whose listing was deleted between checkout // and payment. listing_attendees has no FK to listings, so we still keep a @@ -208,6 +210,7 @@ const processNewBookingSession = async ( intent, datelessGhostBookings(intent.items), deletedListingSpec(session), + snapshot.publicStatusId, ); } return validated; @@ -219,8 +222,7 @@ const processNewBookingSession = async ( // Every trigger — automatic, code, opt-in add-on, and answer — rides the same // metadata refs and is re-fetched by id here, re-checking the visit gate and // re-deriving the amount so a tampered checkout can't dodge a surcharge. - const visits = await buyerVisits(intent.email, intent.phone); - const modifierSpecs = await specsFromRefs(intent.modifiers, { visits }); + const modifierSpecs = snapshot.modifierSpecs; const pricingIntent = checkoutIntentForSession( intent, validatedItems, @@ -238,7 +240,13 @@ const processNewBookingSession = async ( ? chargeMismatchSpec(session, verdict.agreed) : paidPricingRefund(validatedItems, pricedOrder, verdict.agreed); if (knownRefund) { - return storeRefundedBooking(session, intent, placeholders, knownRefund); + return storeRefundedBooking( + session, + intent, + placeholders, + knownRefund, + snapshot.publicStatusId, + ); } // Otherwise try to honour it at the charged price. Expected refusal keeps a @@ -256,6 +264,8 @@ const processNewBookingSession = async ( codeSpecs, pricedOrder.modifierApplications, ticketTokens, + snapshot.questions, + snapshot.notificationPackages, ); const honoured = await createAttendeeForSession( session, @@ -264,6 +274,8 @@ const processNewBookingSession = async ( pricingIntent, pricedOrder, ticketToken, + snapshot.publicStatusId, + snapshot.parentsByChildId, ); if (honoured.ok === null) { return recoverOrRefundUnexpectedCreate({ @@ -271,6 +283,7 @@ const processNewBookingSession = async ( error: honoured.error, intent, placeholders, + publicStatusId: snapshot.publicStatusId, session, ticketToken, validatedItems, @@ -282,6 +295,7 @@ const processNewBookingSession = async ( intent, placeholders, specForFailure(honoured), + snapshot.publicStatusId, ); } diff --git a/src/features/api/payment-processing/items.ts b/src/features/api/payment-processing/items.ts index 9c770038ac..dfb72e6dd1 100644 --- a/src/features/api/payment-processing/items.ts +++ b/src/features/api/payment-processing/items.ts @@ -6,16 +6,16 @@ * mid-checkout. */ -import { unique } from "#fp"; +/* jscpd:ignore-start -- import block */ import { anyPackageBundleMismatch, expectedItemPrice, - hasStaleStandaloneChild, - loadPackagePricingByGroup, - orderEdgeDrifted, + hasStaleStandaloneChildFromFacts, + orderEdgeDriftedFromFacts, type ValidatedItem, } from "#routes/api/payment-processing/package-pricing.ts"; import { validationFailure } from "#routes/api/payment-processing/refunds.ts"; +import type { PaidOrderSnapshot } from "#routes/api/payment-processing/snapshot/types.ts"; import type { ListingValidation, PaymentFailureResult, @@ -27,26 +27,10 @@ import { standaloneLineListingIds, } from "#shared/booking/signed-metadata.ts"; import type { BookingIntent } from "#shared/booking-intent.ts"; -import { getHiddenPackageMemberIds } from "#shared/db/groups.ts"; -import { getListingsWithCountsByIds } from "#shared/db/listings/records.ts"; -import { resolveNamesConcealed } from "#shared/package-privacy.ts"; import type { ValidatedPaymentSession } from "#shared/payments.ts"; import type { ListingWithCount } from "#shared/types.ts"; -/** Every listing the order's lines name, keyed by id, in one read — a line - * whose listing was deleted mid-checkout is simply absent, and its line then - * fails 404 below. */ -const loadOrderListings = async ( - intent: BookingIntent, -): Promise> => { - const listingIds = unique(intent.items.map((item) => item.e)); - const listings = await getListingsWithCountsByIds(listingIds); - return new Map( - listings.flatMap((listing, index) => - listing === null ? [] : [[listingIds[index]!, listing] as const], - ), - ); -}; +/* jscpd:ignore-end */ /** Judge one already-loaded line against the current listing: gone, closed, or * good to price. */ @@ -120,29 +104,34 @@ const bookingPaths = (intent: BookingIntent): BookingPaths => { export const validateAllItems = async ( session: ValidatedPaymentSession, intent: BookingIntent, + snapshot: PaidOrderSnapshot, ): Promise<{ ok: true; items: ValidatedItem[] } | PaymentFailureResult> => { const { allocations, foldedChildIds, standaloneLineIds } = bookingPaths(intent); // For a hidden package, a per-member failure message would reveal a member // name on /payment/success, so never include the listing name in those errors // (fail-safe resolution — see resolveNamesConcealed). - const hiddenPackage = await resolveNamesConcealed(lineGroupIds(intent.items)); + const groupIds = [...lineGroupIds(intent.items)]; + const hiddenPackage = groupIds.some( + (groupId) => + snapshot.notificationPackages.displays.get(groupId)?.hideListings ?? true, + ); // A standalone session started before its listing joined a HIDDEN package must // not book the now-hidden member: its /ticket/ 404s and /t/ would // render the member name/details. Detected here, failed closed after pricing so // the order takes the price_changed refund instead of a leaking standalone // ticket. Lines booked through a package are that bundle's own members, so // only the order's standalone lines are checked. - const staleHiddenMember = - standaloneLineIds.length > 0 && - (await getHiddenPackageMemberIds(standaloneLineIds)).size > 0; + const staleHiddenMember = standaloneLineIds.some((listingId) => + snapshot.hiddenPackageMemberIds.has(listingId), + ); // Suppress per-member names in failure messages for BOTH hidden cases: a hidden // package intent, and a stale standalone session whose listing has since become // a hidden member (else a member closed/deactivated mid-checkout surfaces its // name on /payment/success before the stale-member refund below runs). const includeListingName = intent.items.length > 1 && !hiddenPackage && !staleHiddenMember; - const pricingByGroup = await loadPackagePricingByGroup(intent); + const pricingByGroup = snapshot.notificationPackages.pricingByGroup; // A folded child rides an UNTAGGED line that bundledChildIds removes from // standaloneLineIds wholesale, yet that one line can hold more units than // the package-tagged allocations cover (a bookable-alone child bought beside @@ -152,8 +141,21 @@ export const validateAllItems = async ( // member-only order skips its read. const staleNonStandaloneChild = (standaloneLineIds.length > 0 || allocations.length > 0) && - (await hasStaleStandaloneChild(intent)); - const listingsById = await loadOrderListings(intent); + hasStaleStandaloneChildFromFacts( + intent, + new Set( + intent.items.flatMap((item) => { + const listing = snapshot.listingsById.get(item.e); + return listing && + !listing.bookable_alone && + (snapshot.parentsByChildId.get(item.e)?.length ?? 0) > 0 + ? [item.e] + : []; + }), + ), + snapshot.parentsByChildId, + ); + const listingsById = snapshot.listingsById; const validatedItems: ValidatedItem[] = []; for (const item of intent.items) { const vp = validateListingForPayment( @@ -186,7 +188,10 @@ export const validateAllItems = async ( staleHiddenMember || staleNonStandaloneChild || anyPackageBundleMismatch(pricingByGroup, intent.items) || - (await orderEdgeDrifted(intent, validatedItems, pricingByGroup)) + orderEdgeDriftedFromFacts(intent, validatedItems, pricingByGroup, { + childIdsByParent: snapshot.childrenByParentId, + listingsById: snapshot.listingsById, + }) ) { return { items: validatedItems.map((v) => ({ ...v, expectedPrice: null })), diff --git a/src/features/api/payment-processing/package-pricing.ts b/src/features/api/payment-processing/package-pricing.ts index 5aa4fb536d..4b1981c902 100644 --- a/src/features/api/payment-processing/package-pricing.ts +++ b/src/features/api/payment-processing/package-pricing.ts @@ -4,12 +4,11 @@ * required child-edge changed mid-checkout is caught and fails the order closed * to a price_changed refund rather than booking a partial or stale bundle. * - * Everything here is pure-ish re-derivation over the order's signed lines; the - * IO (loading current members, children, hidden flags) sits in the small loaders - * so the drift checks stay easy to test. + * Everything here is pure re-derivation over the order's signed lines and the + * current facts loaded by the paid-order snapshot. */ -import { requiredMapValue, uniqueBy } from "#fp"; +import { uniqueBy } from "#fp"; import { buildBookingTree } from "#shared/booking/build-tree.ts"; import { buildTicketListing, @@ -25,25 +24,13 @@ import { import { edgeDrifted, lineGroupId, - lineGroupIds, standaloneLineListingIds, } from "#shared/booking/signed-metadata.ts"; import type { BookingIntent, BookingItem } from "#shared/booking-intent.ts"; import { childIdsMatching } from "#shared/child-parents.ts"; -import { - getPackageDisplaysByIds, - loadPackageMemberPricingByGroupIds, -} from "#shared/db/groups.ts"; -import { - getNonStandaloneChildIds, - hydrateListingLinks, - listingChildren, - listingParents, -} from "#shared/db/listing-parents.ts"; +import type { RegistrationPackagePricing } from "#shared/registration-package-facts.ts"; import type { ListingWithCount } from "#shared/types.ts"; -/** Total allocated units per child across the order's per-parent allocations - * (a child chosen under two parents sums both legs). */ const allocatedUnitsByChild = (intent: BookingIntent): Map => { const allocatedByChild = new Map(); for (const allocation of intent.allocations ?? []) { @@ -56,83 +43,17 @@ const allocatedUnitsByChild = (intent: BookingIntent): Map => { export type ValidatedItem = { item: BookingItem; listing: ListingWithCount; - /** The expected line total, or `null` to fail closed (a package line that is - * no longer a valid member — forces a `price_changed` refund). */ expectedPrice: number | null; }; -/** Current package-pricing state for a booking's group: which listings are - * members and their non-zero overrides. Null when the booking isn't a package - * (or the group was deleted / is no longer a package — those members then - * revalidate against the base listing price, so a stale package price mismatches - * and refunds via the normal path). */ -export type PackagePricing = { - memberIds: Set; - priceMap: Map; - /** Each member's CURRENT per-package quantity, to re-check the signed booked - * quantity against an operator's mid-checkout edit. */ - quantityMap: Map; - /** Each customisable member's CURRENT per-day overrides (day count → - * per-unit minor price), so a day-priced line revalidates against the same - * override the checkout charged. */ - dayPriceMap: Map>; -}; - -/** Current package pricing for EACH group the order's lines were booked - * through, keyed by group id. A group that no longer resolves (deleted / - * un-packaged mid-checkout) is absent, so its lines fail closed in - * {@link expectedItemPrice} and take the price_changed refund. */ -export const loadPackagePricingByGroup = async ( - intent: BookingIntent, -): Promise> => { - const groupIds = [...lineGroupIds(intent.items)]; - const [packageDisplays, pricingByGroupId] = await Promise.all([ - getPackageDisplaysByIds(groupIds), - loadPackageMemberPricingByGroupIds(groupIds), - ]); - return new Map( - groupIds - .filter((groupId) => packageDisplays.has(groupId)) - .map((groupId) => { - const pricing = requiredMapValue( - pricingByGroupId, - groupId, - "Missing package pricing", - ); - return [ - groupId, - { - dayPriceMap: pricing.dayPrices, - memberIds: new Set(pricing.rows.map((r) => r.listing_id)), - priceMap: pricing.prices, - quantityMap: pricing.quantities, - }, - ]; - }), - ); -}; - -/** The expected line total for one item, or `null` to fail closed (force a - * `price_changed` refund). Derives the line's {@link PriceRule} with the SAME - * constructor the checkout tree and the webhook payload use - * ({@link packageMemberPriceRule}) and evaluates it with the same - * {@link effectivePrice}, so revalidation can never drift from what checkout - * charged: a member's flat override (including an explicit free 0) > its - * per-day override for the order's day count > the listing's own day/base - * price. `lineGroupId` is the package THIS line was booked through (absent for - * a standalone line); a line that is no longer a current member of that group - * (package deleted, un-flagged, or the listing removed mid-checkout) fails - * closed. */ export const expectedItemPrice = ( - pkg: PackagePricing | undefined, + pkg: RegistrationPackagePricing | undefined, lineGroupId: number | undefined, foldedChildIds: ReadonlySet, item: BookingItem, listing: PricedListing, dayCount: number, ): number | null => { - // A folded child keeps its own base/day rule even when it is also a member; - // a top-level package line must still be a member, else fail closed. const memberLine = lineGroupId !== undefined && !foldedChildIds.has(item.e); if (memberLine && !pkg?.memberIds.has(item.e)) return null; const rule = packageMemberPriceRule( @@ -143,20 +64,8 @@ export const expectedItemPrice = ( return effectivePrice(rule, listing, NO_CUSTOM_PRICES, dayCount) * item.q; }; -/** - * Whether one package's signed lines no longer represent that CURRENT bundle, - * forcing a price_changed refund (the buyer must never be booked for a partial - * or stale bundle). `packageLines` are the order's top-level lines booked - * through this package (folded children excluded); the bundle matches only when - * they cover EXACTLY the current members and their quantities imply ONE common - * positive package count at the current per-package quantities. Catches a - * member added/removed mid-checkout, a member's quantity raised/lowered (so `q` - * is no longer a whole number of packages), or quantities edited so the lines - * no longer share a single count. Per-line price drift is handled separately by - * {@link expectedItemPrice}/the price-mismatch pass. - */ export const packageBundleMismatch = ( - pkg: PackagePricing, + pkg: RegistrationPackagePricing, packageLines: readonly BookingItem[], ): boolean => { if (packageLines.length !== pkg.memberIds.size) return true; @@ -170,12 +79,8 @@ export const packageBundleMismatch = ( return counts.size > 1; }; -/** Whether ANY booked package's lines drifted from its current bundle: each - * group's member lines (by their own edge tags) are checked against that - * group's own membership and per-package quantities - * ({@link packageBundleMismatch}). */ export const anyPackageBundleMismatch = ( - pricingByGroup: ReadonlyMap, + pricingByGroup: ReadonlyMap, items: readonly BookingItem[], ): boolean => [...pricingByGroup].some(([groupId, pkg]) => @@ -185,67 +90,35 @@ export const anyPackageBundleMismatch = ( ), ); -/** Rebuild the order's booking tree from CURRENT config so the revalidation walk - * can re-check each signed line's `nodeKey` still resolves. The top-level nodes - * reuse the item rows already loaded this request; the required-child edges are - * reloaded fresh, so a parent→child edge removed or swapped mid-checkout drops - * that child's `nodeKey` from the tree. `nodeKey`s depend only on membership/edge - * structure, not availability or price, so the rows are wrapped without - * re-resolving capacity. */ -/** Rebuild the order's booking tree from CURRENT config, then check whether the - * signed lines no longer resolve against it — a required child (or package - * member) whose edge the operator removed/swapped mid-checkout, or an edge ADDED - * mid-checkout: a line's listing gained required children the signed order - * carries no allocation for, so booking it would skip an add-on the current page - * requires. Every order is walked against a fresh tree (a childless signed order - * is exactly how an added edge presents). Package-membership and per-line price - * drift are still caught by {@link packageBundleMismatch}/{@link - * expectedItemPrice}. - * - * The tree's top-level nodes reuse the item rows already loaded this request; the - * required-child edges are reloaded fresh, so a parent→child edge removed or - * swapped mid-checkout drops that child's `nodeKey` from the tree. `nodeKey`s - * depend only on membership/edge structure, not availability or price, so the - * rows are wrapped without re-resolving capacity. */ -export const orderEdgeDrifted = async ( +export interface OrderRelationshipFacts { + childIdsByParent: ReadonlyMap; + listingsById: ReadonlyMap; +} + +export const orderEdgeDriftedFromFacts = ( intent: BookingIntent, validatedItems: ValidatedItem[], - pricingByGroup: ReadonlyMap, -): Promise => { + pricingByGroup: ReadonlyMap, + facts: OrderRelationshipFacts, +): boolean => { const allocatedByChild = allocatedUnitsByChild(intent); - // A line leaves the top level only when EVERY unit folds under a parent. A - // bookable-alone child bought beside its parent keeps ONE line whose surplus - // (q beyond the allocated units) books standalone, so that line must build - // its standalone node too — the drift walk revalidates the surplus against - // it, and dropping it by id alone would refund the legitimate order. const fullyFolded = (listingId: number, quantity: number): boolean => (allocatedByChild.get(listingId) ?? 0) >= quantity; - // One resolved listing per id: a listing booked through two paths is two - // lines but one listing row; the tree builder makes one node per path. const topLevel = uniqueBy((info: TicketListing) => info.listing.id)( validatedItems .filter((v) => !fullyFolded(v.item.e, v.item.q)) .map((v) => buildTicketListing(v.listing, false, undefined)), ); - const childLinks = await hydrateListingLinks( - listingChildren, - topLevel.map((t) => t.listing.id), - ); - const childRows = childLinks.listingsByKey; const childrenByParentId = new Map( - [...childRows].map(([parentId, rows]) => [ - parentId, - rows.map((r) => buildTicketListing(r, false, undefined)), + topLevel.map(({ listing }) => [ + listing.id, + (facts.childIdsByParent.get(listing.id) ?? []).map((childId) => { + const child = facts.listingsById.get(childId); + if (!child) throw new Error(`Missing linked listing ${childId}`); + return buildTicketListing(child, false, undefined); + }), ]), ); - // Rebuild each booked package from CURRENT membership, scoped to the lines - // the order actually tagged with it: a tagged line still a member keeps its - // member `nodeKey`; one no longer a member builds standalone, so its signed - // member key drops from the tree and the drift check fails it closed. An - // UNTAGGED line always builds standalone — a listing that joined a (visible) - // package mid-checkout was legitimately booked standalone and must not - // drift; a listing with BOTH kinds of line (booked through a package AND on - // its own) gets both nodes. const nonFolded = intent.items.filter((item) => !fullyFolded(item.e, item.q)); const packages: TreePackage[] = [...pricingByGroup].map(([groupId, pkg]) => ({ dayPrices: pkg.dayPriceMap, @@ -270,43 +143,20 @@ export const orderEdgeDrifted = async ( return edgeDrifted(tree, intent.items, intent.allocations ?? []); }; -/** - * Whether the order books any STANDALONE unit of a child that can no longer be - * booked on its own — a child listing whose "can be booked by itself" flag was - * cleared after this checkout session opened. Completing such a unit would create - * a ticket whose `/ticket/` page now 404s at every fresh entry point, so it - * is failed closed to a price_changed refund. The order-structure drift check - * elsewhere only notices added/removed parent edges, not this flag flip, so it is - * guarded here. - * - * A unit is standalone unless it is folded under one of the child's parents in - * the same order. Folding happens two ways: an explicit per-parent allocation, or - * — when the order carries no allocations — the child being listed alongside a - * parent that adopts it. So a child's standalone unit count is its booked - * quantity minus its allocated quantity, unless the whole quantity is adopted by - * an in-order parent. Any positive standalone count on a now-non-standalone child - * is stale. - */ -export const hasStaleStandaloneChild = async ( +export const hasStaleStandaloneChildFromFacts = ( intent: BookingIntent, -): Promise => { - const orderIds = intent.items.map((item) => item.e); - const nonStandaloneChildIds = await getNonStandaloneChildIds(orderIds); + nonStandaloneChildIds: ReadonlySet, + parentsByChild: ReadonlyMap, +): boolean => { if (nonStandaloneChildIds.size === 0) return false; - const orderIdSet = new Set(orderIds); + const orderIdSet = new Set(intent.items.map((item) => item.e)); const allocatedByChild = allocatedUnitsByChild(intent); - const parentsByChild = await listingParents.getIdsByKeys([ - ...nonStandaloneChildIds, - ]); const adoptedByInOrderParent = childIdsMatching(parentsByChild, (parentIds) => parentIds.some((parentId) => orderIdSet.has(parentId)), ); return intent.items.some((item) => { if (!nonStandaloneChildIds.has(item.e)) return false; const allocated = allocatedByChild.get(item.e) ?? 0; - // Allocated units fold under their named parent; any surplus is standalone. - // With no allocation, the whole quantity folds only if an in-order parent - // adopts it — otherwise every unit is standalone. const standalone = allocated > 0 ? item.q - allocated diff --git a/src/features/api/payment-processing/recovery.ts b/src/features/api/payment-processing/recovery.ts index 32248754d9..62c040485b 100644 --- a/src/features/api/payment-processing/recovery.ts +++ b/src/features/api/payment-processing/recovery.ts @@ -25,6 +25,7 @@ type UnexpectedCreateRecovery = { error: unknown; intent: BookingIntent; placeholders: ReturnType; + publicStatusId: number; session: ValidatedSession["session"]; ticketToken: string; validatedItems: ValidatedItem[]; @@ -72,6 +73,7 @@ export const recoverOrRefundUnexpectedCreate = async ({ error, intent, placeholders, + publicStatusId, session, ticketToken, validatedItems, @@ -97,5 +99,6 @@ export const recoverOrRefundUnexpectedCreate = async ({ refundSpec("unexpected_error")( `Unexpected error completing session ${session.id}: ${String(error)}`, ), + publicStatusId, ); }; diff --git a/src/features/api/payment-processing/snapshot/fold.ts b/src/features/api/payment-processing/snapshot/fold.ts new file mode 100644 index 0000000000..fb2981714c --- /dev/null +++ b/src/features/api/payment-processing/snapshot/fold.ts @@ -0,0 +1,156 @@ +import type { + PaidOrderSnapshot, + SnapshotDayPriceRow, + SnapshotModifierRow, + SnapshotRows, +} from "#routes/api/payment-processing/snapshot/types.ts"; +import type { ModifierRef } from "#shared/booking-intent.ts"; +import { toMinorUnits } from "#shared/currency.ts"; +import { packageMemberMaps } from "#shared/db/groups.ts"; +import type { ModifierSpec } from "#shared/payments.ts"; +import type { RegistrationPackagePricing } from "#shared/registration-package-facts.ts"; +import { classifyBookingLedger } from "#shared/session-ledger.ts"; +import type { GroupListing } from "#shared/types.ts"; + +const appendToMap = ( + map: Map, + key: number, + value: number, +): void => { + const values = map.get(key); + if (values) values.push(value); + else map.set(key, [value]); +}; + +const relationshipMaps = ( + edges: SnapshotRows["childEdges"], +): Pick => { + const childrenByParentId = new Map(); + const parentsByChildId = new Map(); + for (const edge of edges) { + appendToMap(childrenByParentId, edge.parentId, edge.childId); + appendToMap(parentsByChildId, edge.childId, edge.parentId); + } + return { childrenByParentId, parentsByChildId }; +}; + +const packagePricing = ( + groups: SnapshotRows["groups"], + memberships: GroupListing[], + dayPrices: SnapshotDayPriceRow[], +): Map => + new Map( + groups.map((group) => { + const members = memberships.filter((row) => row.group_id === group.id); + const memberDayPrices = dayPrices.filter( + (row) => row.groupId === group.id, + ); + const memberMaps = packageMemberMaps(members); + return [ + group.id, + { + dayPriceMap: new Map( + [...Map.groupBy(memberDayPrices, (row) => row.listingId)].map( + ([listingId, rows]) => [ + listingId, + new Map(rows.map((row) => [row.days, row.unitPrice])), + ], + ), + ), + memberIds: new Set(members.map((row) => row.listing_id)), + priceMap: memberMaps.prices, + quantityMap: memberMaps.quantities, + }, + ]; + }), + ); + +const signedModifierValue = (modifier: SnapshotModifierRow): number => { + if (modifier.calcKind === "multiply") return modifier.calcValue; + const magnitude = + modifier.calcKind === "fixed" + ? toMinorUnits(modifier.calcValue) + : modifier.calcValue; + return modifier.direction === "discount" ? -magnitude : magnitude; +}; + +const modifierSpecs = ( + refs: ModifierRef[], + rows: SnapshotModifierRow[], + scopeRows: SnapshotRows["modifierScopes"], + visits: number, +): ModifierSpec[] => { + const byId = new Map(rows.map((row) => [row.id, row])); + const scopes = Map.groupBy(scopeRows, (row) => row.modifierId); + return refs.flatMap((ref) => { + const modifier = byId.get(ref.i); + if (!modifier || modifier.minVisits > visits) return []; + return [ + { + id: modifier.id, + kind: modifier.calcKind, + listingIds: + modifier.scope === "all" + ? null + : (scopes.get(modifier.id) ?? []).map((row) => row.listingId), + name: modifier.name, + quantity: ref.q, + trigger: modifier.trigger, + value: signedModifierValue(modifier), + }, + ]; + }); +}; + +export const foldPaidOrderSnapshot = ( + refs: ModifierRef[], + rows: SnapshotRows, + dayPrices: SnapshotDayPriceRow[], +): PaidOrderSnapshot => { + const publicStatusId = rows.publicStatusIds[0]; + if (publicStatusId === undefined) { + throw new Error( + "No attendee status has the required is_public_default flag", + ); + } + const visits = Math.max(0, ...rows.visitCounts); + const pricingByGroup = packagePricing( + rows.groups, + rows.memberships, + dayPrices, + ); + return { + ...relationshipMaps(rows.childEdges), + hiddenPackageMemberIds: new Set(rows.hiddenMemberIds), + ledger: classifyBookingLedger( + rows.ledger.hasLegs, + rows.ledger.ownerAttendeeId, + ), + listingsById: new Map( + rows.listings.map((listing) => [listing.id, listing]), + ), + modifierSpecs: modifierSpecs( + refs, + rows.modifiers, + rows.modifierScopes, + visits, + ), + notificationPackages: { + displays: new Map( + rows.groups.map((group) => [ + group.id, + { hideListings: group.hideListings, name: group.name }, + ]), + ), + pricingByGroup, + }, + publicStatusId, + questions: { + questionIdByAnswerId: new Map( + rows.answerRows.map((row) => [row.answerId, row.questionId]), + ), + textQuestionIds: new Set(rows.textQuestionIds), + }, + visits, + }; +}; diff --git a/src/features/api/payment-processing/snapshot/io.ts b/src/features/api/payment-processing/snapshot/io.ts new file mode 100644 index 0000000000..847ff2ea3f --- /dev/null +++ b/src/features/api/payment-processing/snapshot/io.ts @@ -0,0 +1,317 @@ +import { unique } from "#fp"; +import { foldPaidOrderSnapshot } from "#routes/api/payment-processing/snapshot/fold.ts"; +import type { + PaidOrderSnapshot, + SnapshotDayPriceRow, + SnapshotGroupRow, + SnapshotModifierRow, + SnapshotRows, +} from "#routes/api/payment-processing/snapshot/types.ts"; +import { bookingEventGroup } from "#shared/accounting/mappers.ts"; +import { + accountBalanceSubquery, + creditsLessWriteoffDebits, +} from "#shared/accounting/projection-sql.ts"; +import { lineGroupIds } from "#shared/booking/signed-metadata.ts"; +import type { BookingIntent } from "#shared/booking-intent.ts"; +import { decrypt } from "#shared/crypto/encryption.ts"; +import type { EnvKeyEncrypted } from "#shared/crypto/sealed.ts"; +import { + inPlaceholders, + queryBatch, + resultRows, + type SqlStatement, +} from "#shared/db/client.ts"; +import { hashEmail, hashPhone } from "#shared/db/contact-preferences.ts"; +import { imageFilenameSubqueries } from "#shared/db/images.ts"; +import { decryptListingWithCount } from "#shared/db/listings/records.ts"; +import type { ListingRecordRow } from "#shared/db/listings/select.ts"; +import { rawListingsTable } from "#shared/db/listings/table.ts"; +import type { GroupListing, ListingWithCount } from "#shared/types.ts"; + +const selectIn = (column: string, values: readonly unknown[]): string => + values.length === 0 ? "0" : `${column} IN (${inPlaceholders(values)})`; + +const statement = (sql: string, args: SqlStatement["args"]): SqlStatement => ({ + args, + sql, +}); + +const listingStatement = (listingIds: number[]): SqlStatement => { + const columns = rawListingsTable.columns + .map((column) => `listing.${column}`) + .join(", "); + const requested = selectIn("listing.id", listingIds); + const linked = selectIn("listingParent.parent_listing_id", listingIds); + return statement( + `SELECT ${columns}, + ${creditsLessWriteoffDebits("revenue", "listing.id")} AS income, + -${accountBalanceSubquery("cost", "listing.id")} AS cost, + COALESCE((SELECT json_group_object(listingPrice.price_id, listingPrice.unit_price) + FROM listing_prices AS listingPrice + WHERE listingPrice.listing_id = listing.id + AND listingPrice.price_type = 'day_count'), '{}') AS day_prices, + ${imageFilenameSubqueries("listing", "listing.id")}, + listing.booked_quantity AS attendee_count + FROM listings AS listing + WHERE ${requested} + OR listing.id IN ( + SELECT listingParent.child_listing_id + FROM listing_parents AS listingParent + WHERE ${linked} + )`, + [...listingIds, ...listingIds], + ); +}; + +const usableContactHashes = async ( + intent: BookingIntent, +): Promise => { + const values: Promise[] = []; + if (intent.email?.trim()) values.push(hashEmail(intent.email)); + if (intent.phone?.trim()) values.push(hashPhone(intent.phone)); + return Promise.all(values); +}; + +const selectedIds = ( + values: Record | undefined, + idOf: (value: Value) => number, +): number[] => + unique( + Object.values(values ?? {}) + .flat() + .map(idOf), + ); + +const snapshotStatements = ( + eventGroup: string, + intent: BookingIntent, + contactHashes: string[], +): SqlStatement[] => { + const listingIds = unique(intent.items.map((item) => item.e)); + const groupIds = [...lineGroupIds(intent.items)]; + const modifierIds = unique(intent.modifiers.map((ref) => ref.i)); + const answerIds = selectedIds(intent.listingAnswerIds, (id) => id); + const textQuestionIds = selectedIds( + intent.listingTextAnswerIds, + (ref) => ref.q, + ); + return [ + statement( + `SELECT EXISTS(SELECT 1 FROM transfers WHERE event_group = ? LIMIT 1) AS has_legs, + (SELECT attendee_id FROM listing_attendees WHERE ledger_event_group = ? LIMIT 1) AS owner_attendee_id`, + [eventGroup, eventGroup], + ), + listingStatement(listingIds), + statement( + `SELECT id, name, hide_package_listings + FROM groups AS groupRow + WHERE ${selectIn("groupRow.id", groupIds)} AND groupRow.is_package = 1 + ORDER BY groupRow.id`, + groupIds, + ), + statement( + `SELECT groupListing.group_id, groupListing.listing_id, groupListing.quantity, + (SELECT listingPrice.unit_price FROM listing_prices AS listingPrice + WHERE listingPrice.listing_id = groupListing.listing_id + AND listingPrice.price_type = 'group' + AND listingPrice.price_id = CAST(groupListing.group_id AS TEXT)) AS package_price + FROM group_listings AS groupListing + WHERE ${selectIn("groupListing.group_id", groupIds)} + ORDER BY groupListing.group_id, groupListing.listing_id`, + groupIds, + ), + statement( + `SELECT groupListing.group_id, listingPrice.listing_id, + CAST(SUBSTR(listingPrice.price_id, LENGTH(CAST(groupListing.group_id AS TEXT)) + 2) AS INTEGER) AS days, + listingPrice.unit_price + FROM group_listings AS groupListing + JOIN listing_prices AS listingPrice + ON listingPrice.listing_id = groupListing.listing_id + AND listingPrice.price_type = 'group_day' + AND listingPrice.price_id LIKE (groupListing.group_id || '/%') + WHERE ${selectIn("groupListing.group_id", groupIds)}`, + groupIds, + ), + statement( + `SELECT DISTINCT groupListing.listing_id + FROM group_listings AS groupListing + JOIN groups AS groupRow ON groupRow.id = groupListing.group_id + WHERE ${selectIn("groupListing.listing_id", listingIds)} + AND groupRow.is_package = 1 AND groupRow.hide_package_listings = 1`, + listingIds, + ), + statement( + `SELECT parent_listing_id, child_listing_id + FROM listing_parents AS listingParent + WHERE ${selectIn("listingParent.parent_listing_id", listingIds)} + OR ${selectIn("listingParent.child_listing_id", listingIds)} + ORDER BY parent_listing_id, child_listing_id`, + [...listingIds, ...listingIds], + ), + statement( + `SELECT modifier.id, modifier.name, modifier.calc_kind, modifier.calc_value, + modifier.direction, modifier.min_visits, modifier.scope, modifier.trigger + FROM modifiers AS modifier + WHERE ${selectIn("modifier.id", modifierIds)} AND modifier.active = 1 + ORDER BY modifier.id`, + modifierIds, + ), + statement( + `SELECT modifierListing.modifier_id, modifierListing.listing_id + FROM modifier_listings AS modifierListing + WHERE ${selectIn("modifierListing.modifier_id", modifierIds)} + UNION + SELECT modifierGroup.modifier_id, groupListing.listing_id + FROM modifier_groups AS modifierGroup + JOIN group_listings AS groupListing ON groupListing.group_id = modifierGroup.group_id + WHERE ${selectIn("modifierGroup.modifier_id", modifierIds)}`, + [...modifierIds, ...modifierIds], + ), + statement( + `SELECT visits FROM contact_preferences + WHERE ${selectIn("contact_hash", contactHashes)}`, + contactHashes, + ), + statement( + "SELECT id FROM attendee_statuses WHERE is_public_default = 1 ORDER BY sort_order, id", + [], + ), + statement( + `SELECT id, question_id FROM answers AS answer + WHERE ${selectIn("answer.id", answerIds)}`, + answerIds, + ), + statement( + `SELECT id FROM questions AS question + WHERE ${selectIn("question.id", textQuestionIds)}`, + textQuestionIds, + ), + ]; +}; + +type RawGroupRow = { + hide_package_listings: number; + id: number; + name: EnvKeyEncrypted; +}; +type RawModifierRow = { + calc_kind: SnapshotModifierRow["calcKind"]; + calc_value: number; + direction: SnapshotModifierRow["direction"]; + id: number; + min_visits: number; + name: EnvKeyEncrypted; + scope: SnapshotModifierRow["scope"]; + trigger: SnapshotModifierRow["trigger"]; +}; + +const mapListings = async ( + rows: ListingRecordRow[], +): Promise => + Promise.all(rows.map(decryptListingWithCount)); + +const decryptNames = async ( + rows: Row[], +): Promise & { name: string }>> => + Promise.all( + rows.map(async ({ name, ...row }) => ({ + ...row, + name: await decrypt(name), + })), + ); + +const mapNamedRows = async ( + rows: Row[], + toOutput: (row: Omit & { name: string }) => Output, +): Promise => (await decryptNames(rows)).map(toOutput); + +const mapGroups = async (rows: RawGroupRow[]): Promise => + mapNamedRows(rows, (row) => ({ + hideListings: row.hide_package_listings === 1, + id: row.id, + name: row.name, + })); + +const mapModifiers = async ( + rows: RawModifierRow[], +): Promise => + mapNamedRows(rows, (row) => ({ + calcKind: row.calc_kind, + calcValue: row.calc_value, + direction: row.direction, + id: row.id, + minVisits: row.min_visits, + name: row.name, + scope: row.scope, + trigger: row.trigger, + })); + +export const loadPaidOrderSnapshot = async ( + eventId: string, + intent: BookingIntent, +): Promise => { + const [eventGroup, contactHashes] = await Promise.all([ + bookingEventGroup(eventId), + usableContactHashes(intent), + ]); + const results = await queryBatch( + snapshotStatements(eventGroup, intent, contactHashes), + ); + const ledger = resultRows<{ + has_legs: number; + owner_attendee_id: number | null; + }>(results[0]!)[0]!; + const rows: SnapshotRows = { + answerRows: resultRows<{ id: number; question_id: number }>( + results[11]!, + ).map((row) => ({ answerId: row.id, questionId: row.question_id })), + childEdges: resultRows<{ + child_listing_id: number; + parent_listing_id: number; + }>(results[6]!).map((row) => ({ + childId: row.child_listing_id, + parentId: row.parent_listing_id, + })), + groups: await mapGroups(resultRows(results[2]!)), + hiddenMemberIds: resultRows<{ listing_id: number }>(results[5]!).map( + (row) => row.listing_id, + ), + ledger: { + hasLegs: ledger.has_legs === 1, + ownerAttendeeId: ledger.owner_attendee_id, + }, + listings: await mapListings(resultRows(results[1]!)), + memberships: resultRows(results[3]!), + modifierScopes: resultRows<{ listing_id: number; modifier_id: number }>( + results[8]!, + ).map((row) => ({ + listingId: row.listing_id, + modifierId: row.modifier_id, + })), + modifiers: await mapModifiers(resultRows(results[7]!)), + publicStatusIds: resultRows<{ id: number }>(results[10]!).map( + (row) => row.id, + ), + textQuestionIds: resultRows<{ id: number }>(results[12]!).map( + (row) => row.id, + ), + visitCounts: resultRows<{ visits: number }>(results[9]!).map( + (row) => row.visits, + ), + }; + const dayPrices = resultRows<{ + days: number; + group_id: number; + listing_id: number; + unit_price: number; + }>(results[4]!).map( + (row): SnapshotDayPriceRow => ({ + days: row.days, + groupId: row.group_id, + listingId: row.listing_id, + unitPrice: row.unit_price, + }), + ); + return foldPaidOrderSnapshot(intent.modifiers, rows, dayPrices); +}; diff --git a/src/features/api/payment-processing/snapshot/types.ts b/src/features/api/payment-processing/snapshot/types.ts new file mode 100644 index 0000000000..f381ab2bed --- /dev/null +++ b/src/features/api/payment-processing/snapshot/types.ts @@ -0,0 +1,61 @@ +import type { ModifierSpec } from "#shared/payments.ts"; +import type { RegistrationPackageFacts } from "#shared/registration-package-facts.ts"; +import type { BookingLedgerDisposition } from "#shared/session-ledger.ts"; +import type { GroupListing, ListingWithCount } from "#shared/types.ts"; + +export interface PaidQuestionFacts { + questionIdByAnswerId: ReadonlyMap; + textQuestionIds: ReadonlySet; +} + +export interface PaidOrderSnapshot { + childrenByParentId: ReadonlyMap; + hiddenPackageMemberIds: ReadonlySet; + ledger: BookingLedgerDisposition; + listingsById: ReadonlyMap; + modifierSpecs: ModifierSpec[]; + notificationPackages: RegistrationPackageFacts; + parentsByChildId: ReadonlyMap; + publicStatusId: number; + questions: PaidQuestionFacts; + visits: number; +} + +export interface SnapshotGroupRow { + hideListings: boolean; + id: number; + name: string; +} + +export interface SnapshotDayPriceRow { + days: number; + groupId: number; + listingId: number; + unitPrice: number; +} + +export interface SnapshotModifierRow { + calcKind: ModifierSpec["kind"]; + calcValue: number; + direction: "charge" | "discount"; + id: number; + minVisits: number; + name: string; + scope: "all" | "groups" | "listings"; + trigger: ModifierSpec["trigger"]; +} + +export interface SnapshotRows { + answerRows: Array<{ answerId: number; questionId: number }>; + childEdges: Array<{ childId: number; parentId: number }>; + groups: SnapshotGroupRow[]; + hiddenMemberIds: number[]; + ledger: { hasLegs: boolean; ownerAttendeeId: number | null }; + listings: ListingWithCount[]; + memberships: GroupListing[]; + modifierScopes: Array<{ listingId: number; modifierId: number }>; + modifiers: SnapshotModifierRow[]; + publicStatusIds: number[]; + textQuestionIds: number[]; + visitCounts: number[]; +} diff --git a/src/features/api/payment-processing/store-refund.ts b/src/features/api/payment-processing/store-refund.ts index 874b1f6c17..796e4c68b6 100644 --- a/src/features/api/payment-processing/store-refund.ts +++ b/src/features/api/payment-processing/store-refund.ts @@ -157,6 +157,7 @@ export const storeRefundedBooking = async ( intent: BookingIntent, bookings: PlaceholderBookings, spec: RefundSpec, + publicStatusId: number, ): Promise => { if (spec.notify) addPendingWork(sendNtfyError(spec.notify)); const listingId = bookings[0]!.listingId; @@ -164,7 +165,7 @@ export const storeRefundedBooking = async ( // 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 attendeesApi.createAttendeeAtomic({ - ...(await attendeeBaseFields(session, intent)), + ...attendeeBaseFields(session, intent, publicStatusId), allowOverbook: true, bookings, }); diff --git a/src/shared/db/activity-log.ts b/src/shared/db/activity-log.ts index c1a2db9aae..bbef60f8ea 100644 --- a/src/shared/db/activity-log.ts +++ b/src/shared/db/activity-log.ts @@ -143,7 +143,7 @@ const toListingId = (listing?: ListingRef | null): number | null => /** One thing to record in the log: what happened, and which listing/attendee it * happened to. */ -type ActivityToLog = { +export type ActivityToLog = { message: string; listing?: ListingRef | null | undefined; attendeeId?: number | null | undefined; diff --git a/src/shared/db/attendee-types.ts b/src/shared/db/attendee-types.ts index dacca6577d..7aebd29c33 100644 --- a/src/shared/db/attendee-types.ts +++ b/src/shared/db/attendee-types.ts @@ -102,6 +102,7 @@ export type AttendeeInput = ContactFields & { kind?: AttendeeKind; paymentId?: string; bookings: ListingBooking[]; + parentIdsByChild?: ReadonlyMap; /** Order-level remaining balance in minor units (plaintext). Defaults to 0. */ remainingBalance?: number; /** Owner-defined status id assigned to the new attendee. */ diff --git a/src/shared/db/attendees/create.ts b/src/shared/db/attendees/create.ts index 98d7904999..59079b3ed6 100644 --- a/src/shared/db/attendees/create.ts +++ b/src/shared/db/attendees/create.ts @@ -116,7 +116,10 @@ const prepareAttendeeWrite = async ( }; } - const bookings = await annotateOrderParents(rawBookings); + const bookings = await annotateOrderParents( + rawBookings, + input.parentIdsByChild, + ); const contactInfo = contactInfoFromInput(input); const enc = await encryptAttendeeFields( { diff --git a/src/shared/db/attendees/order-parents.ts b/src/shared/db/attendees/order-parents.ts index d26f3a4902..37a74333c3 100644 --- a/src/shared/db/attendees/order-parents.ts +++ b/src/shared/db/attendees/order-parents.ts @@ -38,10 +38,12 @@ import { listingParents } from "#shared/db/listing-parents.ts"; * child listing id. Children with no in-order parent are omitted. */ const inOrderParentByChild = async ( listingIds: readonly number[], + suppliedParents?: ReadonlyMap, ): Promise> => { - const parentsByChild = await listingParents.getIdsByKeys(listingIds); + const parentsByChild = + suppliedParents ?? (await listingParents.getIdsByKeys(listingIds)); const bookedInOrder = new Set(listingIds); - return reduce((result, [childId, parentIds]: [number, number[]]) => { + return reduce((result, [childId, parentIds]: [number, readonly number[]]) => { const inOrderParent = parentIds.find((parentId) => bookedInOrder.has(parentId), ); @@ -65,6 +67,7 @@ const inOrderParentByChild = async ( */ export const annotateOrderParents = async ( bookings: ListingBooking[], + parentsByChild?: ReadonlyMap, ): Promise => { // Pre-expanded orders (expandChildAllocations path) already carry orderToken // and exact parentListingId. Skip the edge-based recomputation to preserve @@ -73,6 +76,7 @@ export const annotateOrderParents = async ( if (bookings.some((b) => b.orderToken)) return bookings; const parentByChild = await inOrderParentByChild( bookings.map((b) => b.listingId), + parentsByChild, ); if (parentByChild.size === 0) return bookings; const orderToken = crypto.randomUUID(); diff --git a/src/shared/db/attendees/queries.ts b/src/shared/db/attendees/queries.ts index 16a16d0814..0b52fddaca 100644 --- a/src/shared/db/attendees/queries.ts +++ b/src/shared/db/attendees/queries.ts @@ -366,16 +366,6 @@ export const hasPaidLine = rowExistsForIdList( * whose legs already exist would be mistaken for a capacity failure and refund a * live ticket. */ -export const attendeeIdByLedgerEventGroup = async ( - eventGroup: string, -): Promise => { - const row = await queryOne<{ attendee_id: number }>( - "SELECT attendee_id FROM listing_attendees WHERE ledger_event_group = ? LIMIT 1", - [eventGroup], - ); - return row?.attendee_id ?? null; -}; - /** * Get an attendee by ID without decrypting PII * Used for payment callbacks and webhooks where decryption is not needed diff --git a/src/shared/db/modifier-resolve.ts b/src/shared/db/modifier-resolve.ts index 733ab7b6d9..7ea5b49875 100644 --- a/src/shared/db/modifier-resolve.ts +++ b/src/shared/db/modifier-resolve.ts @@ -11,7 +11,6 @@ import { unique } from "#fp"; import { t } from "#i18n"; import { itemsSubtotal } from "#shared/booking-fee.ts"; -import type { ModifierRef } from "#shared/booking-intent.ts"; import { hmacHash } from "#shared/crypto/hashing.ts"; import { formatCurrency, toMinorUnits } from "#shared/currency.ts"; import { @@ -716,27 +715,3 @@ export const firstChildUnreachableAddOnForListings = async ( } return null; }; - -/** - * Rebuild modifier specs from the references stored in session metadata, - * re-fetching each modifier's current values (and scope) from the database. - * References to modifiers that have since been removed or deactivated are - * dropped (the webhook then sees a total mismatch and refunds). - */ -export const specsFromRefs = async ( - refs: ModifierRef[], - ctx: PricingContext = NO_VISITS, -): Promise => { - if (refs.length === 0) return []; - const byId = await activeModifiersById(); - const refModifiers = refs - .map((ref) => byId.get(ref.i)) - .filter((modifier): modifier is Modifier => modifier !== undefined); - const scopes = await listingIdsByModifierId(refModifiers); - const specs = refs.map((ref) => { - const modifier = byId.get(ref.i); - if (modifier && modifier.min_visits > ctx.visits) return null; - return modifier ? toSpec(modifier, ref.q, scopes.get(modifier.id)!) : null; - }); - return specs.filter((s): s is ModifierSpec => s !== null); -}; diff --git a/src/shared/db/processed-payments.ts b/src/shared/db/processed-payments.ts index eb5b4a329e..3b7cdc74e9 100644 --- a/src/shared/db/processed-payments.ts +++ b/src/shared/db/processed-payments.ts @@ -7,11 +7,8 @@ * - Creates the attendee and sets attendee_id atomically, closing the crash * window between creation and a separate finalize call. * - * If reserveSession fails (session already claimed), we check if it's: - * - Finalized (attendee_id set) → return success with existing attendee - * - Still processing (attendee_id NULL) → check staleness - * - Stale (>5min old) → delete and retry (process likely crashed) - * - Fresh → return conflict error (still being processed) + * reserveSession() claims missing and stale unresolved rows with one conditional + * upsert. A lookup in the same batch returns any existing outcome. */ import * as v from "valibot"; @@ -20,10 +17,14 @@ import type { EnvKeyEncrypted, OwnerKeyEncrypted, } from "#shared/crypto/sealed.ts"; -import { execute, insert, queryOne } from "#shared/db/client.ts"; +import { + execute, + executeBatchWithResults, + resultRows, +} from "#shared/db/client.ts"; import { encryptPaymentReference } from "#shared/db/payment-references.ts"; import { STALE_RESERVATION_MS } from "#shared/limits.ts"; -import { isoBefore, nowIso, nowMs } from "#shared/now.ts"; +import { isoBefore, nowIso } from "#shared/now.ts"; import { defineStoredJson } from "#shared/validation/stored-json.ts"; export { STALE_RESERVATION_MS }; @@ -85,31 +86,6 @@ export type ReserveSessionResult = | { reserved: true } | { reserved: false; existing: ProcessedPayment }; -/** - * Check if a payment session has already been processed - */ -export const isSessionProcessed = ( - sessionId: string, -): Promise => - queryOne( - "SELECT payment_session_id, attendee_id, processed_at, ticket_tokens, failure_data, payment_reference, provider_refunded_at " + - "FROM processed_payments WHERE payment_session_id = ?", - [sessionId], - ); - -/** - * Check if a reservation is stale (abandoned by a crashed process) - */ -export const isReservationStale = (processedAt: string): boolean => { - const reservedAt = new Date(processedAt).getTime(); - return nowMs() - reservedAt > STALE_RESERVATION_MS; -}; - -/** True when a row is an in-progress reservation with no recorded outcome — the - * in-memory mirror of the {@link UNRESOLVED_RESERVATION} SQL predicate. */ -export const isUnresolvedReservation = (row: ProcessedPayment): boolean => - row.attendee_id === null && row.failure_data === ""; - /** Execute a SQL statement parameterized by a single payment session ID */ const execWithSessionId = (sessionId: string, sql: string): Promise => execute(sql, [sessionId]); @@ -127,13 +103,10 @@ const sessionIdWrite = * Deletes only a still-unresolved row, so it never clobbers a finalized success * or a recorded terminal failure that a racing delivery may have written. * - * Two callers: - * - {@link reserveSession} releases a *stale* reservation (abandoned by a - * crashed process) before retrying the claim. - * - the webhook releases a *fresh* reservation whose refund of a real payment - * just failed: recording no outcome but holding the lock would make the next - * redelivery collide and return 409 until the row goes stale (~5 min), - * gating refund recovery on a local timer instead of provider redelivery. + * The webhook releases a *fresh* reservation whose refund of a real payment + * just failed: recording no outcome but holding the lock would make the next + * redelivery collide and return 409 until the row goes stale (~5 min), gating + * refund recovery on a local timer instead of provider redelivery. */ export const releaseReservation: (sessionId: string) => Promise = sessionIdWrite( @@ -156,54 +129,44 @@ export const deleteAllStaleReservations = async (): Promise => { }; /** - * Reserve a payment session for processing (first phase of two-phase lock) - * Inserts with NULL attendee_id to claim the session. - * Returns { reserved: true } if we claimed it, or { reserved: false, existing } if already claimed. - * - * Handles abandoned reservations: if an existing reservation has NULL attendee_id - * and is older than STALE_RESERVATION_MS, we assume the process crashed and - * delete the stale record to allow retry. + * Reserve a payment session for processing (first phase of two-phase lock). + * Missing and stale unresolved rows are claimed atomically. Existing fresh, + * finalized, and failed rows are returned without changing them. */ export const reserveSession = async ( sessionId: string, ): Promise => { - try { - const { sql, args } = insert("processed_payments", { - attendee_id: null, - payment_session_id: sessionId, - processed_at: nowIso(), - }); - await execute(sql, args); - return { reserved: true }; - } catch (e) { - const errorMsg = String(e); - if ( - errorMsg.includes("UNIQUE constraint") || - errorMsg.includes("PRIMARY KEY constraint") - ) { - // Session already claimed - get existing record (must exist: UNIQUE error proves it) - const existing = (await isSessionProcessed(sessionId))!; - - // Check if reservation is stale (abandoned by crashed process). A row - // carrying a recorded terminal failure is never stale — it is replayed - // by the caller instead of being deleted and re-processed. - if ( - isUnresolvedReservation(existing) && - isReservationStale(existing.processed_at) - ) { - // Release the abandoned row and retry the claim. This recurses at most - // one extra level: any row present after the delete must have been - // inserted at ~now (by this retry or a racing request), so it is fresh - // — isReservationStale is false for it and we fall through to the - // conflict return rather than looping. - await releaseReservation(sessionId); - return reserveSession(sessionId); - } - - return { existing, reserved: false }; - } - throw e; + const claimedAt = nowIso(); + const staleBefore = new Date( + new Date(claimedAt).getTime() - STALE_RESERVATION_MS, + ).toISOString(); + const [claimResult, lookupResult] = await executeBatchWithResults([ + { + args: [sessionId, claimedAt, staleBefore], + sql: `INSERT INTO processed_payments (payment_session_id, attendee_id, processed_at) + VALUES (?, NULL, ?) + ON CONFLICT(payment_session_id) DO UPDATE SET + attendee_id = NULL, + processed_at = excluded.processed_at, + ticket_tokens = '', + failure_data = '', + payment_reference = '', + provider_refunded_at = '' + WHERE ${UNRESOLVED_RESERVATION} + AND processed_payments.processed_at < ? + RETURNING payment_session_id`, + }, + { + args: [sessionId], + sql: "SELECT payment_session_id, attendee_id, processed_at, ticket_tokens, failure_data, payment_reference, provider_refunded_at FROM processed_payments WHERE payment_session_id = ?", + }, + ]); + if (resultRows(claimResult!)[0] !== undefined) return { reserved: true }; + const existing = resultRows(lookupResult!)[0]; + if (!existing) { + throw new Error(`Reserved payment session is missing: ${sessionId}`); } + return { existing, reserved: false }; }; /** Encrypt ticket tokens for the atomic payment finalize. */ diff --git a/src/shared/db/questions/attendee-answers/save.ts b/src/shared/db/questions/attendee-answers/save.ts index 2da2a1c367..a3e174dd5d 100644 --- a/src/shared/db/questions/attendee-answers/save.ts +++ b/src/shared/db/questions/attendee-answers/save.ts @@ -8,8 +8,10 @@ import { fieldById, unique } from "#fp"; import { + executeBatch, inPlaceholders, resultRows, + type SqlStatement, type TxScope, withTransaction, } from "#shared/db/client.ts"; @@ -82,6 +84,74 @@ const dedupeTextAnswerIdsByQuestion = ( textAnswerIds: TextAnswerId[], ): TextAnswerId[] => dedupeByQuestion(textAnswerIds); +type NormalizedAnswerSet = AttendeeAnswerSet & { + textAnswerIds: TextAnswerId[]; + textAnswers: TextAnswer[]; +}; + +const storedIdAnswerStatements = ( + normalized: Map, +): SqlStatement[] => { + const attendeeIds = [...normalized.keys()]; + const statements: SqlStatement[] = [ + { + args: attendeeIds, + sql: `DELETE FROM attendee_answers WHERE attendee_id IN (${inPlaceholders(attendeeIds)})`, + }, + ]; + const choiceRows = [...normalized].flatMap(([attendeeId, set]) => + set.answerIds.map((answerId, position) => ({ + answerId, + attendeeId, + position, + })), + ); + if (choiceRows.length > 0) { + statements.push({ + args: choiceRows.flatMap((row) => [ + row.attendeeId, + row.answerId, + row.position, + ]), + sql: `WITH selected(attendee_id, answer_id, position) AS ( + VALUES ${choiceRows.map(() => "(?, ?, ?)").join(", ")} + ), ranked AS ( + SELECT selected.attendee_id, selected.answer_id, answer.question_id, + ROW_NUMBER() OVER ( + PARTITION BY selected.attendee_id, answer.question_id + ORDER BY selected.position DESC + ) AS choice_order + FROM selected + INNER JOIN answers AS answer ON answer.id = selected.answer_id + ) + INSERT INTO attendee_answers (attendee_id, answer_id, question_id) + SELECT attendee_id, answer_id, question_id + FROM ranked + WHERE choice_order = 1`, + }); + } + const textRows = [...normalized].flatMap(([attendeeId, set]) => + set.textAnswerIds.map((answer) => ({ attendeeId, ...answer })), + ); + if (textRows.length > 0) { + statements.push({ + args: textRows.flatMap((row) => [ + row.attendeeId, + row.questionId, + row.stringId, + ]), + sql: `WITH selected(attendee_id, question_id, string_id) AS ( + VALUES ${textRows.map(() => "(?, ?, ?)").join(", ")} + ) + INSERT INTO attendee_answers (attendee_id, question_id, string_id) + SELECT selected.attendee_id, selected.question_id, selected.string_id + FROM selected + INNER JOIN questions AS question ON question.id = selected.question_id`, + }); + } + return statements; +}; + /** The subset of `questionIds` that still exist — text answers reference a * question directly, so a question deleted between checkout and finalize must * be dropped (mirrors the deleted-answer skip on the choice path) rather than @@ -147,13 +217,12 @@ const existingQuestionIdsTx = async ( export const saveAttendeeAnswers = async ( answersByAttendee: Map, ): Promise => { - const normalized = new Map< - number, - AttendeeAnswerSet & { - textAnswerIds: TextAnswerId[]; - textAnswers: TextAnswer[]; - } - >( + const storedIdsOnly = [...answersByAttendee.values()].every( + (set) => + !Array.isArray(set) && + (set.textAnswers === undefined || set.textAnswers.length === 0), + ); + const normalized = new Map( [...answersByAttendee].map(([id, set]) => { const answerSet = normalizeAnswerSet(set); return [ @@ -167,6 +236,10 @@ export const saveAttendeeAnswers = async ( }), ); if (normalized.size === 0) return; + if (storedIdsOnly) { + await executeBatch(storedIdAnswerStatements(normalized)); + return; + } // Precompute the encrypted + HMAC-indexed string rows BEFORE opening the // transaction. The crypto (hybrid encryption + blind index) is CPU-bound and // holds no DB statement; running it inside `withTransaction` would keep the diff --git a/src/shared/email-renderer.ts b/src/shared/email-renderer.ts index da241aa3bc..f9323ae94b 100644 --- a/src/shared/email-renderer.ts +++ b/src/shared/email-renderer.ts @@ -255,9 +255,13 @@ export const buildTemplateData = async ( entries: EmailEntry[], currency: string, ticketUrl: string, - options: { hidePackageMembers?: boolean } = {}, + options: { + hidePackageMembers?: boolean; + packageDisplays?: ReadonlyMap; + } = {}, ): Promise => { - const displays = await packageDisplaysForRows(entries); + const displays = + options.packageDisplays ?? (await packageDisplaysForRows(entries)); // The buyer's confirmation (hidePackageMembers) collapses hidden packages' // rows; the admin notification keeps them. const templateEntries: TemplateEntry[] = options.hidePackageMembers diff --git a/src/shared/email.ts b/src/shared/email.ts index a08c35a093..609463f039 100644 --- a/src/shared/email.ts +++ b/src/shared/email.ts @@ -7,7 +7,6 @@ import * as v from "valibot"; import { chunk, lazyRef } from "#fp"; import { t } from "#i18n"; import { toBase64 } from "#shared/crypto/utils.ts"; -import { packageDisplaysForRows } from "#shared/db/groups.ts"; import { settings } from "#shared/db/settings.ts"; import { type BuyerEntryGroup, @@ -20,6 +19,10 @@ import { getEnv } from "#shared/env.ts"; import { errorMessage } from "#shared/error-message.ts"; import { type FetchResult, fetchText } from "#shared/fetch.ts"; import { ErrorCode, logError } from "#shared/logger.ts"; +import { + loadRegistrationPackageFacts, + type RegistrationNotification, +} from "#shared/registration-package-facts.ts"; import { generateSvgTicket, type SvgTicketData } from "#shared/svg-ticket.ts"; import { buildCheckinUrl, buildTicketUrl } from "#shared/ticket-url.ts"; import { @@ -70,6 +73,12 @@ export type EmailConfig = { fromAddress: ValidEmail; }; +export type RegistrationEmailDelivery = { + attendeeEmail: ValidEmail | null; + businessEmail: ValidEmail | null; + config: EmailConfig; +}; + /** Read email config from DB settings. Falls back to business email for * fromAddress. Returns null if not configured or the from address is invalid. */ export const getEmailConfig = (): EmailConfig | null => { @@ -124,6 +133,20 @@ export const getActiveEmailConfig = (): EmailConfig | null => { return siteConfig !== null ? siteConfig : getHostEmailConfig(); }; +/** Resolve whether a registration has configured email and somebody to notify. */ +export const registrationEmailDelivery = ( + entries: EmailEntry[], +): RegistrationEmailDelivery | null => { + const config = getActiveEmailConfig(); + if (!config) return null; + const attendeeRaw = entries[0]?.attendee.email; + const attendeeEmail = attendeeRaw ? parseEmail(attendeeRaw) : null; + const businessEmail = parseEmail(settings.businessEmail); + return attendeeEmail || businessEmail + ? { attendeeEmail, businessEmail, config } + : null; +}; + type Headers = Record; /** A function that turns the email config and a message into a provider-specific * value — the request tuple for a whole provider, or just its request body. */ @@ -413,16 +436,13 @@ export const buildTicketAttachments = async ( * Silently skips if email is not configured. * Attaches one SVG ticket per entry to the confirmation email. */ -export const sendRegistrationEmails = async ( - entries: EmailEntry[], - currency: string, -): Promise => { - const config = getActiveEmailConfig(); - if (!config) return; - - const attendeeRaw = entries[0]?.attendee.email; - const attendeeEmail = attendeeRaw ? parseEmail(attendeeRaw) : null; - const businessEmail = parseEmail(settings.businessEmail); +export const sendRegistrationEmails: RegistrationNotification< + EmailEntry +> = async (entries, currency, suppliedFacts) => { + const delivery = registrationEmailDelivery(entries); + if (!delivery) return; + const { attendeeEmail, businessEmail, config } = delivery; + const facts = suppliedFacts ?? (await loadRegistrationPackageFacts(entries)); const ticketUrl = buildTicketUrl(entries); const promises: Promise[] = []; @@ -431,12 +451,10 @@ export const sendRegistrationEmails = async ( // The buyer's confirmation hides every hidden package's member listings — // both in the email body and in the attached ticket SVGs — via the one // buyer-grouping chokepoint (a mixed order conceals each bundle it holds). - const groups = buyerEntryGroups( - entries, - await packageDisplaysForRows(entries), - ); + const groups = buyerEntryGroups(entries, facts.displays); const data = await buildTemplateData(entries, currency, ticketUrl, { hidePackageMembers: true, + packageDisplays: facts.displays, }); const [confirmation, attachments] = await Promise.all([ renderEmailContent("confirmation", data), @@ -454,7 +472,9 @@ export const sendRegistrationEmails = async ( if (businessEmail) { // The admin notification always shows package members, even when hidden. - const data = await buildTemplateData(entries, currency, ticketUrl); + const data = await buildTemplateData(entries, currency, ticketUrl, { + packageDisplays: facts.displays, + }); const notification = await renderEmailContent("admin", data); promises.push( sendEmail(config, { diff --git a/src/shared/registration-package-facts.ts b/src/shared/registration-package-facts.ts new file mode 100644 index 0000000000..87be011a5e --- /dev/null +++ b/src/shared/registration-package-facts.ts @@ -0,0 +1,57 @@ +import { mapNotNullish, unique } from "#fp"; +import { + getPackageDisplaysByIds, + loadPackageMemberPricingByGroupIds, + type PackageDisplay, +} from "#shared/db/groups.ts"; + +export interface RegistrationPackagePricing { + dayPriceMap: ReadonlyMap>; + memberIds: ReadonlySet; + priceMap: ReadonlyMap; + quantityMap: ReadonlyMap; +} + +export interface RegistrationPackageFacts { + displays: ReadonlyMap; + pricingByGroup: ReadonlyMap; +} + +type PackageRow = { attendee: { package_group_id: number } }; + +export type RegistrationNotification = ( + entries: Entry[], + currency: string, + suppliedFacts?: RegistrationPackageFacts, +) => Promise; + +export const loadRegistrationPackageFacts = async ( + rows: readonly PackageRow[], +): Promise => { + const groupIds = unique( + mapNotNullish((row: PackageRow) => + row.attendee.package_group_id > 0 ? row.attendee.package_group_id : null, + )(rows), + ); + if (groupIds.length === 0) { + return { displays: new Map(), pricingByGroup: new Map() }; + } + const [displays, pricing] = await Promise.all([ + getPackageDisplaysByIds(groupIds), + loadPackageMemberPricingByGroupIds(groupIds), + ]); + return { + displays, + pricingByGroup: new Map( + [...pricing].map(([groupId, facts]) => [ + groupId, + { + dayPriceMap: facts.dayPrices, + memberIds: new Set(facts.rows.map((row) => row.listing_id)), + priceMap: facts.prices, + quantityMap: facts.quantities, + }, + ]), + ), + }; +}; diff --git a/src/shared/session-ledger.ts b/src/shared/session-ledger.ts index 926d19f0b8..cbec3ba818 100644 --- a/src/shared/session-ledger.ts +++ b/src/shared/session-ledger.ts @@ -23,10 +23,6 @@ * events. */ -import { bookingEventGroup } from "#shared/accounting/mappers.ts"; -import { eventGroupHasLegs } from "#shared/accounting/queries.ts"; -import { attendeeIdByLedgerEventGroup } from "#shared/db/attendees/queries.ts"; - /** What the ledger already records for a booking session (keyed on its event group). */ export type BookingLedgerDisposition = | { status: "unrecorded" } @@ -55,11 +51,3 @@ export const classifyBookingLedger = ( * lookup is skipped when no legs exist (the common fresh-session case), so an * unrecorded session costs a single existence probe. */ -export const bookingLedgerDisposition = async ( - eventId: string, -): Promise => { - const group = await bookingEventGroup(eventId); - const hasLegs = await eventGroupHasLegs(group); - const owner = hasLegs ? await attendeeIdByLedgerEventGroup(group) : null; - return classifyBookingLedger(hasLegs, owner); -}; diff --git a/src/shared/webhook.ts b/src/shared/webhook.ts index e3d88cfeb5..9b9ef2c5ae 100644 --- a/src/shared/webhook.ts +++ b/src/shared/webhook.ts @@ -10,14 +10,18 @@ import { packageMemberPriceRule, } from "#shared/booking/price-tree.ts"; import { bookedSpanDays } from "#shared/dates.ts"; -import { logActivities, logActivity } from "#shared/db/activity-log.ts"; -import { getBuiltSiteByRenewalTokenIndex } from "#shared/db/built-sites.ts"; import { - loadPackageMemberPricingByGroupIds, - type PackageMemberPricing, -} from "#shared/db/groups.ts"; + type ActivityToLog, + logActivities, + logActivity, +} from "#shared/db/activity-log.ts"; +import { getBuiltSiteByRenewalTokenIndex } from "#shared/db/built-sites.ts"; import { settings } from "#shared/db/settings.ts"; -import { type EmailEntry, sendRegistrationEmails } from "#shared/email.ts"; +import { + type EmailEntry, + registrationEmailDelivery, + sendRegistrationEmails, +} from "#shared/email.ts"; import { errorMessage } from "#shared/error-message.ts"; /* jscpd:ignore-start */ import { ErrorCode, logError } from "#shared/logger.ts"; @@ -25,6 +29,12 @@ import { nowIso } from "#shared/now.ts"; import { sendNtfyError } from "#shared/ntfy.ts"; import { addPendingWork } from "#shared/pending-work.ts"; /* jscpd:ignore-end */ +import { + loadRegistrationPackageFacts, + type RegistrationNotification, + type RegistrationPackageFacts, + type RegistrationPackagePricing, +} from "#shared/registration-package-facts.ts"; import { fetchTextFollowingSafeRedirects } from "#shared/safe-fetch.ts"; import { addMonthsToRenewalDeadline, @@ -114,28 +124,7 @@ export type RegistrationEntry = { * positive amount or an explicit free `0`; members with no override are absent) * and each customisable member's per-day overrides (day count → minor units) — * the loader's shape, minus the fields the payload never reads. */ -type PackageGroupPricing = Pick; - -/** Per-group package pricing, loaded once per payload for the order's package - * groups. */ -type PackageOverrides = ReadonlyMap; - -/** Load the package price overrides for every package the order books through, - * so a package member's full unit price can be reported from its configured - * override rather than the amount collected. Lines carry the group they were - * booked under, or 0 when they are not part of a package; every package is - * listed once and priced in one batch, so a long order costs the same reads as - * a short one. */ -const loadPackageOverrides = ( - entries: RegistrationEntry[], -): Promise => - loadPackageMemberPricingByGroupIds( - unique( - mapNotNullish((e: RegistrationEntry) => - e.attendee.package_group_id > 0 ? e.attendee.package_group_id : null, - )(entries), - ), - ); +type PackagePricingByGroup = ReadonlyMap; /** The full per-unit price for a booking line: the shared checkout evaluation * ({@link packageMemberPriceRule} + {@link effectivePrice}) over the span @@ -148,16 +137,16 @@ const loadPackageOverrides = ( * price for the booked span; everything else reports the listing's base. */ const ticketUnitPrice = ( entry: RegistrationEntry, - overrides: PackageOverrides, + pricingByGroup: PackagePricingByGroup, ): number => { const { listing, attendee } = entry; const groupPricing = attendee.package_group_id > 0 - ? overrides.get(attendee.package_group_id) + ? pricingByGroup.get(attendee.package_group_id) : undefined; const rule = packageMemberPriceRule( - groupPricing?.prices.get(listing.id), - groupPricing?.dayPrices.get(listing.id), + groupPricing?.priceMap.get(listing.id), + groupPricing?.dayPriceMap.get(listing.id), listing.customisable_days, ); return effectivePrice( @@ -174,7 +163,7 @@ const ticketUnitPrice = ( export const buildWebhookPayload = ( entries: RegistrationEntry[], currency: string, - overrides: PackageOverrides = new Map(), + pricingByGroup: PackagePricingByGroup = new Map(), ): WebhookPayload => { const first = entries[0]!; const totalPricePaid = sumOf((e: RegistrationEntry) => @@ -208,7 +197,7 @@ export const buildWebhookPayload = ( listing_slug: entry.listing.slug, quantity: entry.attendee.quantity, ticket_token: entry.attendee.ticket_token, - unit_price: ticketUnitPrice(entry, overrides), + unit_price: ticketUnitPrice(entry, pricingByGroup), })), timestamp: nowIso(), }; @@ -259,28 +248,27 @@ export const sendWebhook = async ( /** * Send consolidated webhook to all unique webhook URLs for the given entries */ -export const sendRegistrationWebhooks = async ( - entries: RegistrationEntry[], - currency: string, -): Promise => { - const webhookUrls = unique( - mapNotNullish((e: RegistrationEntry) => e.listing.webhook_url || null)( - entries, - ), - ); +export const sendRegistrationWebhooks: RegistrationNotification< + RegistrationEntry +> = async (entries, currency, suppliedFacts) => { + const webhookUrls = registrationWebhookUrls(entries); if (webhookUrls.length === 0) return; - const payload = buildWebhookPayload( - entries, - currency, - await loadPackageOverrides(entries), - ); + const facts = suppliedFacts ?? (await loadRegistrationPackageFacts(entries)); + const payload = buildWebhookPayload(entries, currency, facts.pricingByGroup); const firstListingId = entries[0]?.listing.id; await Promise.allSettled( webhookUrls.map((url) => sendWebhook(url, payload, firstListingId)), ); }; +const registrationWebhookUrls = (entries: RegistrationEntry[]): string[] => + unique( + mapNotNullish( + (entry: RegistrationEntry) => entry.listing.webhook_url || null, + )(entries), + ); + /** * Apply renewal deadline bumps for a completed payment. * If siteTokenIndex is present, look up the built site and bump its READ_ONLY_FROM. @@ -357,17 +345,26 @@ export const applyRenewalsForEntries = async ( export const logAndNotifyRegistration = async ( entries: EmailEntry[], siteTokenIndex?: string, + priorActivities: readonly ActivityToLog[] = [], + suppliedPackageFacts?: RegistrationPackageFacts, ): Promise => { - await logActivities( - entries.map(({ listing, attendee }) => ({ + await logActivities([ + ...priorActivities, + ...entries.map(({ listing, attendee }) => ({ attendeeId: attendee.id, listing, message: `Attendee registered for '${listing.name}'`, })), - ); + ]); const currency = settings.currency; - addPendingWork(sendRegistrationWebhooks(entries, currency)); - addPendingWork(sendRegistrationEmails(entries, currency)); + const needsPackageFacts = + registrationWebhookUrls(entries).length > 0 || + registrationEmailDelivery(entries) !== null; + const packageFacts = needsPackageFacts + ? (suppliedPackageFacts ?? (await loadRegistrationPackageFacts(entries))) + : suppliedPackageFacts; + addPendingWork(sendRegistrationWebhooks(entries, currency, packageFacts)); + addPendingWork(sendRegistrationEmails(entries, currency, packageFacts)); addPendingWork(assignAndNotifyBuiltSites(entries)); addPendingWork(applyRenewalsForEntries(entries, siteTokenIndex)); }; diff --git a/test/features/admin/questions/listing-questions.test.ts b/test/features/admin/questions/listing-questions.test.ts index 531a1222e6..fce72dcdcf 100644 --- a/test/features/admin/questions/listing-questions.test.ts +++ b/test/features/admin/questions/listing-questions.test.ts @@ -15,7 +15,7 @@ import { } from "#test-utils/assertions.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; -import { withPoisonedTransactionWrite } from "#test-utils/db-poison.ts"; +import { withPoisonedWrite } from "#test-utils/db-poison.ts"; import { mockFormRequest } from "#test-utils/mocks.ts"; import { adminFormPost, @@ -104,7 +104,7 @@ describeWithEnv("server (admin questions)", { db: true }, () => { test("rolls back assign-all when saving listing links fails", async () => { const listing = await createTestListing({ name: "Rollback listing" }); const qId = await createQuestion("Rollback assignment?"); - const failLinkInsert = withPoisonedTransactionWrite( + const failLinkInsert = withPoisonedWrite( (sql) => sql.includes("INSERT INTO listing_questions"), "link insert failed", ); diff --git a/test/features/api/payment-processing/completion.test.ts b/test/features/api/payment-processing/completion.test.ts index 74c5f81097..32d1159c8f 100644 --- a/test/features/api/payment-processing/completion.test.ts +++ b/test/features/api/payment-processing/completion.test.ts @@ -2,6 +2,7 @@ import { expect } from "@std/expect"; import { it as test } from "@std/testing/bdd"; import { completePaidBooking } from "#routes/api/payment-processing/completion.ts"; import type { CreatedEntry } from "#routes/api/payment-processing/create.ts"; +import type { PaidQuestionFacts } from "#routes/api/payment-processing/snapshot/types.ts"; import type { BookingIntent } from "#shared/booking-intent.ts"; import type { ModifierApplication } from "#shared/checkout-pricing.ts"; import { getDb } from "#shared/db/client.ts"; @@ -9,16 +10,31 @@ import { getListingWithCount } from "#shared/db/listings/records.ts"; import { listingQuestions } from "#shared/db/questions/queries.ts"; import { answersTable, questionsTable } from "#shared/db/questions/tables.ts"; import type { ModifierSpec } from "#shared/payments.ts"; +import { runWithPendingWork } from "#shared/pending-work.ts"; +import type { RegistrationPackageFacts } from "#shared/registration-package-facts.ts"; import { getAllActivityLog } from "#test-utils/activity-log.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { createTestAttendee } from "#test-utils/db-helpers/attendees.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { configureTestEmail } from "#test-utils/email.ts"; +import { stubFetchEachTest } from "#test-utils/fetch-stub.ts"; +import { countDatabaseCalls } from "#test-utils/subrequest-budget.ts"; import { bookingIntent } from "./index/helpers.ts"; /** What the checkout signed, with no answers and nothing added on top. */ const bareIntent = (): BookingIntent => bookingIntent([{ e: 1, p: 1000, q: 1 }]); +const noPackageFacts = (): RegistrationPackageFacts => ({ + displays: new Map(), + pricingByGroup: new Map(), +}); + +const noQuestionFacts = (): PaidQuestionFacts => ({ + questionIdByAnswerId: new Map(), + textQuestionIds: new Set(), +}); + /** One booked line, as the code that writes the booking hands it on. */ const bookedLine = async ( name: string, @@ -52,6 +68,8 @@ describeWithEnv( "finishing off a booking that has been paid", { db: true }, () => { + stubFetchEachTest(() => new Response()); + test("hands back the first line's booking, listing, and tickets", async () => { const { attendeeId, entry, listingId } = await bookedLine("First Line"); @@ -62,6 +80,8 @@ describeWithEnv( [], [], ["tok_a", "tok_b"], + noQuestionFacts(), + noPackageFacts(), ), ).toEqual({ attendee: { id: attendeeId }, @@ -82,6 +102,8 @@ describeWithEnv( [], [], [], + noQuestionFacts(), + noPackageFacts(), ); expect(result).toMatchObject({ @@ -114,6 +136,11 @@ describeWithEnv( [], [], [], + { + questionIdByAnswerId: new Map([[answer.id, question.id]]), + textQuestionIds: new Set(), + }, + noPackageFacts(), ); const saved = await getDb().execute({ @@ -150,23 +177,80 @@ describeWithEnv( }, ]; - await completePaidBooking( - [entry], - bareIntent(), - codeSpecs, - applications, - [], + const calls = await countDatabaseCalls(1, () => + completePaidBooking( + [entry], + bareIntent(), + codeSpecs, + applications, + [], + noQuestionFacts(), + noPackageFacts(), + ), ); + expect(calls).toBe(1); expect(await logMentions("Promo code 'Ten off' used")).toBe(true); }); test("writes down no code when the buyer used none", async () => { const { entry } = await bookedLine("Codeless Line"); - await completePaidBooking([entry], bareIntent(), [], [], []); + await completePaidBooking( + [entry], + bareIntent(), + [], + [], + [], + noQuestionFacts(), + noPackageFacts(), + ); expect(await logMentions("Promo code")).toBe(false); }); + + test("reuses paid package facts for notification rendering", async () => { + const { entry } = await bookedLine("Paid package member"); + await configureTestEmail(); + const packageGroupId = 91; + const packagedEntry: CreatedEntry = { + attendee: { ...entry.attendee, package_group_id: packageGroupId }, + listing: { + ...entry.listing, + webhook_url: "https://example.com/registration", + }, + }; + const facts: RegistrationPackageFacts = { + displays: new Map([ + [packageGroupId, { hideListings: true, name: "Paid package" }], + ]), + pricingByGroup: new Map([ + [ + packageGroupId, + { + dayPriceMap: new Map(), + memberIds: new Set([entry.listing.id]), + priceMap: new Map([[entry.listing.id, 1000]]), + quantityMap: new Map([[entry.listing.id, 1]]), + }, + ], + ]), + }; + + const calls = await countDatabaseCalls(1, () => + runWithPendingWork(() => + completePaidBooking( + [packagedEntry], + bareIntent(), + [], + [], + [], + noQuestionFacts(), + facts, + ), + ), + ); + expect(calls).toBe(1); + }); }, ); diff --git a/test/features/api/payment-processing/create.test.ts b/test/features/api/payment-processing/create.test.ts index aadb4cf38a..99f04566e1 100644 --- a/test/features/api/payment-processing/create.test.ts +++ b/test/features/api/payment-processing/create.test.ts @@ -5,14 +5,15 @@ import { alreadyProcessedResult, bookingSlot, createAttendeeForSession, - logPromoCodeModifiers, pairEntriesByListing, + promoCodeActivities, } from "#routes/api/payment-processing/create.ts"; import { specForFailure } from "#routes/api/payment-processing/store-refund.ts"; import type { BookingIntent } from "#shared/booking-intent.ts"; import type { PricedOrder } from "#shared/checkout-pricing.ts"; import { encrypt } from "#shared/crypto/encryption.ts"; import { decryptWithOwnerKey } from "#shared/crypto/keys.ts"; +import { logActivities } from "#shared/db/activity-log.ts"; import { attendeesApi } from "#shared/db/attendees/api.ts"; import { queryAll } from "#shared/db/client.ts"; import type { @@ -171,6 +172,8 @@ const preparationResult = (options: PreparationOptions) => { pricingIntent, pricedOrder, "stable-ticket-token", + 1, + new Map(), ); }; @@ -277,11 +280,13 @@ describeWithEnv("payment booking lines", { db: true }, () => { `${code.toLowerCase()}@example.com`, ); - await logPromoCodeModifiers( - [{ id: 1, name: code } as never], - [{ delta, modifierId: 1 } as never], - listing as never, - attendee.id, + await logActivities( + promoCodeActivities( + [{ id: 1, name: code } as never], + [{ delta, modifierId: 1 } as never], + listing as never, + attendee.id, + ), ); const [row] = await queryAll<{ message: string }>( diff --git a/test/features/api/payment-processing/create/answers.test.ts b/test/features/api/payment-processing/create/answers.test.ts new file mode 100644 index 0000000000..e707ea2790 --- /dev/null +++ b/test/features/api/payment-processing/create/answers.test.ts @@ -0,0 +1,90 @@ +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { + type CreatedEntry, + saveSessionAnswers, +} from "#routes/api/payment-processing/create.ts"; +import { getDb } from "#shared/db/client.ts"; +import { getListingWithCount } from "#shared/db/listings/records.ts"; +import { getOrCreateStringIds } from "#shared/db/questions/strings.ts"; +import { answersTable, questionsTable } from "#shared/db/questions/tables.ts"; +import { bookingIntent } from "#test/features/api/payment-processing/index/helpers.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; +import { createTestAttendee } from "#test-utils/db-helpers/attendees.ts"; +import { createTestListing } from "#test-utils/db-helpers/listings.ts"; + +const bookedEntry = async (): Promise => { + const listing = await createTestListing({ maxAttendees: 5 }); + const attendee = await createTestAttendee( + listing.id, + listing.slug, + "Answer buyer", + "answers@example.com", + ); + const loaded = await getListingWithCount(listing.id); + if (loaded === null) throw new Error(`Listing ${listing.id} was not created`); + return { attendee, listing: loaded } as CreatedEntry; +}; + +describeWithEnv("paid booking answer saves", { db: true }, () => { + test("saves choice answers when there are no text answers", async () => { + const entry = await bookedEntry(); + const question = await questionsTable.insert({ + displayType: "radio", + text: "Choose one", + }); + const answer = await answersTable.insert({ + questionId: question.id, + sortOrder: 0, + text: "Chosen", + }); + await saveSessionAnswers( + [entry], + bookingIntent([{ e: entry.listing.id, p: 0, q: 1 }], { + listingAnswerIds: { [entry.listing.id]: [answer.id] }, + }), + { + questionIdByAnswerId: new Map([[answer.id, question.id]]), + textQuestionIds: new Set(), + }, + ); + const saved = await getDb().execute({ + args: [entry.attendee.id], + sql: "SELECT question_id, answer_id, string_id FROM attendee_answers WHERE attendee_id = ?", + }); + expect(saved.rows).toEqual([ + { answer_id: answer.id, question_id: question.id, string_id: null }, + ]); + }); + + test("saves a valid text answer when there are no choice answers", async () => { + const entry = await bookedEntry(); + const question = await questionsTable.insert({ + displayType: "free_text", + text: "Add detail", + }); + const stringId = (await getOrCreateStringIds(["The saved detail"])).get( + "The saved detail", + ); + if (stringId === undefined) throw new Error("Text answer was not interned"); + await saveSessionAnswers( + [entry], + bookingIntent([{ e: entry.listing.id, p: 0, q: 1 }], { + listingTextAnswerIds: { + [entry.listing.id]: [{ q: question.id, s: stringId }], + }, + }), + { + questionIdByAnswerId: new Map(), + textQuestionIds: new Set([question.id]), + }, + ); + const saved = await getDb().execute({ + args: [entry.attendee.id], + sql: "SELECT question_id, answer_id, string_id FROM attendee_answers WHERE attendee_id = ?", + }); + expect(saved.rows).toEqual([ + { answer_id: null, question_id: question.id, string_id: stringId }, + ]); + }); +}); diff --git a/test/features/api/payment-processing/index/balance.test.ts b/test/features/api/payment-processing/index/balance.test.ts index c5931f10d7..dac8804fdd 100644 --- a/test/features/api/payment-processing/index/balance.test.ts +++ b/test/features/api/payment-processing/index/balance.test.ts @@ -3,9 +3,9 @@ import { it as test } from "@std/testing/bdd"; import { processPaymentSession } from "#routes/api/payment-processing/index.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 { createReservedAttendee } from "#test-utils/balance.ts"; import { describeWithEnv } from "#test-utils/db.ts"; +import { getProcessedPayment } from "#test-utils/processed-payments.ts"; import { setupStripe } from "#test-utils/settings.ts"; import { stubRefundPayment } from "#test-utils/webhooks.ts"; import { bookingIntent, trustedPayment } from "./helpers.ts"; @@ -43,7 +43,7 @@ describeWithEnv("payment processing balance outcomes", { db: true }, () => { expect((await getAttendeeBalanceState(attendeeId))?.remainingBalance).toBe( 0, ); - expect((await isSessionProcessed(id))?.attendee_id).toBe(attendeeId); + expect((await getProcessedPayment(id))?.attendee_id).toBe(attendeeId); }); test("replays a ledgered balance after its reservation row is lost", async () => { @@ -64,7 +64,7 @@ describeWithEnv("payment processing balance outcomes", { db: true }, () => { success: true, ticketTokens: [], }); - expect((await isSessionProcessed(id))?.attendee_id).toBe(attendeeId); + expect((await getProcessedPayment(id))?.attendee_id).toBe(attendeeId); expect((await getAttendeeBalanceState(attendeeId))?.remainingBalance).toBe( 0, ); @@ -90,6 +90,6 @@ describeWithEnv("payment processing balance outcomes", { db: true }, () => { 1000, ); expect(refund.calls[0]?.args).toEqual([`pi_${id}`]); - expect((await isSessionProcessed(id))?.failure_data).not.toBe(""); + expect((await getProcessedPayment(id))?.failure_data).not.toBe(""); }); }); diff --git a/test/features/api/payment-processing/index/booking.test.ts b/test/features/api/payment-processing/index/booking.test.ts index 408ae65129..1a11fe40c5 100644 --- a/test/features/api/payment-processing/index/booking.test.ts +++ b/test/features/api/payment-processing/index/booking.test.ts @@ -4,10 +4,13 @@ import { spy } from "@std/testing/mock"; import { processPaymentSession } from "#routes/api/payment-processing/index.ts"; import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; import { execute, queryOne } from "#shared/db/client.ts"; -import { isSessionProcessed } from "#shared/db/processed-payments.ts"; +import { listingQuestions } from "#shared/db/questions/queries.ts"; +import { answersTable, questionsTable } from "#shared/db/questions/tables.ts"; import { setSuppressDebugLogs } from "#shared/log-settings.ts"; import { describeWithEnv } from "#test-utils/db.ts"; +import { getProcessedPayment } from "#test-utils/processed-payments.ts"; import { setupStripe } from "#test-utils/settings.ts"; +import { countDatabaseCalls } from "#test-utils/subrequest-budget.ts"; import { stubRefundPayment } from "#test-utils/webhooks.ts"; import { expectStoredRefund, @@ -32,7 +35,38 @@ describeWithEnv("payment processing booking outcomes", { db: true }, () => { expect(attendees).toHaveLength(1); expect(attendees[0]?.quantity).toBe(1); expect(attendees[0]?.price_paid).toBe(1000); - expect((await isSessionProcessed(id))?.attendee_id).toBe(first.attendee.id); + expect((await getProcessedPayment(id))?.attendee_id).toBe( + first.attendee.id, + ); + }); + + test("creates a paid booking in four database calls", async () => { + const id = "cs_direct_booking_budget"; + const { data } = await singleListingPayment(id, 1000); + const calls = await countDatabaseCalls(4, async () => { + expect((await processPaymentSession(id, data)).success).toBe(true); + }); + expect(calls).toBe(4); + }); + + test("creates and answers a paid booking in five database calls", async () => { + const id = "cs_direct_answered_booking_budget"; + const { data, listing } = await singleListingPayment(id, 1000); + const question = await questionsTable.insert({ + displayType: "select", + text: "Meal?", + }); + const answer = await answersTable.insert({ + questionId: question.id, + sortOrder: 0, + text: "Soup", + }); + await listingQuestions.setIds(listing.id, [question.id]); + data.intent.listingAnswerIds = { [String(listing.id)]: [answer.id] }; + const calls = await countDatabaseCalls(5, async () => { + expect((await processPaymentSession(id, data)).success).toBe(true); + }); + expect(calls).toBe(5); }); test("heals a missing reservation from the durable booking ledger", async () => { @@ -52,7 +86,7 @@ describeWithEnv("payment processing booking outcomes", { db: true }, () => { setSuppressDebugLogs(null); } - expect((await isSessionProcessed(id))?.attendee_id).toBe(attendeeId); + expect((await getProcessedPayment(id))?.attendee_id).toBe(attendeeId); expect(await getAttendeesRaw(listing.id)).toHaveLength(1); expect( debug.calls.some((call) => @@ -104,7 +138,7 @@ describeWithEnv("payment processing booking outcomes", { db: true }, () => { [listing.id], ), ).toEqual({ listing_id: listing.id, quantity: 0 }); - expect((await isSessionProcessed(id))?.failure_data).not.toBe(""); + expect((await getProcessedPayment(id))?.failure_data).not.toBe(""); }); test("keeps a charge-mismatched booking and records a terminal refund", async () => { diff --git a/test/features/api/payment-processing/index/helpers.ts b/test/features/api/payment-processing/index/helpers.ts index 510bf6370c..9ebd7d2cbb 100644 --- a/test/features/api/payment-processing/index/helpers.ts +++ b/test/features/api/payment-processing/index/helpers.ts @@ -8,10 +8,10 @@ import type { import type { BookingIntent, BookingItem } from "#shared/booking-intent.ts"; import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; import { execute } from "#shared/db/client.ts"; -import { isSessionProcessed } from "#shared/db/processed-payments.ts"; import type { ValidatedPaymentSession } from "#shared/payments.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; import { webhookMeta } from "#test-utils/factories.ts"; +import { getProcessedPayment } from "#test-utils/processed-payments.ts"; export const bookingIntent = ( items: BookingItem[], @@ -103,7 +103,7 @@ export const expectStoredRefund = async ( expect(result.refunded).toBe(true); expect((await getAttendeesRaw(expected.listingId))[0]?.quantity).toBe(0); expect(refund.calls).toHaveLength(1); - expect((await isSessionProcessed(expected.sessionId))?.failure_data).not.toBe( - "", - ); + expect( + (await getProcessedPayment(expected.sessionId))?.failure_data, + ).not.toBe(""); }; diff --git a/test/features/api/payment-processing/index/refunds.test.ts b/test/features/api/payment-processing/index/refunds.test.ts index 84f01d388b..76b93450a8 100644 --- a/test/features/api/payment-processing/index/refunds.test.ts +++ b/test/features/api/payment-processing/index/refunds.test.ts @@ -4,7 +4,6 @@ import { stub } from "@std/testing/mock"; import { processPaymentSession } from "#routes/api/payment-processing/index.ts"; import { attendeesApi } from "#shared/db/attendees/api.ts"; import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; -import { isSessionProcessed } from "#shared/db/processed-payments.ts"; import { stripeApi } from "#shared/stripe.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { bookAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; @@ -12,6 +11,7 @@ import { createTestListing, deactivateTestListing, } from "#test-utils/db-helpers/listings.ts"; +import { getProcessedPayment } from "#test-utils/processed-payments.ts"; import { setupStripe } from "#test-utils/settings.ts"; import { stubRefundPayment } from "#test-utils/webhooks.ts"; import { @@ -53,7 +53,7 @@ describeWithEnv("payment processing refund outcomes", { db: true }, () => { status: 410, success: false, }); - expect(await isSessionProcessed(id)).toBeNull(); + expect(await getProcessedPayment(id)).toBeNull(); expect(await processPaymentSession(id, data)).toMatchObject({ refunded: false, success: false, diff --git a/test/features/api/payment-processing/items.test.ts b/test/features/api/payment-processing/items.test.ts index 7dd86255a6..74e4a0cc80 100644 --- a/test/features/api/payment-processing/items.test.ts +++ b/test/features/api/payment-processing/items.test.ts @@ -1,9 +1,9 @@ import { expect } from "@std/expect"; import { it as test } from "@std/testing/bdd"; -import { validateAllItems } from "#routes/api/payment-processing/items.ts"; import type { ValidatedItem } from "#routes/api/payment-processing/package-pricing.ts"; import type { PaymentResult } from "#routes/api/webhook-types.ts"; import { setGroupPackageMembers } from "#shared/db/groups.ts"; +import { validateAllItems } from "#test/features/api/payment-processing/items/helpers.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { createHiddenPackageGroup, diff --git a/test/features/api/payment-processing/items/boundaries.test.ts b/test/features/api/payment-processing/items/boundaries.test.ts index e0a0787a7e..75715a83ae 100644 --- a/test/features/api/payment-processing/items/boundaries.test.ts +++ b/test/features/api/payment-processing/items/boundaries.test.ts @@ -1,14 +1,19 @@ import { expect } from "@std/expect"; import { it as test } from "@std/testing/bdd"; -import { validateAllItems } from "#routes/api/payment-processing/items.ts"; +import { validateAllItems as validateSnapshotItems } from "#routes/api/payment-processing/items.ts"; +import { loadPaidOrderSnapshot } from "#routes/api/payment-processing/snapshot/io.ts"; import type { BookingIntent } from "#shared/booking-intent.ts"; import { setGroupPackageMembers } from "#shared/db/groups.ts"; import { bookingIntent, paymentSession, } from "#test/features/api/payment-processing/index/helpers.ts"; +import { validateAllItems } from "#test/features/api/payment-processing/items/helpers.ts"; import { describeWithEnv } from "#test-utils/db.ts"; -import { createHiddenPackageGroup } from "#test-utils/db-helpers/groups.ts"; +import { + createHiddenPackageGroup, + createTestGroup, +} from "#test-utils/db-helpers/groups.ts"; import { createTestListing, pastCloseTime, @@ -32,6 +37,34 @@ const pricesFor = async ( return result.items.map((item) => item.expectedPrice); }; +const closedPackage = async ( + name: string, + closedName: string, +): Promise<{ + groupId: number; + intent: BookingIntent; +}> => { + const group = await createTestGroup({ isPackage: true, name }); + const closed = await createTestListing({ + closesAt: pastCloseTime(), + groupId: group.id, + name: closedName, + unitPrice: 400, + }); + const open = await createTestListing({ groupId: group.id, unitPrice: 400 }); + await setGroupPackageMembers(group.id, [ + { listingId: closed.id, price: 400 }, + { listingId: open.id, price: 400 }, + ]); + return { + groupId: group.id, + intent: bookingIntent([ + { e: closed.id, k: "p", p: 400, q: 1, r: group.id }, + { e: open.id, k: "p", p: 400, q: 1, r: group.id }, + ]), + }; +}; + describeWithEnv("paid item validation boundaries", { db: true }, () => { test("returns the generic closed result for a single listing", async () => { await setupStripe(); @@ -58,6 +91,55 @@ describeWithEnv("paid item validation boundaries", { db: true }, () => { expect(refund.calls).toHaveLength(1); }); + test("names a closed member in a visible package", async () => { + await setupStripe(); + const { intent } = await closedPackage("Visible", "Closed member"); + using refund = stubRefundPayment("re_items_visible_closed"); + + expect( + await validateAllItems( + paymentSession("cs_items_visible_closed", 800, intent), + intent, + ), + ).toMatchObject({ + error: + "Sorry, registration for Closed member closed while you were completing payment.", + status: 410, + success: false, + }); + expect(refund.calls).toHaveLength(1); + }); + + test("keeps a member name private when its package facts are missing", async () => { + await setupStripe(); + const { groupId, intent } = await closedPackage( + "Removed", + "Private member", + ); + const session = paymentSession("cs_items_missing_package", 800, intent); + const loaded = await loadPaidOrderSnapshot(session.id, intent); + const snapshot = { + ...loaded, + notificationPackages: { + ...loaded.notificationPackages, + displays: new Map( + [...loaded.notificationPackages.displays].filter( + ([id]) => id !== groupId, + ), + ), + }, + }; + using refund = stubRefundPayment("re_items_missing_package"); + + expect( + await validateSnapshotItems(session, intent, snapshot), + ).toMatchObject({ + error: "Sorry, registration closed while you were completing payment.", + success: false, + }); + expect(refund.calls).toHaveLength(1); + }); + test("fails one standalone listing that joined a hidden package", async () => { const group = await createHiddenPackageGroup("Hidden after checkout"); const member = await createTestListing({ diff --git a/test/features/api/payment-processing/items/budget.test.ts b/test/features/api/payment-processing/items/budget.test.ts index f9034d2eb6..58da58f860 100644 --- a/test/features/api/payment-processing/items/budget.test.ts +++ b/test/features/api/payment-processing/items/budget.test.ts @@ -6,13 +6,13 @@ import { expect } from "@std/expect"; import { it as test } from "@std/testing/bdd"; -import { validateAllItems } from "#routes/api/payment-processing/items.ts"; import type { BookingIntent, BookingItem } from "#shared/booking-intent.ts"; import { invalidateListingsCache } from "#shared/db/listings/records.ts"; import { bookingIntent, paymentSession, } from "#test/features/api/payment-processing/index/helpers.ts"; +import { validateAllItems } from "#test/features/api/payment-processing/items/helpers.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; import { countDatabaseCalls } from "#test-utils/subrequest-budget.ts"; diff --git a/test/features/api/payment-processing/items/helpers.ts b/test/features/api/payment-processing/items/helpers.ts index ba2a6abf45..e6f2eb0acb 100644 --- a/test/features/api/payment-processing/items/helpers.ts +++ b/test/features/api/payment-processing/items/helpers.ts @@ -1,3 +1,5 @@ +import { validateAllItems as validateSnapshotItems } from "#routes/api/payment-processing/items.ts"; +import { loadPaidOrderSnapshot } from "#routes/api/payment-processing/snapshot/io.ts"; import type { BookingIntent } from "#shared/booking-intent.ts"; import { setGroupPackageMembers } from "#shared/db/groups.ts"; import { listingChildren } from "#shared/db/listing-parents.ts"; @@ -7,6 +9,16 @@ import { createTestGroup } from "#test-utils/db-helpers/groups.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; import type { TestListingOverrides } from "#test-utils/factories.ts"; +export const validateAllItems = async ( + session: Parameters[0], + intent: BookingIntent, +): ReturnType => + validateSnapshotItems( + session, + intent, + await loadPaidOrderSnapshot(session.id, intent), + ); + export const listingPair = async ( parentOverrides: TestListingOverrides = {}, childOverrides: TestListingOverrides = {}, diff --git a/test/features/api/payment-processing/package-pricing.test.ts b/test/features/api/payment-processing/package-pricing.test.ts index 649861f9d4..e4e5a65fe8 100644 --- a/test/features/api/payment-processing/package-pricing.test.ts +++ b/test/features/api/payment-processing/package-pricing.test.ts @@ -3,10 +3,10 @@ import { describe, it as test } from "@std/testing/bdd"; import { anyPackageBundleMismatch, expectedItemPrice, - type PackagePricing, packageBundleMismatch, } from "#routes/api/payment-processing/package-pricing.ts"; import type { PricedListing } from "#shared/booking/price-tree.ts"; +import type { RegistrationPackagePricing as PackagePricing } from "#shared/registration-package-facts.ts"; const pkg: PackagePricing = { dayPriceMap: new Map([[2, new Map([[2, 700]])]]), diff --git a/test/features/api/payment-processing/package-pricing/database.test.ts b/test/features/api/payment-processing/package-pricing/database.test.ts index bae499f2bf..924ff3f71a 100644 --- a/test/features/api/payment-processing/package-pricing/database.test.ts +++ b/test/features/api/payment-processing/package-pricing/database.test.ts @@ -1,15 +1,15 @@ import { expect } from "@std/expect"; import { it as test } from "@std/testing/bdd"; import { - hasStaleStandaloneChild, - loadPackagePricingByGroup, - orderEdgeDrifted, - type PackagePricing, + hasStaleStandaloneChildFromFacts, + orderEdgeDriftedFromFacts, type ValidatedItem, } from "#routes/api/payment-processing/package-pricing.ts"; +import { loadPaidOrderSnapshot } from "#routes/api/payment-processing/snapshot/io.ts"; import type { BookingIntent, BookingItem } from "#shared/booking-intent.ts"; import { setGroupPackageMembers } from "#shared/db/groups.ts"; import { getListingWithCount } from "#shared/db/listings/records.ts"; +import type { RegistrationPackagePricing as PackagePricing } from "#shared/registration-package-facts.ts"; import { bookingIntent } from "#test/features/api/payment-processing/index/helpers.ts"; import { listingPair } from "#test/features/api/payment-processing/items/helpers.ts"; import { describeWithEnv } from "#test-utils/db.ts"; @@ -37,12 +37,17 @@ const edgeDrifted = async ( items: BookingItem[], allocations: NonNullable = [], pricing: ReadonlyMap = new Map(), -): Promise => - orderEdgeDrifted( - bookingIntent(items, allocations.length > 0 ? { allocations } : {}), - await loadedItems(items), - pricing, +): Promise => { + const intent = bookingIntent( + items, + allocations.length > 0 ? { allocations } : {}, ); + const snapshot = await loadPaidOrderSnapshot("edge-drift", intent); + return orderEdgeDriftedFromFacts(intent, await loadedItems(items), pricing, { + childIdsByParent: snapshot.childrenByParentId, + listingsById: snapshot.listingsById, + }); +}; const parentChildItems = ( parentId: number, @@ -79,6 +84,26 @@ const packagePathDrifted = async (currentMember: boolean): Promise => { type ChildPath = "absent" | "adopted" | "allocated"; +const snapshotHasStaleChild = async ( + intent: BookingIntent, +): Promise => { + const snapshot = await loadPaidOrderSnapshot("stale-child", intent); + return hasStaleStandaloneChildFromFacts( + intent, + new Set( + intent.items.flatMap((item) => { + const listing = snapshot.listingsById.get(item.e); + return listing && + !listing.bookable_alone && + (snapshot.parentsByChildId.get(item.e)?.length ?? 0) > 0 + ? [item.e] + : []; + }), + ), + snapshot.parentsByChildId, + ); +}; + const staleChildFor = async ( path: ChildPath, childQuantity = 1, @@ -91,7 +116,7 @@ const staleChildFor = async ( path === "allocated" ? [{ childId: child.id, parentId: parent.id, qty: 1 }] : undefined; - return hasStaleStandaloneChild( + return snapshotHasStaleChild( bookingIntent(items, allocations ? { allocations } : {}), ); }; @@ -127,7 +152,9 @@ describeWithEnv("package pricing database revalidation", { db: true }, () => { // Three fixed reads: the package displays, the membership rows, the day // prices. One read per package instead would blow a real order's budget. const pricingCalls = (intent: BookingIntent): Promise => - countDatabaseCalls(3, () => loadPackagePricingByGroup(intent)); + countDatabaseCalls(1, () => + loadPaidOrderSnapshot("package-price", intent), + ); expect(await pricingCalls(six)).toBe(await pricingCalls(one)); }); @@ -166,7 +193,8 @@ describeWithEnv("package pricing database revalidation", { db: true }, () => { { e: regularMember.id, k: "p", p: 300, q: 1, r: regular.id }, ]); - const pricing = await loadPackagePricingByGroup(intent); + const pricing = (await loadPaidOrderSnapshot("package-values", intent)) + .notificationPackages.pricingByGroup; expect([...pricing.keys()]).toEqual([pkg.id]); expect(pricing.get(pkg.id)?.memberIds).toEqual(new Set([member.id])); expect(pricing.get(pkg.id)?.priceMap).toEqual(new Map([[member.id, 400]])); @@ -196,6 +224,24 @@ describeWithEnv("package pricing database revalidation", { db: true }, () => { expect(await edgeDrifted(items)).toBe(true); }); + test("fails loudly when linked listing facts are incomplete", async () => { + const parent = await createTestListing({ maxAttendees: 5, unitPrice: 500 }); + const item = { e: parent.id, p: 500, q: 1 }; + const validatedItem = await loadedItem(item); + + expect(() => + orderEdgeDriftedFromFacts( + bookingIntent([item]), + [validatedItem], + new Map(), + { + childIdsByParent: new Map([[parent.id, [999]]]), + listingsById: new Map(), + }, + ), + ).toThrow("Missing linked listing 999"); + }); + test("accepts a child allocation while its parent edge still exists", async () => { expect(await allocatedChildEdgeDrifted(1)).toBe(false); }); @@ -222,7 +268,7 @@ describeWithEnv("stale standalone child detection", { db: true }, () => { const listing = await createTestListing({ maxAttendees: 5 }); expect( - await hasStaleStandaloneChild( + await snapshotHasStaleChild( bookingIntent([{ e: listing.id, p: 0, q: 1 }]), ), ).toBe(false); diff --git a/test/features/api/payment-processing/recovery.test.ts b/test/features/api/payment-processing/recovery.test.ts index e6aba28219..671836422e 100644 --- a/test/features/api/payment-processing/recovery.test.ts +++ b/test/features/api/payment-processing/recovery.test.ts @@ -7,6 +7,7 @@ import { placeholderBookings } from "#routes/api/payment-processing/store-refund import type { PaymentResult } from "#routes/api/webhook-types.ts"; import type { BookingIntent } from "#shared/booking-intent.ts"; import { hmacHash } from "#shared/crypto/hashing.ts"; +import { requirePublicStatusId } from "#shared/db/attendee-statuses.ts"; import { getDb } from "#shared/db/client.ts"; import { getListingWithCount } from "#shared/db/listings/records.ts"; import { @@ -82,6 +83,7 @@ const runRecovery = async (opts: { error: opts.error, intent: intent(), placeholders: placeholderBookings(opts.validatedItems, intent()), + publicStatusId: await requirePublicStatusId(), session: session(opts.sessionId), ticketToken: opts.ticketToken, validatedItems: opts.validatedItems, diff --git a/test/features/api/payment-processing/snapshot/fold.test.ts b/test/features/api/payment-processing/snapshot/fold.test.ts new file mode 100644 index 0000000000..9954c9d112 --- /dev/null +++ b/test/features/api/payment-processing/snapshot/fold.test.ts @@ -0,0 +1,207 @@ +import { expect } from "@std/expect"; +import { describe, it as test } from "@std/testing/bdd"; +import { foldPaidOrderSnapshot } from "#routes/api/payment-processing/snapshot/fold.ts"; +import type { + SnapshotDayPriceRow, + SnapshotRows, +} from "#routes/api/payment-processing/snapshot/types.ts"; + +const rows = (overrides: Partial = {}): SnapshotRows => ({ + answerRows: [], + childEdges: [], + groups: [], + hiddenMemberIds: [], + ledger: { hasLegs: false, ownerAttendeeId: null }, + listings: [], + memberships: [], + modifierScopes: [], + modifiers: [], + publicStatusIds: [4], + textQuestionIds: [], + visitCounts: [], + ...overrides, +}); + +describe("paid order snapshot fold", () => { + test("folds package display, membership, flat price, and day prices", () => { + const dayPrices: SnapshotDayPriceRow[] = [ + { days: 2, groupId: 7, listingId: 11, unitPrice: 850 }, + ]; + const snapshot = foldPaidOrderSnapshot( + [], + rows({ + groups: [{ hideListings: true, id: 7, name: "Bundle" }], + memberships: [ + { group_id: 7, listing_id: 11, package_price: 400, quantity: 2 }, + { group_id: 7, listing_id: 12, package_price: null, quantity: 1 }, + ], + }), + dayPrices, + ); + + expect(snapshot.notificationPackages.displays.get(7)).toEqual({ + hideListings: true, + name: "Bundle", + }); + expect(snapshot.notificationPackages.pricingByGroup.get(7)).toEqual({ + dayPriceMap: new Map([[11, new Map([[2, 850]])]]), + memberIds: new Set([11, 12]), + priceMap: new Map([[11, 400]]), + quantityMap: new Map([ + [11, 2], + [12, 1], + ]), + }); + }); + + test("rebuilds referenced modifiers with visits and listing scopes", () => { + const snapshot = foldPaidOrderSnapshot( + [ + { i: 3, q: 2 }, + { i: 4, q: 1 }, + { i: 99, q: 1 }, + ], + rows({ + modifierScopes: [{ listingId: 8, modifierId: 3 }], + modifiers: [ + { + calcKind: "fixed", + calcValue: 5, + direction: "discount", + id: 3, + minVisits: 2, + name: "Returning buyer", + scope: "listings", + trigger: "automatic", + }, + { + calcKind: "percent", + calcValue: 10, + direction: "charge", + id: 4, + minVisits: 4, + name: "Too soon", + scope: "all", + trigger: "code", + }, + ], + visitCounts: [1, 3], + }), + [], + ); + + expect(snapshot.visits).toBe(3); + expect(snapshot.modifierSpecs).toEqual([ + { + id: 3, + kind: "fixed", + listingIds: [8], + name: "Returning buyer", + quantity: 2, + trigger: "automatic", + value: -500, + }, + ]); + }); + + test("folds ledger, relationships, hidden members, and question facts", () => { + const snapshot = foldPaidOrderSnapshot( + [], + rows({ + answerRows: [{ answerId: 21, questionId: 20 }], + childEdges: [{ childId: 12, parentId: 11 }], + hiddenMemberIds: [12], + ledger: { hasLegs: true, ownerAttendeeId: 5 }, + publicStatusIds: [9], + textQuestionIds: [22], + }), + [], + ); + + expect(snapshot.ledger).toEqual({ attendeeId: 5, status: "booked" }); + expect(snapshot.childrenByParentId).toEqual(new Map([[11, [12]]])); + expect(snapshot.parentsByChildId).toEqual(new Map([[12, [11]]])); + expect(snapshot.hiddenPackageMemberIds).toEqual(new Set([12])); + expect(snapshot.publicStatusId).toBe(9); + expect(snapshot.questions.questionIdByAnswerId).toEqual( + new Map([[21, 20]]), + ); + expect(snapshot.questions.textQuestionIds).toEqual(new Set([22])); + }); + + test("fails when the public status is missing", () => { + expect(() => + foldPaidOrderSnapshot([], rows({ publicStatusIds: [] }), []), + ).toThrow("No attendee status has the required is_public_default flag"); + }); + + test("uses zero visits when the buyer has no contact history", () => { + expect(foldPaidOrderSnapshot([], rows(), []).visits).toBe(0); + }); + + test("folds a whole-order modifier without listing scopes", () => { + const snapshot = foldPaidOrderSnapshot( + [{ i: 4, q: 1 }], + rows({ + modifiers: [ + { + calcKind: "fixed", + calcValue: 5, + direction: "charge", + id: 4, + minVisits: 0, + name: "Booking fee", + scope: "all", + trigger: "automatic", + }, + ], + }), + [], + ); + + expect(snapshot.modifierSpecs).toEqual([ + { + id: 4, + kind: "fixed", + listingIds: null, + name: "Booking fee", + quantity: 1, + trigger: "automatic", + value: 500, + }, + ]); + }); + + test("folds a listing modifier with no linked listings", () => { + const snapshot = foldPaidOrderSnapshot( + [{ i: 5, q: 1 }], + rows({ + modifiers: [ + { + calcKind: "fixed", + calcValue: 5, + direction: "charge", + id: 5, + minVisits: 0, + name: "Listing fee", + scope: "listings", + trigger: "automatic", + }, + ], + }), + [], + ); + + expect(snapshot.modifierSpecs).toEqual([ + { + id: 5, + kind: "fixed", + listingIds: [], + name: "Listing fee", + quantity: 1, + trigger: "automatic", + value: 500, + }, + ]); + }); +}); diff --git a/test/features/api/payment-processing/snapshot/io.test.ts b/test/features/api/payment-processing/snapshot/io.test.ts new file mode 100644 index 0000000000..c5089e0bfd --- /dev/null +++ b/test/features/api/payment-processing/snapshot/io.test.ts @@ -0,0 +1,239 @@ +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { loadPaidOrderSnapshot } from "#routes/api/payment-processing/snapshot/io.ts"; +import { + costAccount, + revenueAccount, + WORLD, +} from "#shared/accounting/accounts.ts"; +import { bookingEventGroup } from "#shared/accounting/mappers.ts"; +import { postTransfers } from "#shared/accounting/store.ts"; +import { execute } from "#shared/db/client.ts"; +import { hashEmail, hashPhone } from "#shared/db/contact-preferences.ts"; +import { setGroupPackageMembers } from "#shared/db/groups.ts"; +import { listingChildren } from "#shared/db/listing-parents.ts"; +import { + modifierGroups, + modifierListings, + modifiersTable, +} from "#shared/db/modifiers.ts"; +import { answersTable, questionsTable } from "#shared/db/questions/tables.ts"; +import { bookingIntent } from "#test/features/api/payment-processing/index/helpers.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; +import { createTestAttendeeDirect } from "#test-utils/db-helpers/attendees.ts"; +import { createHiddenPackageGroup } from "#test-utils/db-helpers/groups.ts"; +import { + createDailyTestListing, + createTestListing, +} from "#test-utils/db-helpers/listings.ts"; +import { countDatabaseCalls } from "#test-utils/subrequest-budget.ts"; + +describeWithEnv("paid order snapshot IO", { db: true }, () => { + test("loads one paid line in one database call", async () => { + const listing = await createTestListing({ unitPrice: 500 }); + const intent = bookingIntent([{ e: listing.id, p: 500, q: 1 }]); + + const calls = await countDatabaseCalls(10, () => + loadPaidOrderSnapshot("snapshot-one", intent), + ); + + expect(calls).toBe(1); + }); + + test("loads many paid lines in the same one database call", async () => { + const listings = [ + await createTestListing({ unitPrice: 500 }), + await createTestListing({ unitPrice: 700 }), + ]; + const intent = bookingIntent( + listings.map((listing) => ({ + e: listing.id, + p: listing.unit_price, + q: 1, + })), + ); + + const calls = await countDatabaseCalls(10, () => + loadPaidOrderSnapshot("snapshot-many", intent), + ); + + expect(calls).toBe(1); + }); + + test("loads a standalone listing when optional selections are empty", async () => { + const listing = await createTestListing({ name: "Standalone" }); + + const snapshot = await loadPaidOrderSnapshot( + "snapshot-empty-selections", + bookingIntent([{ e: listing.id, p: 0, q: 1 }]), + ); + + expect(snapshot.listingsById.get(listing.id)?.name).toBe("Standalone"); + expect(snapshot.notificationPackages.pricingByGroup).toEqual(new Map()); + expect(snapshot.modifierSpecs).toEqual([]); + }); + + test("loads every paid order fact from one consistent snapshot", async () => { + const pkg = await createHiddenPackageGroup("Snapshot package"); + const parent = await createDailyTestListing({ + customisableDays: true, + dayPrices: { 2: 900 }, + durationDays: 2, + groupId: pkg.id, + name: "Snapshot parent", + unitPrice: 500, + }); + const child = await createTestListing({ name: "Snapshot child" }); + await listingChildren.setIds(parent.id, [child.id]); + await setGroupPackageMembers(pkg.id, [ + { + dayPrices: { 2: 700 }, + listingId: parent.id, + price: 400, + quantity: 2, + }, + ]); + + const directModifier = await modifiersTable.insert({ + calcKind: "fixed", + calcValue: 5, + direction: "discount", + minVisits: 3, + name: "Direct discount", + scope: "listings", + }); + const groupModifier = await modifiersTable.insert({ + calcKind: "percent", + calcValue: 10, + direction: "charge", + name: "Package charge", + scope: "groups", + }); + await modifierListings.setIds(directModifier.id, [parent.id]); + await modifierGroups.setIds(groupModifier.id, [pkg.id]); + + const choiceQuestion = await questionsTable.insert({ + displayType: "radio", + text: "Choose one", + }); + const answer = await answersTable.insert({ + questionId: choiceQuestion.id, + sortOrder: 0, + text: "Chosen", + }); + const textQuestion = await questionsTable.insert({ + displayType: "free_text", + text: "Add detail", + }); + const email = "snapshot@example.com"; + const phone = "+447700900123"; + await execute( + "INSERT INTO contact_preferences (contact_hash, visits, stats_blob) VALUES (?, ?, ?), (?, ?, ?)", + [await hashEmail(email), 3, "{}", await hashPhone(phone), 7, "{}"], + ); + + const eventId = "snapshot-complete"; + const eventGroup = await bookingEventGroup(eventId); + const { attendee } = await createTestAttendeeDirect( + parent.id, + "Snapshot buyer", + email, + ); + await execute( + "UPDATE listing_attendees SET ledger_event_group = ? WHERE attendee_id = ? AND listing_id = ?", + [eventGroup, attendee.id, parent.id], + ); + await postTransfers([ + { + amount: 600, + destination: revenueAccount(parent.id), + eventGroup, + kind: "manual_income", + occurredAt: "2026-08-06T00:00:00.000Z", + reference: "snapshot-income", + source: WORLD, + }, + { + amount: 250, + destination: WORLD, + eventGroup, + kind: "manual_cost", + occurredAt: "2026-08-06T00:00:00.000Z", + reference: "snapshot-cost", + source: costAccount(parent.id), + }, + ]); + + const intent = bookingIntent( + [{ e: parent.id, k: "p", p: 800, q: 2, r: pkg.id }], + { + email, + listingAnswerIds: { [parent.id]: [answer.id] }, + listingTextAnswerIds: { + [parent.id]: [{ q: textQuestion.id, s: 1 }], + }, + modifiers: [ + { i: directModifier.id, q: 2 }, + { i: groupModifier.id, q: 1 }, + ], + phone, + }, + ); + const snapshot = await loadPaidOrderSnapshot(eventId, intent); + + expect(snapshot.ledger).toEqual({ + attendeeId: attendee.id, + status: "booked", + }); + expect(snapshot.listingsById.get(parent.id)).toMatchObject({ + cost: 250, + income: 600, + name: "Snapshot parent", + }); + expect(snapshot.listingsById.get(child.id)?.name).toBe("Snapshot child"); + expect(snapshot.childrenByParentId).toEqual( + new Map([[parent.id, [child.id]]]), + ); + expect(snapshot.parentsByChildId).toEqual( + new Map([[child.id, [parent.id]]]), + ); + expect(snapshot.hiddenPackageMemberIds).toEqual(new Set([parent.id])); + expect(snapshot.notificationPackages.displays.get(pkg.id)).toEqual({ + hideListings: true, + name: "Snapshot package", + }); + expect(snapshot.notificationPackages.pricingByGroup.get(pkg.id)).toEqual({ + dayPriceMap: new Map([[parent.id, new Map([[2, 700]])]]), + memberIds: new Set([parent.id]), + priceMap: new Map([[parent.id, 400]]), + quantityMap: new Map([[parent.id, 2]]), + }); + expect(snapshot.modifierSpecs).toEqual([ + { + id: directModifier.id, + kind: "fixed", + listingIds: [parent.id], + name: "Direct discount", + quantity: 2, + trigger: "automatic", + value: -500, + }, + { + id: groupModifier.id, + kind: "percent", + listingIds: [parent.id], + name: "Package charge", + quantity: 1, + trigger: "automatic", + value: 10, + }, + ]); + expect(snapshot.questions.questionIdByAnswerId).toEqual( + new Map([[answer.id, choiceQuestion.id]]), + ); + expect(snapshot.questions.textQuestionIds).toEqual( + new Set([textQuestion.id]), + ); + expect(snapshot.visits).toBe(7); + }); +}); diff --git a/test/features/api/payment-processing/store-refund.test.ts b/test/features/api/payment-processing/store-refund.test.ts index 5230ea766a..c322cbf4f5 100644 --- a/test/features/api/payment-processing/store-refund.test.ts +++ b/test/features/api/payment-processing/store-refund.test.ts @@ -16,6 +16,7 @@ import { } from "#routes/api/payment-processing/store-refund.ts"; import { processBooking } from "#shared/booking.ts"; import type { BookingIntent, BookingItem } from "#shared/booking-intent.ts"; +import { requirePublicStatusId } from "#shared/db/attendee-statuses.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { createTestAttendee } from "#test-utils/db-helpers/attendees.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; @@ -168,6 +169,7 @@ describeWithEnv("keeping a booking we could not honour", { db: true }, () => { intent, bookings, specFor("listing full"), + await requirePublicStatusId(), ); return { listing, result }; }; @@ -361,6 +363,7 @@ describeWithEnv( ok: false, reason: "capacity_exceeded", }), + await requirePublicStatusId(), ); expect(result.status).toBe(200); diff --git a/test/integration/email/config.test.ts b/test/integration/email/config.test.ts index 8c32331978..e33e900e93 100644 --- a/test/integration/email/config.test.ts +++ b/test/integration/email/config.test.ts @@ -5,6 +5,7 @@ import { ALL_SETTINGS_KEYS, settings } from "#shared/db/settings.ts"; import { getEmailConfig, getHostEmailConfig } from "#shared/email.ts"; import { updateBusinessEmail } from "#shared/validation/email.ts"; import { describeWithEnv } from "#test-utils/db.ts"; +import { configureTestEmail } from "#test-utils/email.ts"; describeWithEnv("getEmailConfig", { db: true }, () => { test("returns null when no provider configured", async () => { @@ -15,11 +16,7 @@ describeWithEnv("getEmailConfig", { db: true }, () => { }); test("returns config when all settings present", async () => { - await settings.update.email.provider("resend"); - await settings.update.email.apiKey("test-key"); - await settings.update.email.fromAddress("from@test.com"); - settings.invalidateCache(); - await settings.loadKeys(ALL_SETTINGS_KEYS); + await configureTestEmail(); const config = await getEmailConfig(); expect(config).toEqual({ diff --git a/test/integration/email/registration.test.ts b/test/integration/email/registration.test.ts index e16c14d079..4638b45e81 100644 --- a/test/integration/email/registration.test.ts +++ b/test/integration/email/registration.test.ts @@ -3,12 +3,13 @@ import { describe, it as test } from "@std/testing/bdd"; import { ALL_SETTINGS_KEYS, settings } from "#shared/db/settings.ts"; import type { EmailConfig } from "#shared/email.ts"; import { sendRegistrationEmails, sendTestEmail } from "#shared/email.ts"; -import { updateBusinessEmail } from "#shared/validation/email.ts"; +import type { RegistrationPackageFacts } from "#shared/registration-package-facts.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { createTestGroup } from "#test-utils/db-helpers/groups.ts"; -import { validEmail } from "#test-utils/email.ts"; +import { configureTestEmail, validEmail } from "#test-utils/email.ts"; import { makeTestEntry as makeEntry } from "#test-utils/factories.ts"; import { useFetchStub } from "#test-utils/mocks.ts"; +import { countDatabaseCalls } from "#test-utils/subrequest-budget.ts"; const testConfig: EmailConfig = { apiKey: "re_test_key", @@ -16,24 +17,11 @@ const testConfig: EmailConfig = { provider: "resend", }; -const setupDbEmailConfig = async ( - opts: { businessEmail?: string } = {}, -): Promise => { - await settings.update.email.provider("resend"); - await settings.update.email.apiKey("test-key"); - await settings.update.email.fromAddress("from@test.com"); - if (opts.businessEmail) { - await updateBusinessEmail(opts.businessEmail); - } - settings.invalidateCache(); - await settings.loadKeys(ALL_SETTINGS_KEYS); -}; - const setupAndSendRegistration = async ( opts: { businessEmail?: string } = {}, entries?: ReturnType[], ) => { - await setupDbEmailConfig(opts); + await configureTestEmail(opts); await sendRegistrationEmails(entries ?? [makeEntry()], "GBP"); }; @@ -186,6 +174,31 @@ describeWithEnv( expect(decoded).not.toContain("Secret Meal"); }); + test("uses supplied package displays without reading the database", async () => { + await configureTestEmail(); + const groupId = 71; + const entry = makeEntry( + { id: 72 }, + { package_group_id: groupId, ticket_token: "supplied-package" }, + ); + const facts: RegistrationPackageFacts = { + displays: new Map([ + [groupId, { hideListings: true, name: "Supplied package" }], + ]), + pricingByGroup: new Map(), + }; + + expect( + await countDatabaseCalls(0, () => + sendRegistrationEmails([entry], "GBP", facts), + ), + ).toBe(0); + const body = fetch.getFetchJsonBody(); + expect(body.subject).toContain("Supplied package"); + expect(body.html).not.toContain("Test Listing"); + expect(body.text).not.toContain("Test Listing"); + }); + test("attaches numbered tickets for multi-listing registration", async () => { const entries = [ makeEntry({ name: "Listing A" }, { ticket_token: "tok1" }), diff --git a/test/integration/processed-payments/locking.test.ts b/test/integration/processed-payments/locking.test.ts index d4ac2182e8..a3625da587 100644 --- a/test/integration/processed-payments/locking.test.ts +++ b/test/integration/processed-payments/locking.test.ts @@ -1,10 +1,10 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; +import { stub } from "@std/testing/mock"; import { getDb, insert } from "#shared/db/client.ts"; import { clearSessionTokens, decryptSessionTokens, - isSessionProcessed, reserveSession, STALE_RESERVATION_MS, } from "#shared/db/processed-payments.ts"; @@ -12,7 +12,10 @@ 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 { finalizeReservedPayment } from "#test-utils/processed-payments.ts"; +import { + finalizeReservedPayment, + getProcessedPayment, +} from "#test-utils/processed-payments.ts"; /** Perform the full two-phase reserve+finalize as production code does */ const processSession = async ( @@ -28,9 +31,9 @@ const processSession = async ( describeWithEnv("processed-payments / locking", { db: true }, () => { const ctx = useProcessedPaymentsAttendee(); - describe("isSessionProcessed", () => { + describe("getProcessedPayment", () => { test("returns null for unprocessed session", async () => { - expect(await isSessionProcessed("cs_unprocessed_123")).toBeNull(); + expect(await getProcessedPayment("cs_unprocessed_123")).toBeNull(); }); test("returns record for finalized session", async () => { @@ -42,7 +45,7 @@ describeWithEnv("processed-payments / locking", { db: true }, () => { "pi_cs_processed_123", ); - const result = await isSessionProcessed("cs_processed_123"); + const result = await getProcessedPayment("cs_processed_123"); expect(result?.payment_session_id).toBe("cs_processed_123"); expect(result?.attendee_id).toBe(ctx.attendeeId); expect(result?.payment_reference).not.toContain("pi_cs_processed_123"); @@ -52,7 +55,7 @@ describeWithEnv("processed-payments / locking", { db: true }, () => { test("returns record with null attendee_id for reserved-but-not-finalized session", async () => { await reserveSession("cs_reserved_123"); - const result = await isSessionProcessed("cs_reserved_123"); + const result = await getProcessedPayment("cs_reserved_123"); expect(result?.payment_session_id).toBe("cs_reserved_123"); expect(result?.attendee_id).toBeNull(); }); @@ -107,7 +110,7 @@ describeWithEnv("processed-payments / locking", { db: true }, () => { expect(result.reserved).toBe(true); // Old stale record is gone, new one exists - const record = await isSessionProcessed("cs_stale_recovery"); + const record = await getProcessedPayment("cs_stale_recovery"); expect(record?.attendee_id).toBeNull(); expect(new Date(record!.processed_at).getTime()).toBeGreaterThan( Date.now() - 5000, @@ -123,6 +126,63 @@ describeWithEnv("processed-payments / locking", { db: true }, () => { expect(results.filter((r) => r.reserved).length).toBe(1); expect(results.filter((r) => !r.reserved).length).toBe(2); }); + + test("only one concurrent caller reclaims a stale reservation", async () => { + const sessionId = "cs_concurrent_stale_reserve"; + const staleTime = new Date( + Date.now() - STALE_RESERVATION_MS - 1000, + ).toISOString(); + await getDb().execute( + insert("processed_payments", { + attendee_id: null, + payment_session_id: sessionId, + processed_at: staleTime, + }), + ); + const callerCount = 4; + const gates = Array.from({ length: callerCount }, () => + Promise.withResolvers(), + ); + const allWaiting = Promise.withResolvers(); + const db = getDb(); + const realBatch = db.batch.bind(db); + let waiting = 0; + using batch = stub(db, "batch", async (statements, mode) => { + const first = statements[0]; + if ( + typeof first === "object" && + !Array.isArray(first) && + first !== null && + "sql" in first && + first.sql.includes("INSERT INTO processed_payments") + ) { + const gate = gates[waiting]!; + waiting++; + if (waiting === callerCount) allWaiting.resolve(); + await gate.promise; + } + return realBatch(statements, mode); + }); + const attempts = Array.from({ length: callerCount }, () => + reserveSession(sessionId), + ); + await allWaiting.promise; + gates[0]!.resolve(); + const winner = await attempts[0]!; + expect(winner.reserved).toBe(true); + for (const gate of gates.slice(1)) gate.resolve(); + + const results = await Promise.all(attempts); + const stored = await getProcessedPayment(sessionId); + expect(results.filter((result) => result.reserved)).toHaveLength(1); + const losers = results.filter((result) => !result.reserved); + expect(losers).toHaveLength(callerCount - 1); + expect(losers.map((result) => result.existing.processed_at)).toEqual( + Array(callerCount - 1).fill(stored!.processed_at), + ); + expect(stored!.processed_at).not.toBe(staleTime); + expect(batch.calls).toHaveLength(callerCount); + }); }); describe("payment finalize batch", () => { @@ -135,7 +195,7 @@ describeWithEnv("processed-payments / locking", { db: true }, () => { "pi_cs_to_finalize", ); - const record = await isSessionProcessed("cs_to_finalize"); + const record = await getProcessedPayment("cs_to_finalize"); expect(record?.attendee_id).toBe(ctx.attendeeId); }); @@ -148,7 +208,7 @@ describeWithEnv("processed-payments / locking", { db: true }, () => { "pi_cs_with_tokens", ); - const record = await isSessionProcessed("cs_with_tokens"); + const record = await getProcessedPayment("cs_with_tokens"); expect(record?.ticket_tokens).toMatch(/^enc:1:/); expect(await decryptSessionTokens(record!.ticket_tokens)).toBe("tok_abc"); }); @@ -165,7 +225,7 @@ describeWithEnv("processed-payments / locking", { db: true }, () => { ); await clearSessionTokens("cs_clear_test"); - const record = await isSessionProcessed("cs_clear_test"); + const record = await getProcessedPayment("cs_clear_test"); expect(record?.ticket_tokens).toBe(""); expect(record?.attendee_id).toBe(ctx.attendeeId); }); @@ -180,7 +240,7 @@ describeWithEnv("processed-payments / locking", { db: true }, () => { ); await clearSessionTokens("cs_clear_noop"); - const record = await isSessionProcessed("cs_clear_noop"); + const record = await getProcessedPayment("cs_clear_noop"); expect(record?.ticket_tokens).toBe(""); expect(record?.attendee_id).toBe(ctx.attendeeId); }); @@ -211,7 +271,7 @@ describeWithEnv("processed-payments / locking", { db: true }, () => { ]); expect(results.filter(Boolean).length).toBe(1); - expect(await isSessionProcessed("cs_concurrent")).not.toBeNull(); + expect(await getProcessedPayment("cs_concurrent")).not.toBeNull(); }); }); }); diff --git a/test/integration/questions-attendee-answers.test.ts b/test/integration/questions-attendee-answers.test.ts index 0f7b2c4874..9512bedb69 100644 --- a/test/integration/questions-attendee-answers.test.ts +++ b/test/integration/questions-attendee-answers.test.ts @@ -19,7 +19,7 @@ import { import { getTestPrivateKey } from "#test-utils/crypto.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; -import { withPoisonedTransactionWrite } from "#test-utils/db-poison.ts"; +import { withPoisonedWrite } from "#test-utils/db-poison.ts"; import { expectRejects } from "#test-utils/servicing.ts"; /** The choice answer ids one attendee has saved (undefined when none). Shared by @@ -169,7 +169,7 @@ describeWithEnv("custom questions", { db: true }, () => { const { a1, a2, att } = await seedColourAttendeeWithRed(); expect(await choiceAnswersFor(att)).toEqual([a1.id]); - await withPoisonedTransactionWrite( + await withPoisonedWrite( (sql) => sql.includes("INSERT INTO attendee_answers"), "insert boom", )(async () => { @@ -198,7 +198,7 @@ describeWithEnv("custom questions", { db: true }, () => { "Keep me", ); - await withPoisonedTransactionWrite( + await withPoisonedWrite( (sql) => sql.includes("INSERT INTO attendee_answers"), "insert boom", )(async () => { diff --git a/test/integration/server/balance-payment-replay.test.ts b/test/integration/server/balance-payment-replay.test.ts index e6314e703b..1913053800 100644 --- a/test/integration/server/balance-payment-replay.test.ts +++ b/test/integration/server/balance-payment-replay.test.ts @@ -4,13 +4,13 @@ import { stub } from "@std/testing/mock"; import { handleRequest } from "#routes"; import { getAttendeeBalanceState } from "#shared/db/attendees/balance.ts"; import { execute } from "#shared/db/client.ts"; -import { isSessionProcessed } from "#shared/db/processed-payments.ts"; import { stripeApi } from "#shared/stripe.ts"; import { expectHtmlResponse } from "#test-utils/assertions.ts"; import { createReservedAttendee } from "#test-utils/balance.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { signedMeta, singleItem } from "#test-utils/factories.ts"; import { mockRequest } from "#test-utils/mocks.ts"; +import { getProcessedPayment } from "#test-utils/processed-payments.ts"; import { setupStripe } from "#test-utils/settings.ts"; const balanceSession = ( @@ -67,13 +67,15 @@ describeWithEnv("server (balance payment replay)", { db: true }, () => { "DELETE FROM processed_payments WHERE payment_session_id = ?", [sessionId], ); - expect(await isSessionProcessed(sessionId)).toBe(null); + expect(await getProcessedPayment(sessionId)).toBe(null); const replay = await handleRequest( mockRequest(`/payment/success?session_id=${sessionId}`), ); await expectHtmlResponse(replay, 200, 'data-payment-result="success"'); - expect((await isSessionProcessed(sessionId))?.attendee_id).toBe(attendeeId); + expect((await getProcessedPayment(sessionId))?.attendee_id).toBe( + attendeeId, + ); expect(mockRefund.calls.length).toBe(0); // The replay recreated the pruned idempotency row, but it must restore the diff --git a/test/integration/server/payments/confirm.test.ts b/test/integration/server/payments/confirm.test.ts index 4069b59da3..cb19d55517 100644 --- a/test/integration/server/payments/confirm.test.ts +++ b/test/integration/server/payments/confirm.test.ts @@ -5,7 +5,6 @@ import { stub } from "@std/testing/mock"; import { handleRequest } from "#routes"; import { attendeesApi } from "#shared/db/attendees/api.ts"; import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; -import { isSessionProcessed } from "#shared/db/processed-payments.ts"; import { expectHtmlResponse, expectRedirect, @@ -17,6 +16,7 @@ import { createTestListing } from "#test-utils/db-helpers/listings.ts"; import { signMeta, singleItem } from "#test-utils/factories.ts"; import { mockRequest, withMocks } from "#test-utils/mocks.ts"; import { makeParent } from "#test-utils/parents.ts"; +import { getProcessedPayment } from "#test-utils/processed-payments.ts"; import { setupStripe } from "#test-utils/settings.ts"; import { stubRetrieveCheckoutSession } from "#test-utils/webhooks.ts"; @@ -106,10 +106,7 @@ describeWithEnv("server (payment flow)", { db: true, triggers: true }, () => { expect(attendees[0]?.pii_blob).not.toBe(""); // Verify tokens are NOT persisted in DB (redirect has them in URL, no need to store) - const { isSessionProcessed } = await import( - "#shared/db/processed-payments.ts" - ); - const record = await isSessionProcessed("cs_test_paid"); + const record = await getProcessedPayment("cs_test_paid"); expect(record?.ticket_tokens).toBe(""); }, ); @@ -274,7 +271,9 @@ describeWithEnv("server (payment flow)", { db: true, triggers: true }, () => { expect(attendees).toHaveLength(1); expect(attendees[0]!.quantity).toBe(1); expect(attendees[0]!.price_paid).toBe(1000); - const processed = await isSessionProcessed("cs_concurrent_confirm"); + const processed = await getProcessedPayment( + "cs_concurrent_confirm", + ); expect(processed?.attendee_id).toBe(attendees[0]!.id); expect(processed?.failure_data).toBe(""); expect(processed?.ticket_tokens).toBe(""); diff --git a/test/integration/server/payments/replay.test.ts b/test/integration/server/payments/replay.test.ts index 1eea4bd796..dd82a1d721 100644 --- a/test/integration/server/payments/replay.test.ts +++ b/test/integration/server/payments/replay.test.ts @@ -4,7 +4,6 @@ import { describe, it as test } from "@std/testing/bdd"; import { handleRequest } from "#routes"; import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; import { getNoteRows } from "#shared/db/notes/queries.ts"; -import { isSessionProcessed } from "#shared/db/processed-payments.ts"; import { expectHtmlResponse } from "#test-utils/assertions.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { @@ -13,6 +12,7 @@ import { } from "#test-utils/db-helpers/listings.ts"; import { singleItem } from "#test-utils/factories.ts"; import { mockRequest, withMocks } from "#test-utils/mocks.ts"; +import { getProcessedPayment } from "#test-utils/processed-payments.ts"; import { setupStripe } from "#test-utils/settings.ts"; import { stubRefundPayment, @@ -152,7 +152,7 @@ describeWithEnv("server (payment flow)", { db: true, triggers: true }, () => { expect(mockRefund.calls.length).toBe(1); // The session is recorded as a terminal failure. - const record = await isSessionProcessed("cs_replay_price"); + const record = await getProcessedPayment("cs_replay_price"); expect(record?.attendee_id).toBeNull(); expect(record?.failure_data).not.toBe(""); @@ -209,7 +209,7 @@ describeWithEnv("server (payment flow)", { db: true, triggers: true }, () => { // left held: the row is released (deleted) so the next delivery // re-claims and re-attempts the refund immediately, rather than // colliding with the lock until the row goes stale. - expect(await isSessionProcessed("cs_refund_failed")).toBeNull(); + expect(await getProcessedPayment("cs_refund_failed")).toBeNull(); // The next retry re-attempts the refund (proof the lock was released). await handleRequest( diff --git a/test/integration/server/payments/success.test.ts b/test/integration/server/payments/success.test.ts index a542724b9e..119bb90403 100644 --- a/test/integration/server/payments/success.test.ts +++ b/test/integration/server/payments/success.test.ts @@ -11,10 +11,10 @@ import { } from "#test-utils/db-helpers/listings.ts"; import { singleItem } from "#test-utils/factories.ts"; import { mockRequest, withMocks } from "#test-utils/mocks.ts"; +import { expectSessionFailed } from "#test-utils/processed-payments.ts"; import { setupStripe } from "#test-utils/settings.ts"; import { expectRefundedWithNote, - expectSessionFailed, findKeptPlaceholder, stubRefundPayment, stubRetrieveCheckoutSession, diff --git a/test/integration/server/reservation-edge-cases.test.ts b/test/integration/server/reservation-edge-cases.test.ts index 63111c8c9e..c650dd98f7 100644 --- a/test/integration/server/reservation-edge-cases.test.ts +++ b/test/integration/server/reservation-edge-cases.test.ts @@ -121,10 +121,10 @@ describeWithEnv( ); // The session is recorded as a terminal failure (placeholder kept, no // ticket attendee): attendee_id stays null and failure_data is set. - const { isSessionProcessed } = await import( - "#shared/db/processed-payments.ts" + const { getProcessedPayment } = await import( + "#test-utils/processed-payments.ts" ); - const record = await isSessionProcessed("cs_addon_sold"); + const record = await getProcessedPayment("cs_addon_sold"); expect(record?.attendee_id).toBeNull(); expect(record?.failure_data).not.toBe(""); } finally { diff --git a/test/integration/server/webhooks/can-pay-more-multi-ticket.test.ts b/test/integration/server/webhooks/can-pay-more-multi-ticket.test.ts index ac1ea6f859..656fdf02fa 100644 --- a/test/integration/server/webhooks/can-pay-more-multi-ticket.test.ts +++ b/test/integration/server/webhooks/can-pay-more-multi-ticket.test.ts @@ -5,13 +5,13 @@ import { twoListingsAttendees } from "#test/integration/server/attendee-read-hel import { describeWithEnv } from "#test-utils/db.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; import { signedMeta, singleItem } from "#test-utils/factories.ts"; +import { expectSessionFailed } from "#test-utils/processed-payments.ts"; import { setupStripe } from "#test-utils/settings.ts"; import { checkoutSessionEvent, expectKeptAsQuantityZeroAndRefunded, expectMergedMultiListingAttendee, expectRefundedWithNote, - expectSessionFailed, expectWebhookKeptAndRefunded, expectWebhookProcessed, } from "#test-utils/webhooks.ts"; diff --git a/test/integration/server/webhooks/concurrent-processing.test.ts b/test/integration/server/webhooks/concurrent-processing.test.ts index faf6ab87b8..018c8a75f7 100644 --- a/test/integration/server/webhooks/concurrent-processing.test.ts +++ b/test/integration/server/webhooks/concurrent-processing.test.ts @@ -5,7 +5,6 @@ import { stub } from "@std/testing/mock"; import { handleRequest } from "#routes"; import { attendeesApi } from "#shared/db/attendees/api.ts"; import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; -import { isSessionProcessed } from "#shared/db/processed-payments.ts"; import { stripeApi } from "#shared/stripe.ts"; import { expectHtmlResponse } from "#test-utils/assertions.ts"; import { describeWithEnv } from "#test-utils/db.ts"; @@ -18,6 +17,7 @@ import { webhookMeta, } from "#test-utils/factories.ts"; import { mockRequest, mockWebhookRequest } from "#test-utils/mocks.ts"; +import { getProcessedPayment } from "#test-utils/processed-payments.ts"; import { setupStripe, stubWebhookVerify } from "#test-utils/settings.ts"; import { checkoutSessionEvent, @@ -102,7 +102,7 @@ describeWithEnv("server webhooks > concurrent processing", { db: true }, () => { const attendees = await getAttendeesRaw(listing.id); expect(attendees).toHaveLength(1); - const processed = await isSessionProcessed("cs_webhook_concurrent"); + const processed = await getProcessedPayment("cs_webhook_concurrent"); expect(processed?.attendee_id).toBe(attendees[0]!.id); expect(processed?.failure_data).toBe(""); } finally { diff --git a/test/integration/server/webhooks/multi-ticket-refunds.test.ts b/test/integration/server/webhooks/multi-ticket-refunds.test.ts index 3ea3e7c3e0..d11d682d9f 100644 --- a/test/integration/server/webhooks/multi-ticket-refunds.test.ts +++ b/test/integration/server/webhooks/multi-ticket-refunds.test.ts @@ -5,12 +5,12 @@ 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 { signedMeta } from "#test-utils/factories.ts"; +import { expectSessionFailed } from "#test-utils/processed-payments.ts"; import { setupStripe, stubWebhookVerify } from "#test-utils/settings.ts"; import { checkoutSessionEvent, expectKeptAsQuantityZeroAndRefunded, expectMergedMultiListingAttendee, - expectSessionFailed, expectWebhookKeptAndRefunded, postWebhookAndAssert, stubRefundPayment, diff --git a/test/integration/server/webhooks/price-signature-package-overrides.test.ts b/test/integration/server/webhooks/price-signature-package-overrides.test.ts index 3db27b49fa..1c8fed397f 100644 --- a/test/integration/server/webhooks/price-signature-package-overrides.test.ts +++ b/test/integration/server/webhooks/price-signature-package-overrides.test.ts @@ -3,7 +3,6 @@ import { it as test } from "@std/testing/bdd"; import { execute } from "#shared/db/client.ts"; import { groups, setGroupPackageMembers } from "#shared/db/groups.ts"; import { modifiersTable } from "#shared/db/modifiers.ts"; -import { isSessionProcessed } from "#shared/db/processed-payments.ts"; import { expectPackageRefund, expectProcessed, @@ -20,6 +19,7 @@ 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 { signMeta, webhookMeta } from "#test-utils/factories.ts"; +import { getProcessedPayment } from "#test-utils/processed-payments.ts"; import { setupStripe } from "#test-utils/settings.ts"; describeWithEnv( @@ -42,7 +42,7 @@ describeWithEnv( expect(refund.calls.length).toBe(1); // Recorded as a terminal failure (refund settled), so a later delivery // replays it instead of retrying. - const record = await isSessionProcessed("cs_already_refunded"); + const record = await getProcessedPayment("cs_already_refunded"); expect(record?.failure_data).not.toBe(""); }, ); diff --git a/test/integration/server/webhooks/price-signature-post-commit-recovery.test.ts b/test/integration/server/webhooks/price-signature-post-commit-recovery.test.ts index cd57fd6115..0a2a16ba51 100644 --- a/test/integration/server/webhooks/price-signature-post-commit-recovery.test.ts +++ b/test/integration/server/webhooks/price-signature-post-commit-recovery.test.ts @@ -15,7 +15,6 @@ import { modifierUsedQuantities } from "#shared/db/modifier-usage.ts"; import { modifiersTable } from "#shared/db/modifiers.ts"; import { decryptSessionTokens, - isSessionProcessed, releaseReservation, } from "#shared/db/processed-payments.ts"; import { getAttendeeAnswersBatch } from "#shared/db/questions/attendee-answers/reads.ts"; @@ -34,7 +33,10 @@ import { getTestPrivateKey } from "#test-utils/crypto.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { signMeta, singleItem, webhookMeta } from "#test-utils/factories.ts"; import { mockRequest } from "#test-utils/mocks.ts"; -import { expectProcessedPaymentReference } from "#test-utils/processed-payments.ts"; +import { + expectProcessedPaymentReference, + getProcessedPayment, +} from "#test-utils/processed-payments.ts"; import { stubRetrieveCheckoutSession } from "#test-utils/webhooks.ts"; const contactCountsByHash = async (hash: string) => @@ -167,7 +169,7 @@ describeWithEnv("paid booking lost-result recovery", { db: true }, () => { (message) => message === "Promo code 'RECOVER' used: £1 off", ), ).toHaveLength(1); - const processed = await isSessionProcessed(sessionId); + const processed = await getProcessedPayment(sessionId); expect(processed?.attendee_id).toBe(attendee!.id); expect(await decryptSessionTokens(processed!.ticket_tokens)).toBe( ticketToken, @@ -225,7 +227,7 @@ describeWithEnv("paid booking lost-result recovery", { db: true }, () => { await assertJson(webhookRequest(), 200, (json) => { expect(json.processed).toBe(true); }); - const replayed = await isSessionProcessed(sessionId); + const replayed = await getProcessedPayment(sessionId); expect(replayed?.attendee_id).toBe(attendee!.id); expect(await decryptSessionTokens(replayed!.ticket_tokens)).toBe(""); expect(await getAttendeesRaw(listing.id)).toEqual([attendee]); @@ -356,7 +358,7 @@ describeWithEnv("paid booking lost-result recovery", { db: true }, () => { ); expect(decrypted!.id).toBe(committed.attendeeId); expect(decrypted!.ticket_token).toBe(committed.ticketToken); - const processed = await isSessionProcessed(sessionId); + const processed = await getProcessedPayment(sessionId); expect(processed!.attendee_id).toBe(committed.attendeeId); expect(await decryptSessionTokens(processed!.ticket_tokens)).toBe(""); const replayedRedirect = await redirectRequest(sessionId); diff --git a/test/integration/server/webhooks/price-signature-stored-refund-and-ignore.test.ts b/test/integration/server/webhooks/price-signature-stored-refund-and-ignore.test.ts index f4ec86a589..b313326385 100644 --- a/test/integration/server/webhooks/price-signature-stored-refund-and-ignore.test.ts +++ b/test/integration/server/webhooks/price-signature-stored-refund-and-ignore.test.ts @@ -6,7 +6,6 @@ import { transfersByAccount } from "#shared/accounting/queries.ts"; import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; import { getNoteRows, getNotesFor } from "#shared/db/notes/queries.ts"; import { attendeeNotes } from "#shared/db/notes/target.ts"; -import { isSessionProcessed } from "#shared/db/processed-payments.ts"; import { balanceOf } from "#shared/ledger/project.ts"; import { expectAcknowledgedIgnore, @@ -25,6 +24,7 @@ import { assertJson } from "#test-utils/assertions.ts"; import { getTestPrivateKey } from "#test-utils/crypto.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { singleItem, webhookMeta } from "#test-utils/factories.ts"; +import { getProcessedPayment } from "#test-utils/processed-payments.ts"; import { setupStripe } from "#test-utils/settings.ts"; import { stubRetrieveCheckoutSession } from "#test-utils/webhooks.ts"; @@ -109,7 +109,7 @@ describeWithEnv( // …but the session is NOT finalized: attendee_id stays null and the refund // is the terminal outcome. If a change finalizes it, a replay would wrongly // hand the customer a ticket — so pin both fields. - const record = await isSessionProcessed("cs_unfinalized"); + const record = await getProcessedPayment("cs_unfinalized"); expect(record?.attendee_id).toBeNull(); expect(record?.failure_data).not.toBe(""); }); @@ -151,7 +151,7 @@ describeWithEnv( // A success finalizes (attendee_id set in the same transaction as the // attendee insert), so its replay returns the ticket. The store-refund path // is the deliberate exception above; keep the two from drifting together. - const record = await isSessionProcessed("cs_finalized"); + const record = await getProcessedPayment("cs_finalized"); expect(record?.attendee_id).toBe(attendee!.id); }, ); @@ -178,7 +178,7 @@ describeWithEnv( async (refund) => { await expectStoredRefund(listing.id); expect(refund.calls.length).toBe(1); - const record = await isSessionProcessed("cs_crash_store"); + const record = await getProcessedPayment("cs_crash_store"); expect(record?.attendee_id).toBeNull(); expect(record?.failure_data).not.toBe(""); }, @@ -204,7 +204,7 @@ describeWithEnv( await expectStoredRefund(999999); expect(refund.calls.length).toBe(1); // Recorded as the session's terminal outcome (not finalized → no ticket). - const record = await isSessionProcessed("cs_missing_listing"); + const record = await getProcessedPayment("cs_missing_listing"); expect(record?.attendee_id).toBeNull(); expect(record?.failure_data).not.toBe(""); }, @@ -346,7 +346,7 @@ describeWithEnv( const [attendee] = await getAttendeesRaw(listing.id); expect(attendee?.quantity).toBe(0); expect(await getNoteRows("attendee", [attendee!.id])).toHaveLength(1); - const record = await isSessionProcessed("cs_refund_retry"); + const record = await getProcessedPayment("cs_refund_retry"); expect(record?.failure_data).not.toBe(""); }, ); diff --git a/test/integration/server/webhooks/refund-helper-functions.test.ts b/test/integration/server/webhooks/refund-helper-functions.test.ts index 1d30a95c4f..74f9d53eac 100644 --- a/test/integration/server/webhooks/refund-helper-functions.test.ts +++ b/test/integration/server/webhooks/refund-helper-functions.test.ts @@ -10,11 +10,11 @@ import { createTestListing } from "#test-utils/db-helpers/listings.ts"; import { withEnv } from "#test-utils/env.ts"; import { signedMeta, singleItem } from "#test-utils/factories.ts"; import { mockRequest } from "#test-utils/mocks.ts"; +import { expectSessionFailed } from "#test-utils/processed-payments.ts"; import { setupStripe } from "#test-utils/settings.ts"; import { checkoutSessionEvent, expectAttendeeCreatedWithPiiBlob, - expectSessionFailed, expectWebhookProcessed, stubRefundPayment, stubRetrieveCheckoutSession, @@ -56,10 +56,10 @@ describeWithEnv( "We could not find this payment session.", ); // The rejected session must not leave a processed-payment row behind. - const { isSessionProcessed } = await import( - "#shared/db/processed-payments.ts" + const { getProcessedPayment } = await import( + "#test-utils/processed-payments.ts" ); - expect(await isSessionProcessed("cs_null_ref")).toBeNull(); + expect(await getProcessedPayment("cs_null_ref")).toBeNull(); } finally { mockRetrieve.restore(); } diff --git a/test/integration/servicing/atomicity.test.ts b/test/integration/servicing/atomicity.test.ts index 222338cb37..d8dd056493 100644 --- a/test/integration/servicing/atomicity.test.ts +++ b/test/integration/servicing/atomicity.test.ts @@ -18,7 +18,7 @@ import { createDailyTestListing, createTestListing, } from "#test-utils/db-helpers/listings.ts"; -import { withPoisonedTransactionWrite } from "#test-utils/db-poison.ts"; +import { withPoisonedWrite } from "#test-utils/db-poison.ts"; import { createServicingHold, createTestServicingEvent, @@ -32,7 +32,7 @@ import { /** Fail the FIRST `attendee_answers` write (the answer save), so the * create/update compensation runs. */ -const withAnswerSaveFailure = withPoisonedTransactionWrite( +const withAnswerSaveFailure = withPoisonedWrite( (sql) => sql.includes("attendee_answers"), "answer save boom", ); @@ -43,7 +43,7 @@ const withAnswerSaveFailure = withPoisonedTransactionWrite( * old answers, then the re-insert fails and rolls the delete back, so the * compensation must restore the WHOLE prior answer set (choice + free-text), * not just its choice half. */ -const withAnswerInsertFailure = withPoisonedTransactionWrite( +const withAnswerInsertFailure = withPoisonedWrite( (sql) => sql.includes("INSERT INTO attendee_answers"), "answer insert boom", ); diff --git a/test/integration/webhook-price-signature-trusted-and-mismatch.test.ts b/test/integration/webhook-price-signature-trusted-and-mismatch.test.ts index 8670ef0db9..1f4ef697e2 100644 --- a/test/integration/webhook-price-signature-trusted-and-mismatch.test.ts +++ b/test/integration/webhook-price-signature-trusted-and-mismatch.test.ts @@ -7,7 +7,6 @@ import { execute } from "#shared/db/client.ts"; import { listingChildren } from "#shared/db/listing-parents.ts"; import { deleteListing } from "#shared/db/listings/delete.ts"; import { listingsTable } from "#shared/db/listings/records.ts"; -import { isSessionProcessed } from "#shared/db/processed-payments.ts"; import { runDatabasePruning } from "#shared/db/prune.ts"; import { expectProcessed, @@ -24,6 +23,7 @@ import { assertJson } from "#test-utils/assertions.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 { getProcessedPayment } from "#test-utils/processed-payments.ts"; import { setupStripe } from "#test-utils/settings.ts"; const pruneReplayRowWithoutRefundReference = async (sessionId: string) => { @@ -34,7 +34,7 @@ const pruneReplayRowWithoutRefundReference = async (sessionId: string) => { ["2000-01-01T00:00:00.000Z", sessionId], ); await runDatabasePruning(); - expect(await isSessionProcessed(sessionId)).toBe(null); + expect(await getProcessedPayment(sessionId)).toBe(null); }; describeWithEnv( @@ -106,7 +106,7 @@ 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( + expect((await getProcessedPayment(session.id))!.attendee_id).toBe( original!.id, ); }); diff --git a/test/shared/checkout-pricing/consistency.test.ts b/test/shared/checkout-pricing/consistency.test.ts index c5985a52ca..12463fa2ef 100644 --- a/test/shared/checkout-pricing/consistency.test.ts +++ b/test/shared/checkout-pricing/consistency.test.ts @@ -1,13 +1,14 @@ import { expect } from "@std/expect"; import { it as test } from "@std/testing/bdd"; -import type { ModifierRef } from "#shared/booking-intent.ts"; +import { loadPaidOrderSnapshot } from "#routes/api/payment-processing/snapshot/io.ts"; +import type { BookingIntent, ModifierRef } from "#shared/booking-intent.ts"; import { priceCheckout } from "#shared/checkout-pricing.ts"; import { hmacHash } from "#shared/crypto/hashing.ts"; import { getDb } from "#shared/db/client.ts"; +import { hashEmail } from "#shared/db/contact-preferences.ts"; import { answerModifierQuantities, resolveModifiers, - specsFromRefs, } from "#shared/db/modifier-resolve.ts"; import { enableQueryLog, @@ -83,12 +84,32 @@ const pricingIntent = ( * id/quantity refs stored in provider metadata, pass them through the JSON * boundary the webhook parses, then re-fetch by id — exactly as production. */ const rebuildFromMetadata = async ( + items: CheckoutItem[], publicSpecs: ModifierSpec[], ctx: { visits: number } = { visits: 0 }, ): Promise => { const refs = toModifierRefs(publicSpecs) ?? []; const fromMetadata = JSON.parse(JSON.stringify(refs)) as ModifierRef[]; - return specsFromRefs(fromMetadata, ctx); + if (ctx.visits > 0) { + await getDb().execute({ + args: [await hashEmail(buyer.email), ctx.visits], + sql: `INSERT INTO contact_preferences (contact_hash, visits) + VALUES (?, ?) + ON CONFLICT(contact_hash) DO UPDATE SET visits = excluded.visits`, + }); + } + const intent: BookingIntent = { + ...buyer, + date: null, + items: items.map((item) => ({ + e: item.listingId, + p: item.unitPrice * item.quantity, + q: item.quantity, + })), + modifiers: fromMetadata, + }; + return (await loadPaidOrderSnapshot("pricing-consistency", intent)) + .modifierSpecs; }; /** Assert the public specs and their webhook-rebuilt counterparts price a cart @@ -99,7 +120,7 @@ const expectConsistent = async ( opts: { ctx?: { visits: number }; overrides?: Partial } = {}, ): Promise => { const ctx = opts.ctx ?? { visits: 0 }; - const webhookSpecs = await rebuildFromMetadata(publicSpecs, ctx); + const webhookSpecs = await rebuildFromMetadata(items, publicSpecs, ctx); const pub = priceCheckout(pricingIntent(items, publicSpecs, opts.overrides)); const web = priceCheckout(pricingIntent(items, webhookSpecs, opts.overrides)); expect(web.total).toBe(pub.total); diff --git a/test/shared/db/attendees/api/create-rollback.test.ts b/test/shared/db/attendees/api/create-rollback.test.ts index a9aeb4e7a3..bbd0871603 100644 --- a/test/shared/db/attendees/api/create-rollback.test.ts +++ b/test/shared/db/attendees/api/create-rollback.test.ts @@ -18,7 +18,6 @@ import { modifierUsedQuantities } from "#shared/db/modifier-usage.ts"; import { modifiersTable } from "#shared/db/modifiers.ts"; import { decryptSessionTokens, - isSessionProcessed, markSessionFailed, reserveSession, } from "#shared/db/processed-payments.ts"; @@ -26,7 +25,10 @@ import { seedOrderActivity } from "#test-utils/contact-tokens.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 { expectProcessedPaymentReference } from "#test-utils/processed-payments.ts"; +import { + expectProcessedPaymentReference, + getProcessedPayment, +} from "#test-utils/processed-payments.ts"; const OCCURRED_AT = "2026-07-15T00:00:00.000Z"; @@ -254,7 +256,7 @@ describeWithEnv("db > attendee create rollback", { db: true }, () => { expect(created.ticket_token).toBe(ticketToken); expect(created.payment_id).toBe("pi_atomic"); - const session = await isSessionProcessed(sessionId); + const session = await getProcessedPayment(sessionId); expect(session!.attendee_id).toBe(created.id); expect(await decryptSessionTokens(session!.ticket_tokens)).toBe( ticketToken, diff --git a/test/shared/db/attendees/balance.test.ts b/test/shared/db/attendees/balance.test.ts index 52cd949096..9728e55d67 100644 --- a/test/shared/db/attendees/balance.test.ts +++ b/test/shared/db/attendees/balance.test.ts @@ -18,10 +18,7 @@ import { } from "#shared/db/attendees/balance.ts"; import { getDb } from "#shared/db/client.ts"; import { balanceFinalizeStatements } from "#shared/db/payment-finalize.ts"; -import { - isSessionProcessed, - reserveSession, -} from "#shared/db/processed-payments.ts"; +import { reserveSession } from "#shared/db/processed-payments.ts"; import { enableQueryLog, getQueryLog, @@ -38,6 +35,7 @@ import { describeWithEnv } from "#test-utils/db.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; import { postListingSale } from "#test-utils/ledger.ts"; import { expectRefundReferences } from "#test-utils/payment-references.ts"; +import { getProcessedPayment } from "#test-utils/processed-payments.ts"; describeWithEnv("db > settle attendee balance", { db: true }, () => { test("clears the balance, moves to the paid status and logs it", async () => { @@ -97,7 +95,7 @@ describeWithEnv("db > settle attendee balance", { db: true }, () => { ), ); - const row = await isSessionProcessed("balance-ref-ok"); + const row = await getProcessedPayment("balance-ref-ok"); expect(row?.attendee_id).toBe(attendeeId); expect(row?.payment_reference).not.toContain("pi_balance_ok"); await expectRefundReferences(attendeeId, ["pi_balance_ok"]); @@ -120,7 +118,7 @@ describeWithEnv("db > settle attendee balance", { db: true }, () => { ); expect(result).toEqual({ reason: "amount_mismatch", settled: false }); - const row = await isSessionProcessed("balance-ref-mismatch"); + const row = await getProcessedPayment("balance-ref-mismatch"); expect(row?.attendee_id).toBe(null); expect(row?.payment_reference).toBe(""); }); diff --git a/test/shared/db/attendees/create.test.ts b/test/shared/db/attendees/create.test.ts index ac3a1defd2..a1f0b69295 100644 --- a/test/shared/db/attendees/create.test.ts +++ b/test/shared/db/attendees/create.test.ts @@ -25,12 +25,10 @@ import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; import { queryOne, withTransaction } from "#shared/db/client.ts"; import { modifierUsedQuantities } from "#shared/db/modifier-usage.ts"; import { modifiersTable } from "#shared/db/modifiers.ts"; -import { - isSessionProcessed, - reserveSession, -} from "#shared/db/processed-payments.ts"; +import { reserveSession } from "#shared/db/processed-payments.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { getProcessedPayment } from "#test-utils/processed-payments.ts"; /** Narrow a createBookingAtomic result to the successful shape, or fail the test. */ const expectBookingOk = ( @@ -180,7 +178,7 @@ describeWithEnv("db > createBookingAtomic", { db: true }, () => { // Modifier stock consumed exactly once. expect(await modifierUsedQuantities([m.id])).toEqual(new Map([[m.id, 1]])); // Session finalized atomically: attendee_id set in the same batch. - const session = await isSessionProcessed("sess_batch_ok"); + const session = await getProcessedPayment("sess_batch_ok"); expect(session!.attendee_id).toBe(attendeeId); // The booking row is stamped with the legs' event group, so the per-row // amount-paid projection resolves exactly this booking's legs. @@ -215,7 +213,7 @@ describeWithEnv("db > createBookingAtomic", { db: true }, () => { // Nothing landed: no attendee, no legs, no stock, session left unresolved. await expectNothingWritten(listing.id, 0); expect(await modifierUsedQuantities([m.id])).toEqual(new Map()); - expect((await isSessionProcessed("sess_batch_soldout"))!.attendee_id).toBe( + expect((await getProcessedPayment("sess_batch_soldout"))!.attendee_id).toBe( null, ); }); @@ -372,7 +370,7 @@ describeWithEnv("db > createBookingAtomic", { db: true }, () => { await expectCapacityExceeded(plan, listing.id, 500, plan.legs.length); expect( - (await isSessionProcessed("sess_batch_existing_ledger"))!.attendee_id, + (await getProcessedPayment("sess_batch_existing_ledger"))!.attendee_id, ).toBe(null); }); }); diff --git a/test/shared/db/attendees/delete.test.ts b/test/shared/db/attendees/delete.test.ts index 61b4d6b75d..787e074d49 100644 --- a/test/shared/db/attendees/delete.test.ts +++ b/test/shared/db/attendees/delete.test.ts @@ -11,10 +11,7 @@ import { modifierUsedQuantities } from "#shared/db/modifier-usage.ts"; import { getAllModifiers, modifiersTable } from "#shared/db/modifiers.ts"; import { createSystemNote, getNoteRows } from "#shared/db/notes/queries.ts"; import { attendeeNotes } from "#shared/db/notes/target.ts"; -import { - isSessionProcessed, - reserveSession, -} from "#shared/db/processed-payments.ts"; +import { reserveSession } from "#shared/db/processed-payments.ts"; import { insertCheckoutStage } from "#test-utils/checkout-stages.ts"; import { getTestPrivateKey } from "#test-utils/crypto.ts"; import { describeWithEnv } from "#test-utils/db.ts"; @@ -22,7 +19,10 @@ import { createPaidTestAttendee } from "#test-utils/db-helpers/attendee-payments import { createTestAttendee } from "#test-utils/db-helpers/attendees.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; import { insertModifierUsage } from "#test-utils/modifiers.ts"; -import { finalizeReservedPayment } from "#test-utils/processed-payments.ts"; +import { + finalizeReservedPayment, + getProcessedPayment, +} from "#test-utils/processed-payments.ts"; describeWithEnv("db > attendees > deleteAttendee", { db: true }, () => { test("removes attendee", async () => { @@ -66,7 +66,7 @@ describeWithEnv("db > attendees > deleteAttendee", { db: true }, () => { await deleteAttendee(attendee.id); - const processed = await isSessionProcessed("sess_attendee_delete"); + const processed = await getProcessedPayment("sess_attendee_delete"); expect(processed).toBeNull(); }); diff --git a/test/shared/db/listings/delete.test.ts b/test/shared/db/listings/delete.test.ts index feba2b952f..da985bde17 100644 --- a/test/shared/db/listings/delete.test.ts +++ b/test/shared/db/listings/delete.test.ts @@ -19,10 +19,7 @@ import { listingsTable, } from "#shared/db/listings/records.ts"; import { listingReader } from "#shared/db/listings/select.ts"; -import { - isSessionProcessed, - reserveSession, -} from "#shared/db/processed-payments.ts"; +import { reserveSession } from "#shared/db/processed-payments.ts"; import { getAttendeeAnswersBatch } from "#shared/db/questions/attendee-answers/reads.ts"; import { saveAttendeeAnswers } from "#shared/db/questions/attendee-answers/save.ts"; import { listingQuestions } from "#shared/db/questions/queries.ts"; @@ -38,7 +35,10 @@ import { } from "#test-utils/db-helpers/attributes.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; import { withEnv } from "#test-utils/env.ts"; -import { finalizeReservedPayment } from "#test-utils/processed-payments.ts"; +import { + finalizeReservedPayment, + getProcessedPayment, +} from "#test-utils/processed-payments.ts"; import { withTestSession } from "#test-utils/session.ts"; describeWithEnv("db > listings", { db: true, triggers: true }, () => { @@ -101,7 +101,7 @@ describeWithEnv("db > listings", { db: true, triggers: true }, () => { await deleteListing(listing.id); // The attendee is orphaned, not purged, so its payment record survives. - const processed = await isSessionProcessed("sess_listing_delete"); + const processed = await getProcessedPayment("sess_listing_delete"); expect(processed?.attendee_id).toBe(attendee.id); }); @@ -233,7 +233,7 @@ describeWithEnv("db > listings", { db: true, triggers: true }, () => { await deleteListing(listing1.id); - const processed = await isSessionProcessed("sess_multi_listing"); + const processed = await getProcessedPayment("sess_multi_listing"); expect(processed?.attendee_id).toBe(attendeeId); }); diff --git a/test/shared/db/modifier-resolve.test.ts b/test/shared/db/modifier-resolve.test.ts index c95cae5aaa..ebf6984257 100644 --- a/test/shared/db/modifier-resolve.test.ts +++ b/test/shared/db/modifier-resolve.test.ts @@ -13,7 +13,6 @@ import { hasPromoCodeModifiers, oversubscribedAnswerTiers, resolveModifiers, - specsFromRefs, } from "#shared/db/modifier-resolve.ts"; import { getModifierAnswerIds, @@ -608,61 +607,4 @@ describeWithEnv("db > modifier-resolve", { db: true }, () => { expect(await getOptionalAddOns([1])).toEqual([]); }); }); - - describe("specsFromRefs", () => { - test("returns [] for no references", async () => { - expect(await specsFromRefs([])).toEqual([]); - }); - - test("rebuilds specs from references, re-fetching current values", async () => { - const created = await insertModifier({ - calcKind: "fixed", - calcValue: 5, - direction: "charge", - name: "Parking", - }); - const specs = await specsFromRefs([{ i: created.id, q: 2 }]); - expect(specs).toEqual([ - { - id: created.id, - kind: "fixed", - listingIds: null, - name: "Parking", - quantity: 2, - trigger: "automatic", - value: toMinorUnits(5), - }, - ]); - }); - - test("rebuilds the listing ids for a scoped reference", async () => { - const m = await insertModifier({ name: "Scoped" }); - await patchModifier(m.id, { scope: "listings" }); - await linkModifierListing(m.id, 3); - const specs = await specsFromRefs([{ i: m.id, q: 1 }]); - expect(specs[0]?.listingIds).toEqual([3]); - }); - - test("drops references to modifiers that no longer resolve", async () => { - const created = await insertModifier({ name: "Gone" }); - await patchModifier(created.id, { active: 0 }); - expect(await specsFromRefs([{ i: created.id, q: 1 }])).toEqual([]); - expect(await specsFromRefs([{ i: 9999, q: 1 }])).toEqual([]); - }); - - test("re-checks the visit gate when rebuilding references", async () => { - const created = await insertModifier({ - direction: "discount", - minVisits: 1, - name: "Returning", - }); - - expect(await specsFromRefs([{ i: created.id, q: 1 }])).toEqual([]); - expect( - (await specsFromRefs([{ i: created.id, q: 1 }], { visits: 1 })).map( - (s) => s.name, - ), - ).toEqual(["Returning"]); - }); - }); }); diff --git a/test/shared/db/processed-payments.test.ts b/test/shared/db/processed-payments.test.ts index c98b9b4f32..2224775291 100644 --- a/test/shared/db/processed-payments.test.ts +++ b/test/shared/db/processed-payments.test.ts @@ -9,8 +9,6 @@ import { decryptSessionTokens, encryptTicketTokens, finalizeSessionIfUnresolved, - isSessionProcessed, - isUnresolvedReservation, markSessionFailed, parseSessionFailure, reserveSession, @@ -22,16 +20,22 @@ 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 { emptyResultSet } from "#test-utils/db-helpers/result-set.ts"; import { expectProcessedPaymentReference, finalizeReservedPayment, + getProcessedPayment, } from "#test-utils/processed-payments.ts"; +import { countDatabaseCalls } from "#test-utils/subrequest-budget.ts"; describeWithEnv("db > processed payments", { db: true }, () => { describe("reserveSession", () => { test("succeeds on first call", async () => { - const result = await reserveSession("sess_test_1"); - expect(result.reserved).toBe(true); + const calls = await countDatabaseCalls(1, async () => { + const result = await reserveSession("sess_test_1"); + expect(result.reserved).toBe(true); + }); + expect(calls).toBe(1); }); test("returns existing when session already reserved and finalized", async () => { @@ -51,23 +55,27 @@ describeWithEnv("db > processed payments", { db: true }, () => { attendeeResult.attendees[0]!.id, ); - const result = await reserveSession("sess_dup"); - expect(result.reserved).toBe(false); - if (!result.reserved) { - expect(result.existing.attendee_id).toBe( - attendeeResult.attendees[0]!.id, - ); - } + const calls = await countDatabaseCalls(1, async () => { + const result = await reserveSession("sess_dup"); + expect(result.reserved).toBe(false); + if (!result.reserved) { + expect(result.existing.attendee_id).toBe( + attendeeResult.attendees[0]!.id, + ); + } + }); + expect(calls).toBe(1); }); test("returns existing when session is reserved but not finalized", async () => { await reserveSession("sess_unfinalized"); - const result = await reserveSession("sess_unfinalized"); - expect(result.reserved).toBe(false); - if (!result.reserved) { - expect(result.existing.attendee_id).toBeNull(); - } + const calls = await countDatabaseCalls(1, async () => { + const result = await reserveSession("sess_unfinalized"); + expect(result.reserved).toBe(false); + if (!result.reserved) expect(result.existing.attendee_id).toBeNull(); + }); + expect(calls).toBe(1); }); test("retries when stale reservation detected", async () => { @@ -82,11 +90,14 @@ describeWithEnv("db > processed payments", { db: true }, () => { }), ); - const result = await reserveSession("sess_stale"); - expect(result.reserved).toBe(true); + const calls = await countDatabaseCalls(1, async () => { + const result = await reserveSession("sess_stale"); + expect(result.reserved).toBe(true); + }); + expect(calls).toBe(1); // Session was successfully re-reserved and is now tracked - const processed = await isSessionProcessed("sess_stale"); + const processed = await getProcessedPayment("sess_stale"); expect(processed).not.toBeNull(); }); @@ -97,7 +108,7 @@ describeWithEnv("db > processed payments", { db: true }, () => { refunded: true, status: 409, }); - const row = await isSessionProcessed("sess_failrt"); + const row = await getProcessedPayment("sess_failrt"); expect(await parseSessionFailure(row!.failure_data)).toEqual({ error: "Sold out", refunded: true, @@ -111,7 +122,7 @@ describeWithEnv("db > processed payments", { db: true }, () => { error: "Private Listing Name sold out", status: 409, }); - const row = await isSessionProcessed("sess_failenc"); + const row = await getProcessedPayment("sess_failenc"); // The raw column is ciphertext: the user-facing message can embed an // encrypted-at-rest listing name, so it must not be stored in the clear. expect(row!.failure_data).not.toContain("Private Listing Name"); @@ -135,7 +146,7 @@ describeWithEnv("db > processed payments", { db: true }, () => { error: "Second", status: 409, }); - const row = await isSessionProcessed("sess_failtwice"); + const row = await getProcessedPayment("sess_failtwice"); expect((await parseSessionFailure(row!.failure_data))?.error).toBe( "First", ); @@ -160,7 +171,7 @@ describeWithEnv("db > processed payments", { db: true }, () => { await markSessionFailed("sess_finalized_nofail", { error: "late fail" }); - const row = await isSessionProcessed("sess_finalized_nofail"); + const row = await getProcessedPayment("sess_finalized_nofail"); // The success is preserved: attendee_id intact, no failure recorded. expect(row!.attendee_id).toBe(attendee.attendees[0]!.id); expect(row!.failure_data).toBe(""); @@ -226,40 +237,23 @@ describeWithEnv("db > processed payments", { db: true }, () => { }); }); - test("distinguishes unresolved rows from both terminal outcomes", () => { - const base = { - failure_data: "" as const, - payment_reference: "" as const, - payment_session_id: "state-shape", - processed_at: "2026-07-18T00:00:00.000Z", - provider_refunded_at: "", - ticket_tokens: "" as const, - }; - expect(isUnresolvedReservation({ ...base, attendee_id: null })).toBe(true); - expect(isUnresolvedReservation({ ...base, attendee_id: 1 })).toBe(false); - expect( - isUnresolvedReservation({ - ...base, - attendee_id: null, - failure_data: "encrypted" as EnvKeyEncrypted, - }), - ).toBe(false); - }); - test("rethrows the original non-constraint error", async () => { const sentinel = new Error("write transport failed"); const client = getDb(); - const original = client.execute.bind(client); - let first = true; - using executeStub = stub(client, "execute", (...args) => { - if (first) { - first = false; - return Promise.reject(sentinel); - } - return original(...args); - }); + using batchStub = stub(client, "batch", () => Promise.reject(sentinel)); await expect(reserveSession("non-constraint")).rejects.toBe(sentinel); - expect(executeStub.calls).toHaveLength(1); + expect(batchStub.calls).toHaveLength(1); + }); + + test("throws when the atomic lookup does not return the session", async () => { + const client = getDb(); + using batchStub = stub(client, "batch", () => + Promise.resolve([emptyResultSet(), emptyResultSet()]), + ); + await expect(reserveSession("missing-lookup")).rejects.toThrow( + "Reserved payment session is missing: missing-lookup", + ); + expect(batchStub.calls).toHaveLength(1); }); test("encrypts multiple ticket tokens with their separator", async () => { @@ -278,7 +272,7 @@ describeWithEnv("db > processed payments", { db: true }, () => { await finalizeSessionIfUnresolved("sess_heal", 42); - const row = (await isSessionProcessed("sess_heal"))!; + const row = (await getProcessedPayment("sess_heal"))!; expect(row.attendee_id).toBe(42); // The ledger-replay heal never writes ticket_tokens. expect(row.ticket_tokens).toBe(""); @@ -306,7 +300,7 @@ describeWithEnv("db > processed payments", { db: true }, () => { // ticket_tokens (which would render the success page without the ticket). await finalizeSessionIfUnresolved("sess_raced", 99); - const row = (await isSessionProcessed("sess_raced"))!; + const row = (await getProcessedPayment("sess_raced"))!; expect(row.attendee_id).toBe(7); expect(await decryptSessionTokens(row.ticket_tokens)).toBe("tok-real"); }); @@ -320,7 +314,7 @@ describeWithEnv("db > processed payments", { db: true }, () => { await finalizeReservedPayment("sess_clear_tokens", 7, "secret-token"); await clearSessionTokens("sess_clear_tokens"); expect( - (await isSessionProcessed("sess_clear_tokens"))!.ticket_tokens, + (await getProcessedPayment("sess_clear_tokens"))!.ticket_tokens, ).toBe(""); }); }); diff --git a/test/shared/db/processed-payments/finalize-guard.test.ts b/test/shared/db/processed-payments/finalize-guard.test.ts index 48355ccebc..0029487011 100644 --- a/test/shared/db/processed-payments/finalize-guard.test.ts +++ b/test/shared/db/processed-payments/finalize-guard.test.ts @@ -4,14 +4,16 @@ import { executeBatch } from "#shared/db/client.ts"; import { batchFinalizeStatements } from "#shared/db/payment-finalize.ts"; import { decryptSessionTokens, - isSessionProcessed, 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 { expectProcessedPaymentReference } from "#test-utils/processed-payments.ts"; +import { + expectProcessedPaymentReference, + getProcessedPayment, +} from "#test-utils/processed-payments.ts"; describeWithEnv("db > processed payment finalize guard", { db: true }, () => { test("rejects a missing session", async () => { @@ -43,7 +45,7 @@ describeWithEnv("db > processed payment finalize guard", { db: true }, () => { "first-token", ), ); - const finalized = await isSessionProcessed("already-finalized"); + const finalized = await getProcessedPayment("already-finalized"); expect(finalized!.attendee_id).toBe(attendeeId); expect(await decryptSessionTokens(finalized!.ticket_tokens)).toBe( "first-token", @@ -67,7 +69,7 @@ describeWithEnv("db > processed payment finalize guard", { db: true }, () => { ), ).rejects.toThrow("processed_payments.processed_at"); - const row = await isSessionProcessed("already-finalized"); + const row = await getProcessedPayment("already-finalized"); expect(row).toEqual(finalized); await expectProcessedPaymentReference( attendeeId, diff --git a/test/shared/db/processed-payments/staleness.test.ts b/test/shared/db/processed-payments/staleness.test.ts index ed8f01d21b..3239986a5f 100644 --- a/test/shared/db/processed-payments/staleness.test.ts +++ b/test/shared/db/processed-payments/staleness.test.ts @@ -3,44 +3,25 @@ import { describe, it as test } from "@std/testing/bdd"; import { getDb, insert } from "#shared/db/client.ts"; import { deleteAllStaleReservations, - isReservationStale, - isSessionProcessed, releaseReservation, reserveSession, STALE_RESERVATION_MS, } from "#shared/db/processed-payments.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { useProcessedPaymentsAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; -import { finalizeReservedPayment } from "#test-utils/processed-payments.ts"; +import { + finalizeReservedPayment, + getProcessedPayment, +} from "#test-utils/processed-payments.ts"; describeWithEnv("processed-payments / staleness", { db: true }, () => { const ctx = useProcessedPaymentsAttendee(); - describe("isReservationStale", () => { - test("returns false for a recent timestamp", () => { - expect(isReservationStale(new Date().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 true for a timestamp over the threshold", () => { - const stale = new Date( - Date.now() - STALE_RESERVATION_MS - 1000, - ).toISOString(); - expect(isReservationStale(stale)).toBe(true); - }); - }); - describe("releaseReservation", () => { test("deletes an unfinalized reservation", async () => { await reserveSession("cs_stale_to_delete"); await releaseReservation("cs_stale_to_delete"); - expect(await isSessionProcessed("cs_stale_to_delete")).toBeNull(); + expect(await getProcessedPayment("cs_stale_to_delete")).toBeNull(); }); test("does not delete a finalized reservation", async () => { @@ -48,7 +29,7 @@ describeWithEnv("processed-payments / staleness", { db: true }, () => { await finalizeReservedPayment("cs_finalized_no_delete", ctx.attendeeId); await releaseReservation("cs_finalized_no_delete"); - const record = await isSessionProcessed("cs_finalized_no_delete"); + const record = await getProcessedPayment("cs_finalized_no_delete"); expect(record?.attendee_id).toBe(ctx.attendeeId); }); @@ -75,15 +56,15 @@ describeWithEnv("processed-payments / staleness", { db: true }, () => { await insertStale("cs_stale_bulk_2"); expect(await deleteAllStaleReservations()).toBe(2); - expect(await isSessionProcessed("cs_stale_bulk_1")).toBeNull(); - expect(await isSessionProcessed("cs_stale_bulk_2")).toBeNull(); + expect(await getProcessedPayment("cs_stale_bulk_1")).toBeNull(); + expect(await getProcessedPayment("cs_stale_bulk_2")).toBeNull(); }); test("does not delete fresh unfinalized reservations", async () => { await reserveSession("cs_fresh_bulk"); expect(await deleteAllStaleReservations()).toBe(0); - expect(await isSessionProcessed("cs_fresh_bulk")).not.toBeNull(); + expect(await getProcessedPayment("cs_fresh_bulk")).not.toBeNull(); }); test("does not delete finalized reservations regardless of age", async () => { @@ -98,9 +79,9 @@ describeWithEnv("processed-payments / staleness", { db: true }, () => { ); expect(await deleteAllStaleReservations()).toBe(0); - expect((await isSessionProcessed("cs_finalized_bulk"))?.attendee_id).toBe( - ctx.attendeeId, - ); + expect( + (await getProcessedPayment("cs_finalized_bulk"))?.attendee_id, + ).toBe(ctx.attendeeId); }); test("returns 0 when no stale reservations exist", async () => { diff --git a/test/shared/db/questions/attendee-answers/save/group-listings.test.ts b/test/shared/db/questions/attendee-answers/save/group-listings.test.ts new file mode 100644 index 0000000000..f74ab011cc --- /dev/null +++ b/test/shared/db/questions/attendee-answers/save/group-listings.test.ts @@ -0,0 +1,50 @@ +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { groupListingAnswerSets } from "#shared/db/questions/attendee-answers/save.ts"; + +test("groups listing choices and keeps the last text for each attendee question", () => { + const grouped = groupListingAnswerSets( + [ + { attendee: { id: 10 }, listing: { id: 1 } }, + { attendee: { id: 10 }, listing: { id: 2 } }, + { attendee: { id: 20 }, listing: { id: 3 } }, + { attendee: { id: 30 }, listing: { id: 4 } }, + { attendee: { id: 40 }, listing: { id: 5 } }, + ], + { "1": [101], "2": [202], "4": [404] }, + { + "1": [{ questionId: 7, text: "First" }], + "2": [ + { questionId: 7, text: "Last" }, + { questionId: 8, text: "Other" }, + ], + "5": [{ questionId: 9, text: "Text only" }], + }, + ); + expect(grouped).toEqual( + new Map([ + [ + 10, + { + answerIds: [101, 202], + textAnswers: [ + { questionId: 7, text: "Last" }, + { questionId: 8, text: "Other" }, + ], + }, + ], + [30, { answerIds: [404] }], + [ + 40, + { answerIds: [], textAnswers: [{ questionId: 9, text: "Text only" }] }, + ], + ]), + ); +}); + +test("uses no answers when listing maps and entries are empty", () => { + expect( + groupListingAnswerSets([{ attendee: { id: 10 }, listing: { id: 1 } }], {}), + ).toEqual(new Map()); + expect(groupListingAnswerSets([], {})).toEqual(new Map()); +}); diff --git a/test/shared/db/questions/attendee-answers/save/stored-ids-behavior.test.ts b/test/shared/db/questions/attendee-answers/save/stored-ids-behavior.test.ts new file mode 100644 index 0000000000..fb9b87521c --- /dev/null +++ b/test/shared/db/questions/attendee-answers/save/stored-ids-behavior.test.ts @@ -0,0 +1,216 @@ +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { execute, queryAll } from "#shared/db/client.ts"; +import { saveAttendeeAnswers } from "#shared/db/questions/attendee-answers/save.ts"; +import { getOrCreateStringIds } from "#shared/db/questions/strings.ts"; +import { + addAnswer, + createAttendee, + createQuestion, +} from "#test/shared/db/questions/helpers.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; +import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { expectRejects } from "#test-utils/servicing.ts"; +import { countDatabaseCalls } from "#test-utils/subrequest-budget.ts"; + +const storedRowsFor = (attendeeId: number) => + queryAll<{ answer_id: number | null; string_id: number | null }>( + "SELECT answer_id, string_id FROM attendee_answers WHERE attendee_id = ? ORDER BY id", + [attendeeId], + ); + +const seedStoredIdAnswers = async () => { + const choiceQuestion = await createQuestion("Choice"); + const answer = await addAnswer(choiceQuestion.id, 0, "Stored choice"); + const textQuestion = await createQuestion("Text", { + displayType: "free_text", + }); + const stringId = (await getOrCreateStringIds(["Stored"])).get("Stored")!; + const attendee = await createAttendee((await createTestListing()).id); + return { answer, attendee, stringId, textQuestion }; +}; + +const saveSeed = async ( + before?: ( + seed: Awaited>, + ) => Promise, +) => { + const seed = await seedStoredIdAnswers(); + if (before) await before(seed); + const calls = await countDatabaseCalls(1, () => + saveAttendeeAnswers( + new Map([ + [ + seed.attendee.id, + { + answerIds: [seed.answer.id], + textAnswerIds: [ + { questionId: seed.textQuestion.id, stringId: seed.stringId }, + ], + }, + ], + ]), + ), + ); + return { calls, ...seed }; +}; + +describeWithEnv( + "db > attendee answers > stored id behavior", + { db: true }, + () => { + test("omits deleted choices and text questions", async () => { + const { attendee, calls } = await saveSeed(async (seed) => { + await execute("DELETE FROM answers WHERE id = ?", [seed.answer.id]); + await execute("DELETE FROM questions WHERE id = ?", [ + seed.textQuestion.id, + ]); + }); + expect(calls).toBe(1); + expect(await storedRowsFor(attendee.id)).toEqual([]); + }); + + test("saves one choice with one stored text id", async () => { + const { answer, attendee, calls, stringId } = await saveSeed(); + expect(calls).toBe(1); + expect(await storedRowsFor(attendee.id)).toEqual([ + { answer_id: answer.id, string_id: null }, + { answer_id: null, string_id: stringId }, + ]); + }); + + test("saves stored and plain text answers together", async () => { + const choiceQuestion = await createQuestion("Choice"); + const choice = await addAnswer(choiceQuestion.id, 0, "Chosen"); + const storedQuestion = await createQuestion("Stored", { + displayType: "free_text", + }); + const plainQuestion = await createQuestion("Plain", { + displayType: "free_text", + }); + const storedStringId = ( + await getOrCreateStringIds(["Stored answer"]) + ).get("Stored answer")!; + const attendee = await createAttendee((await createTestListing()).id); + await saveAttendeeAnswers( + new Map([ + [ + attendee.id, + { + answerIds: [choice.id], + textAnswerIds: [ + { questionId: storedQuestion.id, stringId: storedStringId }, + ], + textAnswers: [ + { questionId: plainQuestion.id, text: "Plain answer" }, + ], + }, + ], + ]), + ); + expect(await storedRowsFor(attendee.id)).toEqual([ + { answer_id: choice.id, string_id: null }, + { answer_id: null, string_id: storedStringId }, + { answer_id: null, string_id: expect.any(Number) }, + ]); + }); + + test("saves one plain text answer", async () => { + const question = await createQuestion("Plain", { + displayType: "free_text", + }); + const attendee = await createAttendee((await createTestListing()).id); + await saveAttendeeAnswers( + new Map([ + [ + attendee.id, + { + answerIds: [], + textAnswers: [{ questionId: question.id, text: "One answer" }], + }, + ], + ]), + ); + expect(await storedRowsFor(attendee.id)).toEqual([ + { answer_id: null, string_id: expect.any(Number) }, + ]); + }); + + test("rolls back the stored id batch when an insert fails", async () => { + const question = await createQuestion("Choice"); + const oldAnswer = await addAnswer(question.id, 0, "Old"); + const newAnswer = await addAnswer(question.id, 1, "New"); + const textQuestion = await createQuestion("Text", { + displayType: "free_text", + }); + const attendee = await createAttendee((await createTestListing()).id); + await saveAttendeeAnswers( + new Map([[attendee.id, { answerIds: [oldAnswer.id] }]]), + ); + await expectRejects( + saveAttendeeAnswers( + new Map([ + [ + attendee.id, + { + answerIds: [newAnswer.id], + textAnswerIds: [ + { questionId: textQuestion.id, stringId: null as never }, + ], + }, + ], + ]), + ), + /invalid attendee answer/, + ); + expect(await storedRowsFor(attendee.id)).toEqual([ + { answer_id: oldAnswer.id, string_id: null }, + ]); + expect( + await queryAll( + "SELECT id, times_selected FROM answers WHERE id IN (?, ?) ORDER BY id", + [oldAnswer.id, newAnswer.id], + ), + ).toEqual([ + { id: oldAnswer.id, times_selected: 1 }, + { id: newAnswer.id, times_selected: 0 }, + ]); + }); + + test("clears stored answers in one database call", async () => { + const question = await createQuestion("Choice"); + const answer = await addAnswer(question.id, 0, "Old"); + const attendee = await createAttendee((await createTestListing()).id); + await saveAttendeeAnswers( + new Map([[attendee.id, { answerIds: [answer.id] }]]), + ); + const calls = await countDatabaseCalls(1, () => + saveAttendeeAnswers( + new Map([ + [ + attendee.id, + { answerIds: [], textAnswerIds: [], textAnswers: [] }, + ], + ]), + ), + ); + expect(calls).toBe(1); + expect(await storedRowsFor(attendee.id)).toEqual([]); + }); + + test("does not call the database for no attendees", async () => { + expect( + await countDatabaseCalls(0, () => saveAttendeeAnswers(new Map())), + ).toBe(0); + }); + + test("keeps the array form on the plaintext transaction path", async () => { + const attendee = await createAttendee((await createTestListing()).id); + expect( + await countDatabaseCalls(3, () => + saveAttendeeAnswers(new Map([[attendee.id, []]])), + ), + ).toBe(3); + }); + }, +); diff --git a/test/shared/db/questions/attendee-answers/save/stored-ids.test.ts b/test/shared/db/questions/attendee-answers/save/stored-ids.test.ts new file mode 100644 index 0000000000..a03a60b252 --- /dev/null +++ b/test/shared/db/questions/attendee-answers/save/stored-ids.test.ts @@ -0,0 +1,119 @@ +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { queryAll } from "#shared/db/client.ts"; +import { saveAttendeeAnswers } from "#shared/db/questions/attendee-answers/save.ts"; +import { getOrCreateStringIds } from "#shared/db/questions/strings.ts"; +import { + addAnswer, + createAttendee, + createQuestion, +} from "#test/shared/db/questions/helpers.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; +import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { countDatabaseCalls } from "#test-utils/subrequest-budget.ts"; + +describeWithEnv("db > attendee answers > stored ids", { db: true }, () => { + test("replaces choice and stored text ids for several attendees in one database call", async () => { + const choiceQuestion = await createQuestion("Meal?"); + const firstChoice = await addAnswer(choiceQuestion.id, 0, "Soup"); + const lastChoice = await addAnswer(choiceQuestion.id, 1, "Salad"); + const textQuestion = await createQuestion("Notes?", { + displayType: "free_text", + }); + const stringIds = await getOrCreateStringIds(["Old", "First", "Last"]); + const oldStringId = stringIds.get("Old")!; + const firstStringId = stringIds.get("First")!; + const lastStringId = stringIds.get("Last")!; + const listing = await createTestListing(); + const firstAttendee = await createAttendee(listing.id, "Alice"); + const secondAttendee = await createAttendee(listing.id, "Bob"); + const oldAnswers = { + answerIds: [firstChoice.id], + textAnswerIds: [{ questionId: textQuestion.id, stringId: oldStringId }], + }; + await saveAttendeeAnswers( + new Map([ + [firstAttendee.id, oldAnswers], + [secondAttendee.id, oldAnswers], + ]), + ); + + const calls = await countDatabaseCalls(1, () => + saveAttendeeAnswers( + new Map([ + [ + firstAttendee.id, + { + answerIds: [firstChoice.id, lastChoice.id], + textAnswerIds: [ + { questionId: textQuestion.id, stringId: firstStringId }, + { questionId: textQuestion.id, stringId: lastStringId }, + ], + }, + ], + [ + secondAttendee.id, + { + answerIds: [lastChoice.id, firstChoice.id], + textAnswerIds: [ + { questionId: textQuestion.id, stringId: lastStringId }, + { questionId: textQuestion.id, stringId: firstStringId }, + ], + }, + ], + ]), + ), + ); + expect(calls).toBe(1); + expect( + await queryAll( + `SELECT attendee_id, question_id, answer_id, string_id + FROM attendee_answers ORDER BY attendee_id, question_id`, + ), + ).toEqual([ + { + answer_id: lastChoice.id, + attendee_id: firstAttendee.id, + question_id: choiceQuestion.id, + string_id: null, + }, + { + answer_id: null, + attendee_id: firstAttendee.id, + question_id: textQuestion.id, + string_id: lastStringId, + }, + { + answer_id: firstChoice.id, + attendee_id: secondAttendee.id, + question_id: choiceQuestion.id, + string_id: null, + }, + { + answer_id: null, + attendee_id: secondAttendee.id, + question_id: textQuestion.id, + string_id: firstStringId, + }, + ]); + expect( + await queryAll( + "SELECT id, times_selected FROM answers WHERE id IN (?, ?) ORDER BY id", + [firstChoice.id, lastChoice.id], + ), + ).toEqual([ + { id: firstChoice.id, times_selected: 1 }, + { id: lastChoice.id, times_selected: 1 }, + ]); + expect( + await queryAll( + "SELECT id, used_count FROM strings WHERE id IN (?, ?, ?) ORDER BY id", + [oldStringId, firstStringId, lastStringId], + ), + ).toEqual([ + { id: oldStringId, used_count: 0 }, + { id: firstStringId, used_count: 1 }, + { id: lastStringId, used_count: 1 }, + ]); + }); +}); diff --git a/test/shared/registration-package-facts.test.ts b/test/shared/registration-package-facts.test.ts new file mode 100644 index 0000000000..7a06078d6d --- /dev/null +++ b/test/shared/registration-package-facts.test.ts @@ -0,0 +1,61 @@ +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { setGroupPackageMembers } from "#shared/db/groups.ts"; +import { loadRegistrationPackageFacts } from "#shared/registration-package-facts.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; +import { createHiddenPackageGroup } from "#test-utils/db-helpers/groups.ts"; +import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { countDatabaseCalls } from "#test-utils/subrequest-budget.ts"; + +const row = (packageGroupId: number) => ({ + attendee: { package_group_id: packageGroupId }, +}); + +describeWithEnv("loadRegistrationPackageFacts", { db: true }, () => { + test("does not read the database for rows outside a package", async () => { + const calls = await countDatabaseCalls(0, async () => { + expect(await loadRegistrationPackageFacts([row(0), row(-1)])).toEqual({ + displays: new Map(), + pricingByGroup: new Map(), + }); + }); + expect(calls).toBe(0); + }); + + test("loads each package once with its display and complete member pricing", async () => { + const group = await createHiddenPackageGroup("Weekend bundle"); + const member = await createTestListing({ + customisableDays: true, + dayPrices: { 1: 900, 2: 1600 }, + durationDays: 2, + groupId: group.id, + listingType: "daily", + name: "Cabin", + unitPrice: 900, + }); + await setGroupPackageMembers(group.id, [ + { dayPrices: { 2: 1400 }, listingId: member.id, price: 750, quantity: 3 }, + ]); + const facts = await loadRegistrationPackageFacts([ + row(group.id), + row(group.id), + row(0), + ]); + expect(facts.displays).toEqual( + new Map([[group.id, { hideListings: true, name: "Weekend bundle" }]]), + ); + expect(facts.pricingByGroup).toEqual( + new Map([ + [ + group.id, + { + dayPriceMap: new Map([[member.id, new Map([[2, 1400]])]]), + memberIds: new Set([member.id]), + priceMap: new Map([[member.id, 750]]), + quantityMap: new Map([[member.id, 3]]), + }, + ], + ]), + ); + }); +}); diff --git a/test/shared/session-ledger.test.ts b/test/shared/session-ledger.test.ts index 7fbbbc7237..39ba5d1cba 100644 --- a/test/shared/session-ledger.test.ts +++ b/test/shared/session-ledger.test.ts @@ -1,84 +1,19 @@ import { expect } from "@std/expect"; import { it as test } from "@std/testing/bdd"; -import { bookingEventGroup } from "#shared/accounting/mappers.ts"; -import { postTransfers } from "#shared/accounting/store.ts"; -import { - enableQueryLog, - getQueryLog, - runWithQueryLogContext, -} from "#shared/db/query-log.ts"; import { type BookingLedgerDisposition, - bookingLedgerDisposition, classifyBookingLedger, } from "#shared/session-ledger.ts"; -import { describeWithEnv } from "#test-utils/db.ts"; -import { createPaidTestAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; -import { createTestListing } from "#test-utils/db-helpers/listings.ts"; -import { tx } from "#test-utils/ledger.ts"; -/** - * The pure preflight decision table, pinning the classifier itself so the - * booked/orphaned/unrecorded verdict can't drift. The IO loader ({@link - * bookingLedgerDisposition}) gets its own DB-backed tests below. - */ const cases: [boolean, number | null, BookingLedgerDisposition][] = [ - // No legs ⇒ never honoured, whatever the owner lookup would say. [false, null, { status: "unrecorded" }], [false, 42, { status: "unrecorded" }], - // Legs with a live owner ⇒ a real booking to replay. [true, 42, { attendeeId: 42, status: "booked" }], - // Legs but no live owner ⇒ deleted attendee / placeholder: already handled. [true, null, { status: "orphaned" }], ]; for (const [hasLegs, owner, expected] of cases) { - test(`classifyBookingLedger(${hasLegs}, ${owner}) ⇒ ${expected.status}`, () => { + test(`classifyBookingLedger(${hasLegs}, ${owner}) => ${expected.status}`, () => { expect(classifyBookingLedger(hasLegs, owner)).toEqual(expected); }); } - -describeWithEnv("bookingLedgerDisposition", { db: true }, () => { - test("returns unrecorded when the ledger holds no legs for the event", async () => { - const disposition = await bookingLedgerDisposition("never-happened-event"); - expect(disposition).toEqual({ status: "unrecorded" }); - }); - - test("skips the owner lookup for an unrecorded session, costing a single existence probe", async () => { - await runWithQueryLogContext(async () => { - enableQueryLog(); - await bookingLedgerDisposition("never-happened-event-2"); - // Only eventGroupHasLegs' existence check — attendeeIdByLedgerEventGroup - // must not run when there are no legs to own. - expect(getQueryLog().length).toBe(1); - }); - }); - - test("returns orphaned when legs exist but no listing_attendees row owns the group", async () => { - const eventId = "orphan-event"; - const group = await bookingEventGroup(eventId); - await postTransfers([tx({ eventGroup: group, reference: "orphan-ref" })]); - - const disposition = await bookingLedgerDisposition(eventId); - - expect(disposition).toEqual({ status: "orphaned" }); - }); - - test("returns booked with the owning attendee id when a live booking owns the group", async () => { - const listing = await createTestListing(); - const attendee = await createPaidTestAttendee( - listing.id, - "Test User", - "test@example.com", - "pay-1", - 500, - ); - // Matches postListingSale's default eventId (`sale-${listingId}-${attendeeId}`), - // used implicitly by createPaidTestAttendee. - const eventId = `sale-${listing.id}-${attendee.id}`; - - const disposition = await bookingLedgerDisposition(eventId); - - expect(disposition).toEqual({ attendeeId: attendee.id, status: "booked" }); - }); -}); diff --git a/test/shared/webhook/budget.test.ts b/test/shared/webhook/budget.test.ts index 9990f9bfb0..aeda91f1fe 100644 --- a/test/shared/webhook/budget.test.ts +++ b/test/shared/webhook/budget.test.ts @@ -9,6 +9,7 @@ import { expect } from "@std/expect"; import { it as test } from "@std/testing/bdd"; import { setGroupPackageMembers } from "#shared/db/groups.ts"; import type { EmailEntry } from "#shared/email.ts"; +import type { RegistrationPackageFacts } from "#shared/registration-package-facts.ts"; import { logAndNotifyRegistration, sendRegistrationWebhooks, @@ -56,6 +57,7 @@ describeWithEnv("registration notification budget", { db: true }, () => { const packagedEntries = async ( label: string, packageCount: number, + webhookUrl = "https://example.com/hook", ): Promise => { const entries: EmailEntry[] = []; for (let index = 0; index < packageCount; index++) { @@ -67,7 +69,7 @@ describeWithEnv("registration notification budget", { db: true }, () => { groupId: group.id, name: `${label} member ${index}`, unitPrice: 0, - webhookUrl: "https://example.com/hook", + webhookUrl, }); await setGroupPackageMembers(group.id, [ { listingId: member.id, price: 500 }, @@ -78,7 +80,7 @@ describeWithEnv("registration notification budget", { db: true }, () => { id: member.id, name: member.name, slug: member.slug, - webhook_url: "https://example.com/hook", + webhook_url: webhookUrl, }, { id: member.id, package_group_id: group.id }, ), @@ -108,4 +110,46 @@ describeWithEnv("registration notification budget", { db: true }, () => { expect(await calls(six)).toBe(await calls(one)); }); + + test("does not read package facts when email and webhooks are off", async () => { + const entries = await packagedEntries("Disabled", 1, ""); + + expect( + await countDatabaseCalls(1, () => logAndNotifyRegistration(entries)), + ).toBe(1); + }); + + test("loads free package facts once when a webhook is enabled", async () => { + const entries = await packagedEntries("Enabled", 1); + + expect( + await countDatabaseCalls(4, () => logAndNotifyRegistration(entries)), + ).toBe(4); + }); + + test("uses supplied package facts without reading the database", async () => { + const [entry] = await packagedEntries("Supplied", 1); + const groupId = entry!.attendee.package_group_id; + const facts: RegistrationPackageFacts = { + displays: new Map([ + [groupId, { hideListings: false, name: "Supplied package" }], + ]), + pricingByGroup: new Map([ + [ + groupId, + { + dayPriceMap: new Map(), + memberIds: new Set([entry!.listing.id]), + priceMap: new Map([[entry!.listing.id, 500]]), + quantityMap: new Map([[entry!.listing.id, 1]]), + }, + ], + ]), + }; + expect( + await countDatabaseCalls(0, () => + sendRegistrationWebhooks([entry!], "GBP", facts), + ), + ).toBe(0); + }); }); diff --git a/test/shared/webhook/payload-fields.test.ts b/test/shared/webhook/payload-fields.test.ts index 92b50464c7..20c549b5ad 100644 --- a/test/shared/webhook/payload-fields.test.ts +++ b/test/shared/webhook/payload-fields.test.ts @@ -6,6 +6,7 @@ import { expect } from "@std/expect"; import { beforeEach, it as test } from "@std/testing/bdd"; import { resetEffectiveDomain } from "#shared/config.ts"; +import type { RegistrationPackagePricing } from "#shared/registration-package-facts.ts"; import { buildWebhookPayload, type RegistrationEntry, @@ -20,6 +21,16 @@ import { import type { EmailEntry } from "#test-utils/internal.ts"; describeWithEnv("buildWebhookPayload", { db: true }, () => { + const packagePricing = ( + prices: ReadonlyMap = new Map(), + dayPriceMap: ReadonlyMap> = new Map(), + ): RegistrationPackagePricing => ({ + dayPriceMap, + memberIds: new Set(), + priceMap: prices, + quantityMap: new Map(), + }); + beforeEach(async () => { resetEffectiveDomain(); const { settings: s } = await import("#shared/db/settings.ts"); @@ -83,9 +94,7 @@ describeWithEnv("buildWebhookPayload", { db: true }, () => { }, ), ]; - const overrides = new Map([ - [7, { dayPrices: new Map(), prices: new Map([[42, 900]]) }], - ]); + const overrides = new Map([[7, packagePricing(new Map([[42, 900]]))]]); const payload = buildWebhookPayload(entries, "GBP", overrides); @@ -97,11 +106,7 @@ describeWithEnv("buildWebhookPayload", { db: true }, () => { /** The payload for entries whose package group 7 carries NO overrides. */ const payloadWithEmptyOverrides = (entries: EmailEntry[]) => - buildWebhookPayload( - entries, - "GBP", - new Map([[7, { dayPrices: new Map(), prices: new Map() }]]), - ); + buildWebhookPayload(entries, "GBP", new Map([[7, packagePricing()]])); test("falls back to the base price for a package member with no override", async () => { const entries = [ @@ -142,13 +147,7 @@ describeWithEnv("buildWebhookPayload", { db: true }, () => { entries, "GBP", new Map([ - [ - 7, - { - dayPrices: new Map([[44, new Map([[2, 1500]])]]), - prices: new Map(), - }, - ], + [7, packagePricing(new Map(), new Map([[44, new Map([[2, 1500]])]]))], ]), ); expect(payload.tickets[0]!.unit_price).toBe(1500); diff --git a/test/shared/webhook/payload.test.ts b/test/shared/webhook/payload.test.ts index 184eb499d9..75e972d1f8 100644 --- a/test/shared/webhook/payload.test.ts +++ b/test/shared/webhook/payload.test.ts @@ -6,6 +6,7 @@ import { expect } from "@std/expect"; import { it as test } from "@std/testing/bdd"; import { spy } from "@std/testing/mock"; +import type { RegistrationPackagePricing } from "#shared/registration-package-facts.ts"; import { buildWebhookPayload, type RegistrationEntry, @@ -34,7 +35,12 @@ const reportedPrice = ( const overrides = new Map([ [ overriddenGroupId, - { dayPrices: new Map(), prices: new Map([[entry.listing.id, 750]]) }, + { + dayPriceMap: new Map(), + memberIds: new Set(), + priceMap: new Map([[entry.listing.id, 750]]), + quantityMap: new Map(), + } satisfies RegistrationPackagePricing, ], ]); return buildWebhookPayload([entry], "GBP", overrides).tickets[0]?.unit_price; diff --git a/test/specs/steps/payment-capacity.ts b/test/specs/steps/payment-capacity.ts index 0d09ce9243..69fcb489bc 100644 --- a/test/specs/steps/payment-capacity.ts +++ b/test/specs/steps/payment-capacity.ts @@ -7,7 +7,6 @@ import { leaveEvidencePage } from "#scripts/specs/evidence/pages.ts"; import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; import { getNotesFor } from "#shared/db/notes/queries.ts"; import { attendeeNotes } from "#shared/db/notes/target.ts"; -import { isSessionProcessed } from "#shared/db/processed-payments.ts"; import { requiredWorldValue, type TicketsWorld, @@ -17,12 +16,15 @@ import { fillSoleCapacityListing } from "#test-utils/db-helpers/attendee-payment import { createTestListing } from "#test-utils/db-helpers/listings.ts"; import { signedMeta, singleItem } from "#test-utils/factories.ts"; import { mockRequest, withExpectedError } from "#test-utils/mocks.ts"; +import { + expectSessionFailed, + getProcessedPayment, +} from "#test-utils/processed-payments.ts"; import { setupStripe } from "#test-utils/settings.ts"; import { checkoutSessionEvent, expectAttendeeCreatedWithPiiBlob, expectRefundedWithNote, - expectSessionFailed, expectWebhookKeptAndRefunded, expectWebhookProcessed, findKeptPlaceholder, @@ -103,7 +105,7 @@ Then( const listingId = requiredWorldValue(this.listingId, "listing id"); const sessionId = requiredWorldValue(this.sessionId, "session id"); const attendee = await expectAttendeeCreatedWithPiiBlob(listingId); - const record = await isSessionProcessed(sessionId); + const record = await getProcessedPayment(sessionId); if (!record) throw new Error(`Processed payment ${sessionId} was not stored`); expect(record.attendee_id).toBe(attendee.id); @@ -198,7 +200,7 @@ Given( const attendee = await findKeptPlaceholder(listingId); this.placeholderId = attendee.id; this.attendeeIds = (await getAttendeesRaw(listingId)).map(({ id }) => id); - const record = await isSessionProcessed(sessionId); + const record = await getProcessedPayment(sessionId); if (!record?.failure_data) throw new Error("terminal failure was not stored"); this.firstFailureData = record.failure_data; @@ -242,7 +244,7 @@ Then( expect(firstBody).toContain("refunded"); expect(firstBody).not.toContain("being processed"); const sessionId = requiredWorldValue(this.sessionId, "session id"); - const record = await isSessionProcessed(sessionId); + const record = await getProcessedPayment(sessionId); if (!record) throw new Error(`Processed payment ${sessionId} was not stored`); expect(record.attendee_id).toBeNull(); diff --git a/test/test-utils/db-poison.ts b/test/test-utils/db-poison.ts index d7f06cbd98..b128448508 100644 --- a/test/test-utils/db-poison.ts +++ b/test/test-utils/db-poison.ts @@ -1,23 +1,28 @@ import { getDb } from "#shared/db/client.ts"; /** - * Reject the first transactional statement whose SQL matches `matches`, then - * delegate every subsequent write to the real tx — so the failure lands - * mid-flow (after the in-transaction DELETE ran, for `saveAttendeeAnswers`) and - * the caller's rollback/compensation runs against a working client. - * - * Swaps the db client's `transaction` method in place (module namespaces are - * frozen, but the client instance's method is configurable), then restores it - * in `finally`. `saveAttendeeAnswers` runs its DELETE + string interning + - * INSERT inside one `withTransaction`, so a poison that intercepts - * `db.transaction` is what reaches its writes. + * Reject the first batch or transactional statement whose SQL matches + * `matches`, then delegate every subsequent write to the real client. + * Stored-ID answers use `db.batch`; plaintext answers use `db.transaction`. */ -export const withPoisonedTransactionWrite = +export const withPoisonedWrite = (matches: (sql: string) => boolean, message: string) => async (body: () => Promise): Promise => { const db = getDb(); + const realDbBatch = db.batch.bind(db); const realTransaction = db.transaction.bind(db); let poisoned = true; + db.batch = (( + statements: Array<{ sql: string }>, + mode: "read" | "write", + ) => { + const matched = statements.find((statement) => matches(statement.sql)); + if (poisoned && matched) { + poisoned = false; + return Promise.reject(new Error(message)); + } + return realDbBatch(statements as never, mode); + }) as typeof db.batch; db.transaction = (async (mode: "read" | "write" = "write") => { const tx = await realTransaction(mode); const realBatch = tx.batch.bind(tx); @@ -42,6 +47,7 @@ export const withPoisonedTransactionWrite = try { await body(); } finally { + db.batch = realDbBatch; db.transaction = realTransaction; } }; diff --git a/test/test-utils/email.ts b/test/test-utils/email.ts index 7b8b20b423..af5d651f22 100644 --- a/test/test-utils/email.ts +++ b/test/test-utils/email.ts @@ -1,9 +1,13 @@ import { expect } from "@std/expect"; import { type Stub, stub } from "@std/testing/mock"; import { resetEffectiveDomain } from "#shared/config.ts"; -import { settings } from "#shared/db/settings.ts"; +import { ALL_SETTINGS_KEYS, settings } from "#shared/db/settings.ts"; import { resetHostEmailConfig } from "#shared/email.ts"; -import { parseEmail, type ValidEmail } from "#shared/validation/email.ts"; +import { + parseEmail, + updateBusinessEmail, + type ValidEmail, +} from "#shared/validation/email.ts"; import { type EnvScope, withEnv } from "./env.ts"; /** @@ -17,6 +21,17 @@ export const validEmail = (address: string): ValidEmail => { return parsed; }; +export const configureTestEmail = async ( + opts: { businessEmail?: string } = {}, +): Promise => { + await settings.update.email.provider("resend"); + await settings.update.email.apiKey("test-key"); + await settings.update.email.fromAddress("from@test.com"); + if (opts.businessEmail) await updateBusinessEmail(opts.businessEmail); + settings.invalidateCache(); + await settings.loadKeys(ALL_SETTINGS_KEYS); +}; + /** * The per-describe state shared by the contact-form and support-message unit * tests: a `fetch` stub installed on demand, an env scope the tests diff --git a/test/test-utils/processed-payments.ts b/test/test-utils/processed-payments.ts index 03de093641..a858d73d66 100644 --- a/test/test-utils/processed-payments.ts +++ b/test/test-utils/processed-payments.ts @@ -1,8 +1,27 @@ import { expect } from "@std/expect"; -import { executeBatch } from "#shared/db/client.ts"; +import { executeBatch, queryOne } from "#shared/db/client.ts"; import { batchFinalizeStatements } from "#shared/db/payment-finalize.ts"; import { getRefundPaymentReferences } from "#shared/db/payment-references.ts"; -import { reserveSession } from "#shared/db/processed-payments.ts"; +import { + type ProcessedPayment, + reserveSession, +} from "#shared/db/processed-payments.ts"; + +export const getProcessedPayment = ( + sessionId: string, +): Promise => + queryOne( + "SELECT payment_session_id, attendee_id, processed_at, ticket_tokens, failure_data, payment_reference, provider_refunded_at " + + "FROM processed_payments WHERE payment_session_id = ?", + [sessionId], + ); + +export const expectSessionFailed = async (sessionId: string): Promise => { + const record = await getProcessedPayment(sessionId); + if (!record) throw new Error(`Processed payment ${sessionId} was not stored`); + expect(record.attendee_id).toBeNull(); + expect(record.failure_data).not.toBe(""); +}; /** Finalize a reserved payment through the same guarded batch as checkout. */ export const finalizeReservedPayment = async ( diff --git a/test/test-utils/webhooks.ts b/test/test-utils/webhooks.ts index 4028a66cdd..c15085726c 100644 --- a/test/test-utils/webhooks.ts +++ b/test/test-utils/webhooks.ts @@ -3,6 +3,7 @@ import { type Stub, stub } from "@std/testing/mock"; import type { SessionMetadata } from "#shared/payments.ts"; import { stripeApi } from "#shared/stripe.ts"; import type { Attendee } from "#shared/types.ts"; +import { expectSessionFailed } from "#test-utils/processed-payments.ts"; import { assertJson } from "./assertions.ts"; import { signedMeta } from "./factories.ts"; import { mockWebhookRequest } from "./mocks.ts"; @@ -197,17 +198,6 @@ export const findKeptPlaceholder = async ( return placeholders[0]!; }; -/** A terminal payment failure has no ticket attendee and keeps its details. */ -export const expectSessionFailed = async (sessionId: string): Promise => { - const { isSessionProcessed } = await import( - "#shared/db/processed-payments.ts" - ); - const record = await isSessionProcessed(sessionId); - if (!record) throw new Error(`Processed payment ${sessionId} was not stored`); - expect(record.attendee_id).toBeNull(); - expect(record.failure_data).not.toBe(""); -}; - /** * Assert the refund fired exactly once and a system note recorded the reason * against `attendeeId` — the shared tail of every "kept and refunded" From a4400034feceb67a5134b10f7b140529ffc88dc0 Mon Sep 17 00:00:00 2001 From: Stefan Date: Fri, 7 Aug 2026 00:28:39 +0100 Subject: [PATCH 2/5] Resolve paid booking review findings --- .../mutation/equivalent-mutants/features.txt | 3 +- .../equivalent-mutants/shared-a-l.txt | 1 - .../equivalent-mutants/shared-m-z.txt | 2 +- .../api/payment-processing/completion.ts | 4 +- src/features/api/payment-processing/create.ts | 19 +-- src/features/api/payment-processing/index.ts | 1 - .../snapshot/{fold.ts => build.ts} | 9 +- .../api/payment-processing/snapshot/io.ts | 53 ++----- .../api/payment-processing/snapshot/types.ts | 9 -- src/shared/session-ledger.ts | 39 +---- src/shared/webhook.ts | 31 ++-- .../api/payment-processing/completion.test.ts | 24 +-- .../payment-processing/create/answers.test.ts | 56 ++++--- .../snapshot/{fold.test.ts => build.test.ts} | 56 ++++--- .../payment-processing/snapshot/io.test.ts | 137 ++++++++++++++---- test/integration/server/api-packages.test.ts | 37 +++++ test/shared/webhook/budget.test.ts | 23 ++- 17 files changed, 270 insertions(+), 234 deletions(-) rename src/features/api/payment-processing/snapshot/{fold.ts => build.ts} (94%) rename test/features/api/payment-processing/snapshot/{fold.test.ts => build.test.ts} (77%) diff --git a/scripts/mutation/equivalent-mutants/features.txt b/scripts/mutation/equivalent-mutants/features.txt index 71ec6f9a58..0ab87325a4 100644 --- a/scripts/mutation/equivalent-mutants/features.txt +++ b/scripts/mutation/equivalent-mutants/features.txt @@ -9,8 +9,7 @@ src/ui/client/admin/order-gallery.ts::initOrderGallery.refresh.timer~1xo126t ?? # Paid snapshot collection fallbacks: every present value is an object or array, # which remains truthy even when empty; only undefined reaches the empty fallback. -src/features/api/payment-processing/snapshot/fold.ts::modifierSpecs.listingIds~1k3bo9n ?? → || # scopes.get(): SnapshotModifierScopeRow[]|undefined — arrays are always truthy -src/features/api/payment-processing/snapshot/io.ts::selectedIds~1xhmyr1 ?? → || # values is Record|undefined — record objects are always truthy +src/features/api/payment-processing/snapshot/build.ts::modifierSpecs.listingIds~1k3bo9n ?? → || # scopes.get(): SnapshotModifierScopeRow[]|undefined — arrays are always truthy # ticket-payment values whose present form is always truthy, whose falsy value # equals the fallback, or whose mutated constant is normalized before use. diff --git a/scripts/mutation/equivalent-mutants/shared-a-l.txt b/scripts/mutation/equivalent-mutants/shared-a-l.txt index e1253c67a1..29c0a220da 100644 --- a/scripts/mutation/equivalent-mutants/shared-a-l.txt +++ b/scripts/mutation/equivalent-mutants/shared-a-l.txt @@ -12,7 +12,6 @@ src/shared/uptime-kuma/matching.ts::SCHEDULED_MONITOR_RULES.holds~14f0p4m 1000 src/shared/checkout-pricing.ts::priceCheckout.modifierSpecs~1b95fmx ?? → || # intent.modifiers: an array is always truthy src/shared/booking-lines.ts::checkoutBookingLines.packageGroupId~0eqzp6w ?? → || # packageGroupId is absent or a positive database group id; an explicit 0 also has the same 0 fallback src/features/api/payment-processing/create.ts::bookingSlot.packageGroupId~0zn56vl ?? → || # lineGroupId returns undefined or a positive package group id; an explicit 0 would also keep the same 0 fallback -src/features/api/payment-processing/create.ts::saveSessionAnswers.listingAnswerIds~1qjy0pt ?? → || # listingAnswerIds is an object when present, and objects are always truthy src/features/api/payment-processing/create.ts::saveSessionAnswers.refs~13mow4w ?? → || # a listing's text refs are an array when present, and arrays are always truthy src/features/api/payment-processing/create.ts::saveSessionAnswers.existing~1kealbd ?? → || # a grouped answer set is an object when present, and objects are always truthy src/features/api/payment-processing/create.ts::saveSessionAnswers.textAnswerIds~1ex96yt ?? → || # existing text answer ids are an array when present, and arrays are always truthy diff --git a/scripts/mutation/equivalent-mutants/shared-m-z.txt b/scripts/mutation/equivalent-mutants/shared-m-z.txt index 37d2b5b68c..5daca45e15 100644 --- a/scripts/mutation/equivalent-mutants/shared-m-z.txt +++ b/scripts/mutation/equivalent-mutants/shared-m-z.txt @@ -3,7 +3,7 @@ # Registration package facts are objects when supplied, so they are always # truthy; undefined selects the loader under either nullish or OR fallback. src/shared/webhook.ts::sendRegistrationWebhooks.facts~007uooi ?? → || # suppliedFacts is RegistrationPackageFacts|undefined -src/shared/webhook.ts::logAndNotifyRegistration.packageFacts~0fhpirc ?? → || # suppliedPackageFacts is RegistrationPackageFacts|undefined +src/shared/webhook.ts::queueRegistrationNotifications.packageFacts~0fhpirc ?? → || # suppliedPackageFacts is RegistrationPackageFacts|undefined # CRUD configuration values are functions or objects when present, and hydrated # map values are records, so none can be falsy-but-non-null. diff --git a/src/features/api/payment-processing/completion.ts b/src/features/api/payment-processing/completion.ts index 9e5648305d..f57af0c578 100644 --- a/src/features/api/payment-processing/completion.ts +++ b/src/features/api/payment-processing/completion.ts @@ -4,7 +4,6 @@ import { saveSessionAnswers, sessionSuccess, } from "#routes/api/payment-processing/create.ts"; -import type { PaidQuestionFacts } from "#routes/api/payment-processing/snapshot/types.ts"; import type { PaymentResult } from "#routes/api/webhook-types.ts"; import type { BookingIntent } from "#shared/booking-intent.ts"; import type { ModifierApplication } from "#shared/checkout-pricing.ts"; @@ -18,10 +17,9 @@ export const completePaidBooking = async ( codeSpecs: ModifierSpec[], modifierApplications: ModifierApplication[], ticketTokens: string[], - questionFacts: PaidQuestionFacts, notificationPackages: RegistrationPackageFacts, ): Promise => { - await saveSessionAnswers(createdEntries, intent, questionFacts); + await saveSessionAnswers(createdEntries, intent); const firstEntry = createdEntries[0]!; const promoActivities = codeSpecs.length > 0 diff --git a/src/features/api/payment-processing/create.ts b/src/features/api/payment-processing/create.ts index 5a050e4787..5b89cd2c89 100644 --- a/src/features/api/payment-processing/create.ts +++ b/src/features/api/payment-processing/create.ts @@ -14,7 +14,6 @@ import { orderLineTotal, paidByItem, } from "#routes/api/payment-processing/pricing.ts"; -import type { PaidQuestionFacts } from "#routes/api/payment-processing/snapshot/types.ts"; import type { PaymentResult } from "#routes/api/webhook-types.ts"; /* jscpd:ignore-start */ import { lineGroupId } from "#shared/booking/signed-metadata.ts"; @@ -193,24 +192,14 @@ const textRefsWithStringId = ( export const saveSessionAnswers = async ( createdEntries: CreatedEntry[], intent: BookingIntent, - questionFacts: PaidQuestionFacts, ): Promise => { if (!intent.listingAnswerIds && !intent.listingTextAnswerIds) return; - const listingAnswerIds = Object.fromEntries( - Object.entries(intent.listingAnswerIds ?? {}).map( - ([listingId, answerIds]) => [ - listingId, - answerIds.filter((answerId) => - questionFacts.questionIdByAnswerId.has(answerId), - ), - ], - ), + const grouped = groupListingAnswerSets( + createdEntries, + intent.listingAnswerIds ?? {}, ); - const grouped = groupListingAnswerSets(createdEntries, listingAnswerIds); for (const { attendee, listing } of createdEntries) { - const refs = ( - intent.listingTextAnswerIds?.[String(listing.id)] ?? [] - ).filter((ref) => questionFacts.textQuestionIds.has(ref.q)); + const refs = intent.listingTextAnswerIds?.[String(listing.id)] ?? []; const resolvedRefs = textRefsWithStringId(refs, listing.id); if (resolvedRefs.length === 0) continue; const existing = grouped.get(attendee.id) ?? { answerIds: [] }; diff --git a/src/features/api/payment-processing/index.ts b/src/features/api/payment-processing/index.ts index 3331c44a4c..8c53e7c37b 100644 --- a/src/features/api/payment-processing/index.ts +++ b/src/features/api/payment-processing/index.ts @@ -264,7 +264,6 @@ const processNewBookingSession = async ( codeSpecs, pricedOrder.modifierApplications, ticketTokens, - snapshot.questions, snapshot.notificationPackages, ); const honoured = await createAttendeeForSession( diff --git a/src/features/api/payment-processing/snapshot/fold.ts b/src/features/api/payment-processing/snapshot/build.ts similarity index 94% rename from src/features/api/payment-processing/snapshot/fold.ts rename to src/features/api/payment-processing/snapshot/build.ts index fb2981714c..2d407caaac 100644 --- a/src/features/api/payment-processing/snapshot/fold.ts +++ b/src/features/api/payment-processing/snapshot/build.ts @@ -102,7 +102,7 @@ const modifierSpecs = ( }); }; -export const foldPaidOrderSnapshot = ( +export const buildPaidOrderSnapshot = ( refs: ModifierRef[], rows: SnapshotRows, dayPrices: SnapshotDayPriceRow[], @@ -145,12 +145,5 @@ export const foldPaidOrderSnapshot = ( pricingByGroup, }, publicStatusId, - questions: { - questionIdByAnswerId: new Map( - rows.answerRows.map((row) => [row.answerId, row.questionId]), - ), - textQuestionIds: new Set(rows.textQuestionIds), - }, - visits, }; }; diff --git a/src/features/api/payment-processing/snapshot/io.ts b/src/features/api/payment-processing/snapshot/io.ts index 847ff2ea3f..d635a5eaa4 100644 --- a/src/features/api/payment-processing/snapshot/io.ts +++ b/src/features/api/payment-processing/snapshot/io.ts @@ -1,5 +1,5 @@ import { unique } from "#fp"; -import { foldPaidOrderSnapshot } from "#routes/api/payment-processing/snapshot/fold.ts"; +import { buildPaidOrderSnapshot } from "#routes/api/payment-processing/snapshot/build.ts"; import type { PaidOrderSnapshot, SnapshotDayPriceRow, @@ -73,16 +73,6 @@ const usableContactHashes = async ( return Promise.all(values); }; -const selectedIds = ( - values: Record | undefined, - idOf: (value: Value) => number, -): number[] => - unique( - Object.values(values ?? {}) - .flat() - .map(idOf), - ); - const snapshotStatements = ( eventGroup: string, intent: BookingIntent, @@ -91,11 +81,6 @@ const snapshotStatements = ( const listingIds = unique(intent.items.map((item) => item.e)); const groupIds = [...lineGroupIds(intent.items)]; const modifierIds = unique(intent.modifiers.map((ref) => ref.i)); - const answerIds = selectedIds(intent.listingAnswerIds, (id) => id); - const textQuestionIds = selectedIds( - intent.listingTextAnswerIds, - (ref) => ref.q, - ); return [ statement( `SELECT EXISTS(SELECT 1 FROM transfers WHERE event_group = ? LIMIT 1) AS has_legs, @@ -160,12 +145,18 @@ const snapshotStatements = ( statement( `SELECT modifierListing.modifier_id, modifierListing.listing_id FROM modifier_listings AS modifierListing - WHERE ${selectIn("modifierListing.modifier_id", modifierIds)} - UNION - SELECT modifierGroup.modifier_id, groupListing.listing_id - FROM modifier_groups AS modifierGroup - JOIN group_listings AS groupListing ON groupListing.group_id = modifierGroup.group_id - WHERE ${selectIn("modifierGroup.modifier_id", modifierIds)}`, + JOIN modifiers AS modifier + ON modifier.id = modifierListing.modifier_id + AND modifier.scope = 'listings' + WHERE ${selectIn("modifierListing.modifier_id", modifierIds)} + UNION + SELECT modifierGroup.modifier_id, groupListing.listing_id + FROM modifier_groups AS modifierGroup + JOIN modifiers AS modifier + ON modifier.id = modifierGroup.modifier_id + AND modifier.scope = 'groups' + JOIN group_listings AS groupListing ON groupListing.group_id = modifierGroup.group_id + WHERE ${selectIn("modifierGroup.modifier_id", modifierIds)}`, [...modifierIds, ...modifierIds], ), statement( @@ -177,16 +168,6 @@ const snapshotStatements = ( "SELECT id FROM attendee_statuses WHERE is_public_default = 1 ORDER BY sort_order, id", [], ), - statement( - `SELECT id, question_id FROM answers AS answer - WHERE ${selectIn("answer.id", answerIds)}`, - answerIds, - ), - statement( - `SELECT id FROM questions AS question - WHERE ${selectIn("question.id", textQuestionIds)}`, - textQuestionIds, - ), ]; }; @@ -263,9 +244,6 @@ export const loadPaidOrderSnapshot = async ( owner_attendee_id: number | null; }>(results[0]!)[0]!; const rows: SnapshotRows = { - answerRows: resultRows<{ id: number; question_id: number }>( - results[11]!, - ).map((row) => ({ answerId: row.id, questionId: row.question_id })), childEdges: resultRows<{ child_listing_id: number; parent_listing_id: number; @@ -293,9 +271,6 @@ export const loadPaidOrderSnapshot = async ( publicStatusIds: resultRows<{ id: number }>(results[10]!).map( (row) => row.id, ), - textQuestionIds: resultRows<{ id: number }>(results[12]!).map( - (row) => row.id, - ), visitCounts: resultRows<{ visits: number }>(results[9]!).map( (row) => row.visits, ), @@ -313,5 +288,5 @@ export const loadPaidOrderSnapshot = async ( unitPrice: row.unit_price, }), ); - return foldPaidOrderSnapshot(intent.modifiers, rows, dayPrices); + return buildPaidOrderSnapshot(intent.modifiers, rows, dayPrices); }; diff --git a/src/features/api/payment-processing/snapshot/types.ts b/src/features/api/payment-processing/snapshot/types.ts index f381ab2bed..64e7563661 100644 --- a/src/features/api/payment-processing/snapshot/types.ts +++ b/src/features/api/payment-processing/snapshot/types.ts @@ -3,11 +3,6 @@ import type { RegistrationPackageFacts } from "#shared/registration-package-fact import type { BookingLedgerDisposition } from "#shared/session-ledger.ts"; import type { GroupListing, ListingWithCount } from "#shared/types.ts"; -export interface PaidQuestionFacts { - questionIdByAnswerId: ReadonlyMap; - textQuestionIds: ReadonlySet; -} - export interface PaidOrderSnapshot { childrenByParentId: ReadonlyMap; hiddenPackageMemberIds: ReadonlySet; @@ -17,8 +12,6 @@ export interface PaidOrderSnapshot { notificationPackages: RegistrationPackageFacts; parentsByChildId: ReadonlyMap; publicStatusId: number; - questions: PaidQuestionFacts; - visits: number; } export interface SnapshotGroupRow { @@ -46,7 +39,6 @@ export interface SnapshotModifierRow { } export interface SnapshotRows { - answerRows: Array<{ answerId: number; questionId: number }>; childEdges: Array<{ childId: number; parentId: number }>; groups: SnapshotGroupRow[]; hiddenMemberIds: number[]; @@ -56,6 +48,5 @@ export interface SnapshotRows { modifierScopes: Array<{ listingId: number; modifierId: number }>; modifiers: SnapshotModifierRow[]; publicStatusIds: number[]; - textQuestionIds: number[]; visitCounts: number[]; } diff --git a/src/shared/session-ledger.ts b/src/shared/session-ledger.ts index cbec3ba818..33eb8be3a2 100644 --- a/src/shared/session-ledger.ts +++ b/src/shared/session-ledger.ts @@ -1,40 +1,10 @@ -/** - * Ledger preflight for a payment session. - * - * The transfers ledger — not the prunable `processed_payments` idempotency row — - * is the durable record of whether a paid session was already honoured. So - * before anything that moves money for a session (creating a booking, settling a - * balance, refunding one), the payment machine consults the ledger here and acts - * on a typed verdict rather than re-deriving an ad-hoc check at each site: - * - * - `unrecorded` — the ledger holds no legs for this booking event, so the - * session has never been honoured: process it fresh. - * - `booked` — a live booking still owns the event group, so the ticket - * exists: replay it, never re-book or refund it. - * - `orphaned` — legs exist but no live booking owns them (an operator deleted - * the attendee, leaving the ledger rows; or it was a refunded quantity-0 - * placeholder): the money is already accounted for, so neither refund again - * nor recreate — acknowledge as already handled. - * - * The classification is split into a PURE decision function ({@link - * classifyBookingLedger}) over the two facts the ledger yields and a thin IO - * loader ({@link bookingLedgerDisposition}) that fetches them, so the decision - * table is unit-testable on its own and the same shape can back other money - * events. - */ - /** What the ledger already records for a booking session (keyed on its event group). */ export type BookingLedgerDisposition = | { status: "unrecorded" } | { status: "booked"; attendeeId: number } | { status: "orphaned" }; -/** - * Classify a booking event from the two facts the ledger yields: whether any - * legs are stored for it, and which live booking (if any) still owns the event - * group. Pure — no IO — so the booked/orphaned/unrecorded decision is exercised - * directly by table-driven tests. - */ +/** Classify stored ledger legs and their current booking owner. */ export const classifyBookingLedger = ( hasLegs: boolean, ownerAttendeeId: number | null, @@ -44,10 +14,3 @@ export const classifyBookingLedger = ( : ownerAttendeeId === null ? { status: "orphaned" } : { attendeeId: ownerAttendeeId, status: "booked" }; - -/** - * Load a booking session's ledger facts and classify them. `eventId` is the - * booking's stable event id — the payment session id on the paid path. The owner - * lookup is skipped when no legs exist (the common fresh-session case), so an - * unrecorded session costs a single existence probe. - */ diff --git a/src/shared/webhook.ts b/src/shared/webhook.ts index 9b9ef2c5ae..9166382365 100644 --- a/src/shared/webhook.ts +++ b/src/shared/webhook.ts @@ -269,6 +269,21 @@ const registrationWebhookUrls = (entries: RegistrationEntry[]): string[] => )(entries), ); +const queueRegistrationNotifications = async ( + entries: EmailEntry[], + currency: string, + suppliedPackageFacts?: RegistrationPackageFacts, +): Promise => { + const needsPackageFacts = + registrationWebhookUrls(entries).length > 0 || + registrationEmailDelivery(entries) !== null; + const packageFacts = needsPackageFacts + ? (suppliedPackageFacts ?? (await loadRegistrationPackageFacts(entries))) + : suppliedPackageFacts; + addPendingWork(sendRegistrationWebhooks(entries, currency, packageFacts)); + addPendingWork(sendRegistrationEmails(entries, currency, packageFacts)); +}; + /** * Apply renewal deadline bumps for a completed payment. * If siteTokenIndex is present, look up the built site and bump its READ_ONLY_FROM. @@ -339,8 +354,9 @@ export const applyRenewalsForEntries = async ( * Log attendee registration and send consolidated webhook * Used for single-listing registrations * - * Webhook sends are queued as pending work so they run in the background - * but complete before the edge runtime tears down the request context. + * Notification preparation and sends are queued as pending work so they run in + * the background but complete before the edge runtime tears down the request + * context. */ export const logAndNotifyRegistration = async ( entries: EmailEntry[], @@ -357,14 +373,9 @@ export const logAndNotifyRegistration = async ( })), ]); const currency = settings.currency; - const needsPackageFacts = - registrationWebhookUrls(entries).length > 0 || - registrationEmailDelivery(entries) !== null; - const packageFacts = needsPackageFacts - ? (suppliedPackageFacts ?? (await loadRegistrationPackageFacts(entries))) - : suppliedPackageFacts; - addPendingWork(sendRegistrationWebhooks(entries, currency, packageFacts)); - addPendingWork(sendRegistrationEmails(entries, currency, packageFacts)); + addPendingWork( + queueRegistrationNotifications(entries, currency, suppliedPackageFacts), + ); addPendingWork(assignAndNotifyBuiltSites(entries)); addPendingWork(applyRenewalsForEntries(entries, siteTokenIndex)); }; diff --git a/test/features/api/payment-processing/completion.test.ts b/test/features/api/payment-processing/completion.test.ts index 32d1159c8f..0b0fc01c3b 100644 --- a/test/features/api/payment-processing/completion.test.ts +++ b/test/features/api/payment-processing/completion.test.ts @@ -2,7 +2,6 @@ import { expect } from "@std/expect"; import { it as test } from "@std/testing/bdd"; import { completePaidBooking } from "#routes/api/payment-processing/completion.ts"; import type { CreatedEntry } from "#routes/api/payment-processing/create.ts"; -import type { PaidQuestionFacts } from "#routes/api/payment-processing/snapshot/types.ts"; import type { BookingIntent } from "#shared/booking-intent.ts"; import type { ModifierApplication } from "#shared/checkout-pricing.ts"; import { getDb } from "#shared/db/client.ts"; @@ -30,11 +29,6 @@ const noPackageFacts = (): RegistrationPackageFacts => ({ pricingByGroup: new Map(), }); -const noQuestionFacts = (): PaidQuestionFacts => ({ - questionIdByAnswerId: new Map(), - textQuestionIds: new Set(), -}); - /** One booked line, as the code that writes the booking hands it on. */ const bookedLine = async ( name: string, @@ -80,7 +74,6 @@ describeWithEnv( [], [], ["tok_a", "tok_b"], - noQuestionFacts(), noPackageFacts(), ), ).toEqual({ @@ -102,7 +95,6 @@ describeWithEnv( [], [], [], - noQuestionFacts(), noPackageFacts(), ); @@ -136,10 +128,6 @@ describeWithEnv( [], [], [], - { - questionIdByAnswerId: new Map([[answer.id, question.id]]), - textQuestionIds: new Set(), - }, noPackageFacts(), ); @@ -184,7 +172,6 @@ describeWithEnv( codeSpecs, applications, [], - noQuestionFacts(), noPackageFacts(), ), ); @@ -202,7 +189,6 @@ describeWithEnv( [], [], [], - noQuestionFacts(), noPackageFacts(), ); @@ -239,15 +225,7 @@ describeWithEnv( const calls = await countDatabaseCalls(1, () => runWithPendingWork(() => - completePaidBooking( - [packagedEntry], - bareIntent(), - [], - [], - [], - noQuestionFacts(), - facts, - ), + completePaidBooking([packagedEntry], bareIntent(), [], [], [], facts), ), ); expect(calls).toBe(1); diff --git a/test/features/api/payment-processing/create/answers.test.ts b/test/features/api/payment-processing/create/answers.test.ts index e707ea2790..947524acb6 100644 --- a/test/features/api/payment-processing/create/answers.test.ts +++ b/test/features/api/payment-processing/create/answers.test.ts @@ -26,8 +26,24 @@ const bookedEntry = async (): Promise => { return { attendee, listing: loaded } as CreatedEntry; }; +const saveAndReadAnswers = async ( + entry: CreatedEntry, + answers: Parameters[1], +) => { + await saveSessionAnswers( + [entry], + bookingIntent([{ e: entry.listing.id, p: 0, q: 1 }], answers), + ); + return ( + await getDb().execute({ + args: [entry.attendee.id], + sql: "SELECT question_id, answer_id, string_id FROM attendee_answers WHERE attendee_id = ?", + }) + ).rows; +}; + describeWithEnv("paid booking answer saves", { db: true }, () => { - test("saves choice answers when there are no text answers", async () => { + test("saves a choice answer missing from the paid-order snapshot", async () => { const entry = await bookedEntry(); const question = await questionsTable.insert({ displayType: "radio", @@ -38,26 +54,15 @@ describeWithEnv("paid booking answer saves", { db: true }, () => { sortOrder: 0, text: "Chosen", }); - await saveSessionAnswers( - [entry], - bookingIntent([{ e: entry.listing.id, p: 0, q: 1 }], { - listingAnswerIds: { [entry.listing.id]: [answer.id] }, - }), - { - questionIdByAnswerId: new Map([[answer.id, question.id]]), - textQuestionIds: new Set(), - }, - ); - const saved = await getDb().execute({ - args: [entry.attendee.id], - sql: "SELECT question_id, answer_id, string_id FROM attendee_answers WHERE attendee_id = ?", + const saved = await saveAndReadAnswers(entry, { + listingAnswerIds: { [entry.listing.id]: [answer.id] }, }); - expect(saved.rows).toEqual([ + expect(saved).toEqual([ { answer_id: answer.id, question_id: question.id, string_id: null }, ]); }); - test("saves a valid text answer when there are no choice answers", async () => { + test("saves a text answer missing from the paid-order snapshot", async () => { const entry = await bookedEntry(); const question = await questionsTable.insert({ displayType: "free_text", @@ -67,23 +72,12 @@ describeWithEnv("paid booking answer saves", { db: true }, () => { "The saved detail", ); if (stringId === undefined) throw new Error("Text answer was not interned"); - await saveSessionAnswers( - [entry], - bookingIntent([{ e: entry.listing.id, p: 0, q: 1 }], { - listingTextAnswerIds: { - [entry.listing.id]: [{ q: question.id, s: stringId }], - }, - }), - { - questionIdByAnswerId: new Map(), - textQuestionIds: new Set([question.id]), + const saved = await saveAndReadAnswers(entry, { + listingTextAnswerIds: { + [entry.listing.id]: [{ q: question.id, s: stringId }], }, - ); - const saved = await getDb().execute({ - args: [entry.attendee.id], - sql: "SELECT question_id, answer_id, string_id FROM attendee_answers WHERE attendee_id = ?", }); - expect(saved.rows).toEqual([ + expect(saved).toEqual([ { answer_id: null, question_id: question.id, string_id: stringId }, ]); }); diff --git a/test/features/api/payment-processing/snapshot/fold.test.ts b/test/features/api/payment-processing/snapshot/build.test.ts similarity index 77% rename from test/features/api/payment-processing/snapshot/fold.test.ts rename to test/features/api/payment-processing/snapshot/build.test.ts index 9954c9d112..779f72f7d3 100644 --- a/test/features/api/payment-processing/snapshot/fold.test.ts +++ b/test/features/api/payment-processing/snapshot/build.test.ts @@ -1,13 +1,12 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; -import { foldPaidOrderSnapshot } from "#routes/api/payment-processing/snapshot/fold.ts"; +import { buildPaidOrderSnapshot } from "#routes/api/payment-processing/snapshot/build.ts"; import type { SnapshotDayPriceRow, SnapshotRows, } from "#routes/api/payment-processing/snapshot/types.ts"; const rows = (overrides: Partial = {}): SnapshotRows => ({ - answerRows: [], childEdges: [], groups: [], hiddenMemberIds: [], @@ -17,17 +16,16 @@ const rows = (overrides: Partial = {}): SnapshotRows => ({ modifierScopes: [], modifiers: [], publicStatusIds: [4], - textQuestionIds: [], visitCounts: [], ...overrides, }); -describe("paid order snapshot fold", () => { - test("folds package display, membership, flat price, and day prices", () => { +describe("paid order snapshot builder", () => { + test("builds package display, membership, flat price, and day prices", () => { const dayPrices: SnapshotDayPriceRow[] = [ { days: 2, groupId: 7, listingId: 11, unitPrice: 850 }, ]; - const snapshot = foldPaidOrderSnapshot( + const snapshot = buildPaidOrderSnapshot( [], rows({ groups: [{ hideListings: true, id: 7, name: "Bundle" }], @@ -55,7 +53,7 @@ describe("paid order snapshot fold", () => { }); test("rebuilds referenced modifiers with visits and listing scopes", () => { - const snapshot = foldPaidOrderSnapshot( + const snapshot = buildPaidOrderSnapshot( [ { i: 3, q: 2 }, { i: 4, q: 1 }, @@ -90,7 +88,6 @@ describe("paid order snapshot fold", () => { [], ); - expect(snapshot.visits).toBe(3); expect(snapshot.modifierSpecs).toEqual([ { id: 3, @@ -104,16 +101,14 @@ describe("paid order snapshot fold", () => { ]); }); - test("folds ledger, relationships, hidden members, and question facts", () => { - const snapshot = foldPaidOrderSnapshot( + test("builds ledger, relationships, and hidden members", () => { + const snapshot = buildPaidOrderSnapshot( [], rows({ - answerRows: [{ answerId: 21, questionId: 20 }], childEdges: [{ childId: 12, parentId: 11 }], hiddenMemberIds: [12], ledger: { hasLegs: true, ownerAttendeeId: 5 }, publicStatusIds: [9], - textQuestionIds: [22], }), [], ); @@ -123,24 +118,39 @@ describe("paid order snapshot fold", () => { expect(snapshot.parentsByChildId).toEqual(new Map([[12, [11]]])); expect(snapshot.hiddenPackageMemberIds).toEqual(new Set([12])); expect(snapshot.publicStatusId).toBe(9); - expect(snapshot.questions.questionIdByAnswerId).toEqual( - new Map([[21, 20]]), - ); - expect(snapshot.questions.textQuestionIds).toEqual(new Set([22])); }); test("fails when the public status is missing", () => { expect(() => - foldPaidOrderSnapshot([], rows({ publicStatusIds: [] }), []), + buildPaidOrderSnapshot([], rows({ publicStatusIds: [] }), []), ).toThrow("No attendee status has the required is_public_default flag"); }); - test("uses zero visits when the buyer has no contact history", () => { - expect(foldPaidOrderSnapshot([], rows(), []).visits).toBe(0); + test("does not apply a returning-buyer modifier without contact history", () => { + const snapshot = buildPaidOrderSnapshot( + [{ i: 4, q: 1 }], + rows({ + modifiers: [ + { + calcKind: "percent", + calcValue: 10, + direction: "discount", + id: 4, + minVisits: 1, + name: "Returning buyer", + scope: "all", + trigger: "automatic", + }, + ], + }), + [], + ); + + expect(snapshot.modifierSpecs).toEqual([]); }); - test("folds a whole-order modifier without listing scopes", () => { - const snapshot = foldPaidOrderSnapshot( + test("builds a whole-order modifier without listing scopes", () => { + const snapshot = buildPaidOrderSnapshot( [{ i: 4, q: 1 }], rows({ modifiers: [ @@ -172,8 +182,8 @@ describe("paid order snapshot fold", () => { ]); }); - test("folds a listing modifier with no linked listings", () => { - const snapshot = foldPaidOrderSnapshot( + test("builds a listing modifier with no linked listings", () => { + const snapshot = buildPaidOrderSnapshot( [{ i: 5, q: 1 }], rows({ modifiers: [ diff --git a/test/features/api/payment-processing/snapshot/io.test.ts b/test/features/api/payment-processing/snapshot/io.test.ts index c5089e0bfd..850a1bb374 100644 --- a/test/features/api/payment-processing/snapshot/io.test.ts +++ b/test/features/api/payment-processing/snapshot/io.test.ts @@ -8,26 +8,77 @@ import { } from "#shared/accounting/accounts.ts"; import { bookingEventGroup } from "#shared/accounting/mappers.ts"; import { postTransfers } from "#shared/accounting/store.ts"; +import { priceCheckout } from "#shared/checkout-pricing.ts"; import { execute } from "#shared/db/client.ts"; import { hashEmail, hashPhone } from "#shared/db/contact-preferences.ts"; -import { setGroupPackageMembers } from "#shared/db/groups.ts"; +import { setGroupPackageMembers, setListingGroups } from "#shared/db/groups.ts"; import { listingChildren } from "#shared/db/listing-parents.ts"; import { modifierGroups, modifierListings, modifiersTable, } from "#shared/db/modifiers.ts"; -import { answersTable, questionsTable } from "#shared/db/questions/tables.ts"; import { bookingIntent } from "#test/features/api/payment-processing/index/helpers.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { createTestAttendeeDirect } from "#test-utils/db-helpers/attendees.ts"; -import { createHiddenPackageGroup } from "#test-utils/db-helpers/groups.ts"; +import { + createHiddenPackageGroup, + createTestGroup, +} from "#test-utils/db-helpers/groups.ts"; import { createDailyTestListing, createTestListing, } from "#test-utils/db-helpers/listings.ts"; import { countDatabaseCalls } from "#test-utils/subrequest-budget.ts"; +const loadStaleScopePricing = async (scope: "groups" | "listings") => { + const listingLink = await createTestListing({ + name: "Listing link", + unitPrice: 1000, + }); + const groupLink = await createTestListing({ + name: "Group link", + unitPrice: 3000, + }); + const group = await createTestGroup({ name: "Modifier group" }); + await setListingGroups(groupLink.id, [group.id]); + const modifier = await modifiersTable.insert({ + calcKind: "percent", + calcValue: 10, + direction: "charge", + name: "Scoped charge", + scope, + }); + await modifierListings.setIds(modifier.id, [listingLink.id]); + await modifierGroups.setIds(modifier.id, [group.id]); + + const intent = bookingIntent( + [ + { e: listingLink.id, p: listingLink.unit_price, q: 1 }, + { e: groupLink.id, p: groupLink.unit_price, q: 1 }, + ], + { modifiers: [{ i: modifier.id, q: 1 }] }, + ); + const snapshot = await loadPaidOrderSnapshot(`stale-${scope}-scope`, intent); + const pricing = priceCheckout({ + address: intent.address, + date: intent.date, + email: intent.email, + items: [listingLink, groupLink].map((listing) => ({ + listingId: listing.id, + name: listing.name, + quantity: 1, + slug: listing.slug, + unitPrice: listing.unit_price, + })), + modifiers: snapshot.modifierSpecs, + name: intent.name, + phone: intent.phone, + special_instructions: intent.special_instructions, + }); + return { groupLink, listingLink, modifier, pricing, snapshot }; +}; + describeWithEnv("paid order snapshot IO", { db: true }, () => { test("loads one paid line in one database call", async () => { const listing = await createTestListing({ unitPrice: 500 }); @@ -73,6 +124,62 @@ describeWithEnv("paid order snapshot IO", { db: true }, () => { expect(snapshot.modifierSpecs).toEqual([]); }); + test("ignores stale group links for a listing-scoped modifier", async () => { + const { listingLink, modifier, pricing, snapshot } = + await loadStaleScopePricing("listings"); + + expect(snapshot.modifierSpecs).toEqual([ + { + id: modifier.id, + kind: "percent", + listingIds: [listingLink.id], + name: "Scoped charge", + quantity: 1, + trigger: "automatic", + value: 10, + }, + ]); + expect(pricing.modifierApplications).toEqual([ + { + amountApplied: 100, + delta: 100, + modifierId: modifier.id, + name: "Scoped charge", + quantity: 1, + scopedSubtotal: 1000, + }, + ]); + expect(pricing.total).toBe(4100); + }); + + test("ignores stale listing links for a group-scoped modifier", async () => { + const { groupLink, modifier, pricing, snapshot } = + await loadStaleScopePricing("groups"); + + expect(snapshot.modifierSpecs).toEqual([ + { + id: modifier.id, + kind: "percent", + listingIds: [groupLink.id], + name: "Scoped charge", + quantity: 1, + trigger: "automatic", + value: 10, + }, + ]); + expect(pricing.modifierApplications).toEqual([ + { + amountApplied: 300, + delta: 300, + modifierId: modifier.id, + name: "Scoped charge", + quantity: 1, + scopedSubtotal: 3000, + }, + ]); + expect(pricing.total).toBe(4300); + }); + test("loads every paid order fact from one consistent snapshot", async () => { const pkg = await createHiddenPackageGroup("Snapshot package"); const parent = await createDailyTestListing({ @@ -112,19 +219,6 @@ describeWithEnv("paid order snapshot IO", { db: true }, () => { await modifierListings.setIds(directModifier.id, [parent.id]); await modifierGroups.setIds(groupModifier.id, [pkg.id]); - const choiceQuestion = await questionsTable.insert({ - displayType: "radio", - text: "Choose one", - }); - const answer = await answersTable.insert({ - questionId: choiceQuestion.id, - sortOrder: 0, - text: "Chosen", - }); - const textQuestion = await questionsTable.insert({ - displayType: "free_text", - text: "Add detail", - }); const email = "snapshot@example.com"; const phone = "+447700900123"; await execute( @@ -168,10 +262,6 @@ describeWithEnv("paid order snapshot IO", { db: true }, () => { [{ e: parent.id, k: "p", p: 800, q: 2, r: pkg.id }], { email, - listingAnswerIds: { [parent.id]: [answer.id] }, - listingTextAnswerIds: { - [parent.id]: [{ q: textQuestion.id, s: 1 }], - }, modifiers: [ { i: directModifier.id, q: 2 }, { i: groupModifier.id, q: 1 }, @@ -228,12 +318,5 @@ describeWithEnv("paid order snapshot IO", { db: true }, () => { value: 10, }, ]); - expect(snapshot.questions.questionIdByAnswerId).toEqual( - new Map([[answer.id, choiceQuestion.id]]), - ); - expect(snapshot.questions.textQuestionIds).toEqual( - new Set([textQuestion.id]), - ); - expect(snapshot.visits).toBe(7); }); }); diff --git a/test/integration/server/api-packages.test.ts b/test/integration/server/api-packages.test.ts index 61bf4c5567..38292a52b8 100644 --- a/test/integration/server/api-packages.test.ts +++ b/test/integration/server/api-packages.test.ts @@ -14,6 +14,7 @@ import { } from "#test-utils/db-helpers/listings.ts"; import { createFlexPackage } from "#test-utils/packages.ts"; import { apiGet } from "#test-utils/parents.ts"; +import { statementSql, wrapDbClient } from "#test-utils/record-queries.ts"; /** POST /api/packages/:slug/book with a minimal valid contact payload merged * with any extra body fields (quantity, date, dayCount, children). */ @@ -458,6 +459,42 @@ describeWithEnv("public API packages", { db: true }, () => { expect(Number(bRow.package_group_id)).toBe(group.id); }); + test("POST keeps a package fact read failure after a free booking", async () => { + const group = await createTestGroup({ + isPackage: true, + name: "Free notify kit", + slug: "free-notify-kit", + }); + const member = await createTestListing({ + groupId: group.id, + maxAttendees: 10, + maxQuantity: 10, + name: "Free notify member", + unitPrice: 0, + webhookUrl: "https://example.com/registration", + }); + await setGroupPackageMembers(group.id, [ + { listingId: member.id, price: null }, + ]); + const restoreDb = wrapDbClient({ + batch: () => {}, + execute: (statement) => + statementSql(statement).includes("groupRecord.hide_package_listings") + ? Promise.reject(new Error("package facts unavailable")) + : null, + }); + + let result: Awaited>; + try { + result = await apiBookPackage(group.slug); + } finally { + restoreDb(); + } + + expect(result.response.status).toBe(200); + expect(await bookingRows(member.id)).toHaveLength(1); + }); + test("POST rejects an explicit quantity of 0 and malformed JSON", async () => { const { group } = await fixedPackage("Zero Kit", "zero-kit"); const zero = await apiBookPackage(group.slug, { quantity: 0 }); diff --git a/test/shared/webhook/budget.test.ts b/test/shared/webhook/budget.test.ts index aeda91f1fe..c685143b6e 100644 --- a/test/shared/webhook/budget.test.ts +++ b/test/shared/webhook/budget.test.ts @@ -9,6 +9,7 @@ import { expect } from "@std/expect"; import { it as test } from "@std/testing/bdd"; import { setGroupPackageMembers } from "#shared/db/groups.ts"; import type { EmailEntry } from "#shared/email.ts"; +import { runWithPendingWork } from "#shared/pending-work.ts"; import type { RegistrationPackageFacts } from "#shared/registration-package-facts.ts"; import { logAndNotifyRegistration, @@ -17,6 +18,7 @@ import { 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 { configureTestEmail } from "#test-utils/email.ts"; import { makeTestEntry as makeEntry } from "#test-utils/factories.ts"; import { stubFetchEachTest } from "#test-utils/fetch-stub.ts"; import { countDatabaseCalls } from "#test-utils/subrequest-budget.ts"; @@ -94,7 +96,7 @@ describeWithEnv("registration notification budget", { db: true }, () => { const eight = await orderEntries("Many", 8); const calls = (entries: EmailEntry[]): Promise => countDatabaseCalls(REGISTRATION_CALL_LIMIT, () => - logAndNotifyRegistration(entries), + runWithPendingWork(() => logAndNotifyRegistration(entries)), ); expect(await calls(eight)).toBe(await calls(one)); @@ -115,7 +117,9 @@ describeWithEnv("registration notification budget", { db: true }, () => { const entries = await packagedEntries("Disabled", 1, ""); expect( - await countDatabaseCalls(1, () => logAndNotifyRegistration(entries)), + await countDatabaseCalls(1, () => + runWithPendingWork(() => logAndNotifyRegistration(entries)), + ), ).toBe(1); }); @@ -123,7 +127,20 @@ describeWithEnv("registration notification budget", { db: true }, () => { const entries = await packagedEntries("Enabled", 1); expect( - await countDatabaseCalls(4, () => logAndNotifyRegistration(entries)), + await countDatabaseCalls(4, () => + runWithPendingWork(() => logAndNotifyRegistration(entries)), + ), + ).toBe(4); + }); + + test("shares one package fact load between webhook and email", async () => { + const entries = await packagedEntries("Shared", 1); + await configureTestEmail(); + + expect( + await countDatabaseCalls(4, () => + runWithPendingWork(() => logAndNotifyRegistration(entries)), + ), ).toBe(4); }); From ae812647f68d304f95071e82474e862d88afa0dc Mon Sep 17 00:00:00 2001 From: Stefan Date: Fri, 7 Aug 2026 05:14:33 +0100 Subject: [PATCH 3/5] Address delayed paid booking review --- src/features/api/payment-processing/create.ts | 6 ++- src/shared/db/processed-payments.ts | 4 +- .../db/questions/attendee-answers/save.ts | 9 ++-- .../api/payment-processing/create.test.ts | 50 ++++++++++++++++--- .../payment-processing/create/answers.test.ts | 2 +- .../payment-processing/index/balance.test.ts | 7 ++- .../payment-processing/index/booking.test.ts | 7 ++- .../api/payment-processing/index/helpers.ts | 6 +-- .../payment-processing/store-refund.test.ts | 1 + .../processed-payments/locking.test.ts | 11 ++-- test/integration/server/api-packages.test.ts | 15 ++++-- test/shared/db/processed-payments.test.ts | 2 +- .../save/stored-ids-behavior.test.ts | 21 +++++--- .../shared/registration-package-facts.test.ts | 36 +++++++++++-- test/shared/webhook/budget.test.ts | 23 ++++++--- test/test-utils/db-poison.ts | 34 +++++++------ test/test-utils/processed-payments.ts | 8 ++- 17 files changed, 168 insertions(+), 74 deletions(-) diff --git a/src/features/api/payment-processing/create.ts b/src/features/api/payment-processing/create.ts index 5b89cd2c89..16db592d04 100644 --- a/src/features/api/payment-processing/create.ts +++ b/src/features/api/payment-processing/create.ts @@ -236,7 +236,11 @@ export const promoCodeActivities = ( ): ActivityToLog[] => { const byId = new Map(applications.map((a) => [a.modifierId, a])); return specs.map((spec) => { - const delta = byId.get(spec.id)!.delta; + const delta = requiredMapValue( + byId, + spec.id, + `Modifier application ${spec.id} was not loaded for promo code activity`, + ).delta; const effect = delta < 0 ? `${formatCurrency(-delta)} off` : `+${formatCurrency(delta)}`; return { diff --git a/src/shared/db/processed-payments.ts b/src/shared/db/processed-payments.ts index 3b7cdc74e9..6a167b30ca 100644 --- a/src/shared/db/processed-payments.ts +++ b/src/shared/db/processed-payments.ts @@ -137,9 +137,7 @@ export const reserveSession = async ( sessionId: string, ): Promise => { const claimedAt = nowIso(); - const staleBefore = new Date( - new Date(claimedAt).getTime() - STALE_RESERVATION_MS, - ).toISOString(); + const staleBefore = isoBefore(STALE_RESERVATION_MS); const [claimResult, lookupResult] = await executeBatchWithResults([ { args: [sessionId, claimedAt, staleBefore], diff --git a/src/shared/db/questions/attendee-answers/save.ts b/src/shared/db/questions/attendee-answers/save.ts index a3e174dd5d..6dbd18ad50 100644 --- a/src/shared/db/questions/attendee-answers/save.ts +++ b/src/shared/db/questions/attendee-answers/save.ts @@ -161,7 +161,6 @@ const existingQuestionIdsTx = async ( tx: TxScope, questionIds: number[], ): Promise> => { - if (questionIds.length === 0) return new Set(); const rows = resultRows<{ id: number }>( await tx.execute( questionsTable.read.pick(["id"]).statement({ id: questionIds }), @@ -217,11 +216,6 @@ const existingQuestionIdsTx = async ( export const saveAttendeeAnswers = async ( answersByAttendee: Map, ): Promise => { - const storedIdsOnly = [...answersByAttendee.values()].every( - (set) => - !Array.isArray(set) && - (set.textAnswers === undefined || set.textAnswers.length === 0), - ); const normalized = new Map( [...answersByAttendee].map(([id, set]) => { const answerSet = normalizeAnswerSet(set); @@ -236,6 +230,9 @@ export const saveAttendeeAnswers = async ( }), ); if (normalized.size === 0) return; + const storedIdsOnly = [...normalized.values()].every( + (set) => set.textAnswers.length === 0, + ); if (storedIdsOnly) { await executeBatch(storedIdAnswerStatements(normalized)); return; diff --git a/test/features/api/payment-processing/create.test.ts b/test/features/api/payment-processing/create.test.ts index 99f04566e1..df38e8690b 100644 --- a/test/features/api/payment-processing/create.test.ts +++ b/test/features/api/payment-processing/create.test.ts @@ -10,7 +10,10 @@ import { } from "#routes/api/payment-processing/create.ts"; import { specForFailure } from "#routes/api/payment-processing/store-refund.ts"; import type { BookingIntent } from "#shared/booking-intent.ts"; -import type { PricedOrder } from "#shared/checkout-pricing.ts"; +import type { + ModifierApplication, + PricedOrder, +} from "#shared/checkout-pricing.ts"; import { encrypt } from "#shared/crypto/encryption.ts"; import { decryptWithOwnerKey } from "#shared/crypto/keys.ts"; import { logActivities } from "#shared/db/activity-log.ts"; @@ -18,8 +21,10 @@ import { attendeesApi } from "#shared/db/attendees/api.ts"; import { queryAll } from "#shared/db/client.ts"; import type { CheckoutIntent, + ModifierSpec, ValidatedPaymentSession, } from "#shared/payments.ts"; +import type { ListingWithCount } from "#shared/types.ts"; import { getTestPrivateKey } from "#test-utils/crypto.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { bookTestAttendee } from "#test-utils/db-helpers/attendees.ts"; @@ -86,6 +91,23 @@ test("builds standalone and package booking slots", () => { }); }); +test("fails when a promo code has no pricing application", () => { + const spec: ModifierSpec = { + id: 1, + kind: "fixed", + listingIds: null, + name: "Missing application", + quantity: 1, + trigger: "code", + value: -100, + }; + const listing: ListingWithCount = testListingWithCount(); + + expect(() => promoCodeActivities([spec], [], listing, 1)).toThrow( + "Modifier application 1 was not loaded for promo code activity", + ); +}); + type PreparationOptions = { chargedUnitAmount?: number; fullSubtotal?: number; @@ -273,20 +295,32 @@ describeWithEnv("payment booking lines", { db: true }, () => { ["money off", "POUNDOFF", -100, "Promo code 'POUNDOFF' used: £1 off"], ] as const) { test(`writes down ${name}`, async () => { - const listing = await createTestListing(); + const listing: ListingWithCount = await createTestListing(); const attendee = await bookTestAttendee( [listing.id], `${code} buyer`, `${code.toLowerCase()}@example.com`, ); + const spec: ModifierSpec = { + id: 1, + kind: "fixed", + listingIds: null, + name: code, + quantity: 1, + trigger: "code", + value: delta, + }; + const application: ModifierApplication = { + amountApplied: Math.abs(delta), + delta, + modifierId: 1, + name: code, + quantity: 1, + scopedSubtotal: listing.unit_price, + }; await logActivities( - promoCodeActivities( - [{ id: 1, name: code } as never], - [{ delta, modifierId: 1 } as never], - listing as never, - attendee.id, - ), + promoCodeActivities([spec], [application], listing, attendee.id), ); const [row] = await queryAll<{ message: string }>( diff --git a/test/features/api/payment-processing/create/answers.test.ts b/test/features/api/payment-processing/create/answers.test.ts index 947524acb6..a4387908d3 100644 --- a/test/features/api/payment-processing/create/answers.test.ts +++ b/test/features/api/payment-processing/create/answers.test.ts @@ -23,7 +23,7 @@ const bookedEntry = async (): Promise => { ); const loaded = await getListingWithCount(listing.id); if (loaded === null) throw new Error(`Listing ${listing.id} was not created`); - return { attendee, listing: loaded } as CreatedEntry; + return { attendee, listing: loaded }; }; const saveAndReadAnswers = async ( diff --git a/test/features/api/payment-processing/index/balance.test.ts b/test/features/api/payment-processing/index/balance.test.ts index dac8804fdd..b288e68fb5 100644 --- a/test/features/api/payment-processing/index/balance.test.ts +++ b/test/features/api/payment-processing/index/balance.test.ts @@ -5,7 +5,10 @@ import { getAttendeeBalanceState } from "#shared/db/attendees/balance.ts"; import { execute } from "#shared/db/client.ts"; import { createReservedAttendee } from "#test-utils/balance.ts"; import { describeWithEnv } from "#test-utils/db.ts"; -import { getProcessedPayment } from "#test-utils/processed-payments.ts"; +import { + expectSessionFailed, + getProcessedPayment, +} from "#test-utils/processed-payments.ts"; import { setupStripe } from "#test-utils/settings.ts"; import { stubRefundPayment } from "#test-utils/webhooks.ts"; import { bookingIntent, trustedPayment } from "./helpers.ts"; @@ -90,6 +93,6 @@ describeWithEnv("payment processing balance outcomes", { db: true }, () => { 1000, ); expect(refund.calls[0]?.args).toEqual([`pi_${id}`]); - expect((await getProcessedPayment(id))?.failure_data).not.toBe(""); + await expectSessionFailed(id); }); }); diff --git a/test/features/api/payment-processing/index/booking.test.ts b/test/features/api/payment-processing/index/booking.test.ts index 1a11fe40c5..fe61437b2e 100644 --- a/test/features/api/payment-processing/index/booking.test.ts +++ b/test/features/api/payment-processing/index/booking.test.ts @@ -8,7 +8,10 @@ import { listingQuestions } from "#shared/db/questions/queries.ts"; import { answersTable, questionsTable } from "#shared/db/questions/tables.ts"; import { setSuppressDebugLogs } from "#shared/log-settings.ts"; import { describeWithEnv } from "#test-utils/db.ts"; -import { getProcessedPayment } from "#test-utils/processed-payments.ts"; +import { + expectSessionFailed, + getProcessedPayment, +} from "#test-utils/processed-payments.ts"; import { setupStripe } from "#test-utils/settings.ts"; import { countDatabaseCalls } from "#test-utils/subrequest-budget.ts"; import { stubRefundPayment } from "#test-utils/webhooks.ts"; @@ -138,7 +141,7 @@ describeWithEnv("payment processing booking outcomes", { db: true }, () => { [listing.id], ), ).toEqual({ listing_id: listing.id, quantity: 0 }); - expect((await getProcessedPayment(id))?.failure_data).not.toBe(""); + await expectSessionFailed(id); }); test("keeps a charge-mismatched booking and records a terminal refund", async () => { diff --git a/test/features/api/payment-processing/index/helpers.ts b/test/features/api/payment-processing/index/helpers.ts index 9ebd7d2cbb..5fdcf1290b 100644 --- a/test/features/api/payment-processing/index/helpers.ts +++ b/test/features/api/payment-processing/index/helpers.ts @@ -11,7 +11,7 @@ import { execute } from "#shared/db/client.ts"; import type { ValidatedPaymentSession } from "#shared/payments.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; import { webhookMeta } from "#test-utils/factories.ts"; -import { getProcessedPayment } from "#test-utils/processed-payments.ts"; +import { expectSessionFailed } from "#test-utils/processed-payments.ts"; export const bookingIntent = ( items: BookingItem[], @@ -103,7 +103,5 @@ export const expectStoredRefund = async ( expect(result.refunded).toBe(true); expect((await getAttendeesRaw(expected.listingId))[0]?.quantity).toBe(0); expect(refund.calls).toHaveLength(1); - expect( - (await getProcessedPayment(expected.sessionId))?.failure_data, - ).not.toBe(""); + await expectSessionFailed(expected.sessionId); }; diff --git a/test/features/api/payment-processing/store-refund.test.ts b/test/features/api/payment-processing/store-refund.test.ts index c322cbf4f5..056ba9443a 100644 --- a/test/features/api/payment-processing/store-refund.test.ts +++ b/test/features/api/payment-processing/store-refund.test.ts @@ -223,6 +223,7 @@ describeWithEnv("keeping a booking we could not honour", { db: true }, () => { ); const rows = await getAttendeesByListingIds([listing.id]); expect(rows.length).toBe(1); + expect(rows[0]?.status_id).toBe(await requirePublicStatusId()); // Kept, but holding nothing — a quantity-1 row here would take a place // from a real buyer. expect(rows[0]?.quantity).toBe(0); diff --git a/test/integration/processed-payments/locking.test.ts b/test/integration/processed-payments/locking.test.ts index a3625da587..3adfbf2c1e 100644 --- a/test/integration/processed-payments/locking.test.ts +++ b/test/integration/processed-payments/locking.test.ts @@ -167,20 +167,19 @@ describeWithEnv("processed-payments / locking", { db: true }, () => { reserveSession(sessionId), ); await allWaiting.promise; - gates[0]!.resolve(); - const winner = await attempts[0]!; - expect(winner.reserved).toBe(true); - for (const gate of gates.slice(1)) gate.resolve(); + for (const gate of gates) gate.resolve(); const results = await Promise.all(attempts); const stored = await getProcessedPayment(sessionId); + if (stored === null) + throw new Error(`Session ${sessionId} was not stored`); expect(results.filter((result) => result.reserved)).toHaveLength(1); const losers = results.filter((result) => !result.reserved); expect(losers).toHaveLength(callerCount - 1); expect(losers.map((result) => result.existing.processed_at)).toEqual( - Array(callerCount - 1).fill(stored!.processed_at), + Array(callerCount - 1).fill(stored.processed_at), ); - expect(stored!.processed_at).not.toBe(staleTime); + expect(stored.processed_at).not.toBe(staleTime); expect(batch.calls).toHaveLength(callerCount); }); }); diff --git a/test/integration/server/api-packages.test.ts b/test/integration/server/api-packages.test.ts index 38292a52b8..9b1df1860d 100644 --- a/test/integration/server/api-packages.test.ts +++ b/test/integration/server/api-packages.test.ts @@ -476,12 +476,18 @@ describeWithEnv("public API packages", { db: true }, () => { await setGroupPackageMembers(group.id, [ { listingId: member.id, price: null }, ]); + let packageFactFailureRan = false; const restoreDb = wrapDbClient({ batch: () => {}, - execute: (statement) => - statementSql(statement).includes("groupRecord.hide_package_listings") - ? Promise.reject(new Error("package facts unavailable")) - : null, + execute: (statement) => { + if ( + !statementSql(statement).includes("groupRecord.hide_package_listings") + ) { + return null; + } + packageFactFailureRan = true; + return Promise.reject(new Error("package facts unavailable")); + }, }); let result: Awaited>; @@ -491,6 +497,7 @@ describeWithEnv("public API packages", { db: true }, () => { restoreDb(); } + expect(packageFactFailureRan).toBe(true); expect(result.response.status).toBe(200); expect(await bookingRows(member.id)).toHaveLength(1); }); diff --git a/test/shared/db/processed-payments.test.ts b/test/shared/db/processed-payments.test.ts index 2224775291..7d9bff3c4a 100644 --- a/test/shared/db/processed-payments.test.ts +++ b/test/shared/db/processed-payments.test.ts @@ -78,7 +78,7 @@ describeWithEnv("db > processed payments", { db: true }, () => { expect(calls).toBe(1); }); - test("retries when stale reservation detected", async () => { + test("claims a stale reservation in one database call", async () => { const oldTimestamp = new Date( nowMs() - STALE_RESERVATION_MS - 1000, ).toISOString(); diff --git a/test/shared/db/questions/attendee-answers/save/stored-ids-behavior.test.ts b/test/shared/db/questions/attendee-answers/save/stored-ids-behavior.test.ts index fb9b87521c..cd22feb76a 100644 --- a/test/shared/db/questions/attendee-answers/save/stored-ids-behavior.test.ts +++ b/test/shared/db/questions/attendee-answers/save/stored-ids-behavior.test.ts @@ -115,22 +115,29 @@ describeWithEnv( ]); }); - test("saves one plain text answer", async () => { - const question = await createQuestion("Plain", { + test("omits a deleted choice while saving plain text", async () => { + const choiceQuestion = await createQuestion("Removed choice"); + const choice = await addAnswer(choiceQuestion.id, 0, "Removed"); + const plainQuestion = await createQuestion("Plain", { displayType: "free_text", }); const attendee = await createAttendee((await createTestListing()).id); + await execute("DELETE FROM answers WHERE id = ?", [choice.id]); + await saveAttendeeAnswers( new Map([ [ attendee.id, { - answerIds: [], - textAnswers: [{ questionId: question.id, text: "One answer" }], + answerIds: [choice.id], + textAnswers: [ + { questionId: plainQuestion.id, text: "Plain answer" }, + ], }, ], ]), ); + expect(await storedRowsFor(attendee.id)).toEqual([ { answer_id: null, string_id: expect.any(Number) }, ]); @@ -204,13 +211,13 @@ describeWithEnv( ).toBe(0); }); - test("keeps the array form on the plaintext transaction path", async () => { + test("saves the array form in one batch call", async () => { const attendee = await createAttendee((await createTestListing()).id); expect( - await countDatabaseCalls(3, () => + await countDatabaseCalls(1, () => saveAttendeeAnswers(new Map([[attendee.id, []]])), ), - ).toBe(3); + ).toBe(1); }); }, ); diff --git a/test/shared/registration-package-facts.test.ts b/test/shared/registration-package-facts.test.ts index 7a06078d6d..d667036113 100644 --- a/test/shared/registration-package-facts.test.ts +++ b/test/shared/registration-package-facts.test.ts @@ -1,10 +1,16 @@ import { expect } from "@std/expect"; import { it as test } from "@std/testing/bdd"; import { setGroupPackageMembers } from "#shared/db/groups.ts"; +import { PRICE_TYPE_GROUP_DAY } from "#shared/db/listing-prices.ts"; import { loadRegistrationPackageFacts } from "#shared/registration-package-facts.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { createHiddenPackageGroup } from "#test-utils/db-helpers/groups.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { + type DbCallHooks, + statementSql, + wrapDbClient, +} from "#test-utils/record-queries.ts"; import { countDatabaseCalls } from "#test-utils/subrequest-budget.ts"; const row = (packageGroupId: number) => ({ @@ -36,11 +42,31 @@ describeWithEnv("loadRegistrationPackageFacts", { db: true }, () => { await setGroupPackageMembers(group.id, [ { dayPrices: { 2: 1400 }, listingId: member.id, price: 750, quantity: 3 }, ]); - const facts = await loadRegistrationPackageFacts([ - row(group.id), - row(group.id), - row(0), - ]); + const packageQueries: Parameters[0][] = []; + const restoreDb = wrapDbClient({ + batch: () => {}, + execute: (statement) => { + if (statementSql(statement).includes("groupListing.group_id IN")) { + packageQueries.push(statement); + } + return null; + }, + }); + let facts: Awaited>; + try { + facts = await loadRegistrationPackageFacts([ + row(group.id), + row(group.id), + row(0), + ]); + } finally { + restoreDb(); + } + expect( + packageQueries.map((statement) => + typeof statement === "string" ? undefined : statement.args, + ), + ).toEqual([[group.id], [PRICE_TYPE_GROUP_DAY, group.id]]); expect(facts.displays).toEqual( new Map([[group.id, { hideListings: true, name: "Weekend bundle" }]]), ); diff --git a/test/shared/webhook/budget.test.ts b/test/shared/webhook/budget.test.ts index c685143b6e..b4af97af8a 100644 --- a/test/shared/webhook/budget.test.ts +++ b/test/shared/webhook/budget.test.ts @@ -15,19 +15,19 @@ import { logAndNotifyRegistration, sendRegistrationWebhooks, } from "#shared/webhook.ts"; +import { stubWebhookFetch } from "#test/shared/webhook/helpers.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 { configureTestEmail } from "#test-utils/email.ts"; import { makeTestEntry as makeEntry } from "#test-utils/factories.ts"; -import { stubFetchEachTest } from "#test-utils/fetch-stub.ts"; import { countDatabaseCalls } from "#test-utils/subrequest-budget.ts"; /** Enough for the fixed reads, far below one read per line or per package. */ const REGISTRATION_CALL_LIMIT = 10; describeWithEnv("registration notification budget", { db: true }, () => { - stubFetchEachTest(() => new Response()); + const fetchSpy = stubWebhookFetch(); /** One booking line per listing, all sharing one webhook URL. */ const orderEntries = async ( @@ -145,8 +145,12 @@ describeWithEnv("registration notification budget", { db: true }, () => { }); test("uses supplied package facts without reading the database", async () => { - const [entry] = await packagedEntries("Supplied", 1); - const groupId = entry!.attendee.package_group_id; + const [storedEntry] = await packagedEntries("Supplied", 1); + const entry = { + ...storedEntry!, + listing: { ...storedEntry!.listing, name: "Supplied package" }, + }; + const groupId = entry.attendee.package_group_id; const facts: RegistrationPackageFacts = { displays: new Map([ [groupId, { hideListings: false, name: "Supplied package" }], @@ -156,17 +160,20 @@ describeWithEnv("registration notification budget", { db: true }, () => { groupId, { dayPriceMap: new Map(), - memberIds: new Set([entry!.listing.id]), - priceMap: new Map([[entry!.listing.id, 500]]), - quantityMap: new Map([[entry!.listing.id, 1]]), + memberIds: new Set([entry.listing.id]), + priceMap: new Map([[entry.listing.id, 500]]), + quantityMap: new Map([[entry.listing.id, 1]]), }, ], ]), }; expect( await countDatabaseCalls(0, () => - sendRegistrationWebhooks([entry!], "GBP", facts), + sendRegistrationWebhooks([entry], "GBP", facts), ), ).toBe(0); + const payload = fetchSpy.firstBody(); + expect(payload.tickets[0]!.listing_name).toBe("Supplied package"); + expect(payload.tickets[0]!.unit_price).toBe(500); }); }); diff --git a/test/test-utils/db-poison.ts b/test/test-utils/db-poison.ts index b128448508..7fe4194fc8 100644 --- a/test/test-utils/db-poison.ts +++ b/test/test-utils/db-poison.ts @@ -1,40 +1,44 @@ import { getDb } from "#shared/db/client.ts"; +type PoisonedWrite = (body: () => Promise) => Promise; +type SqlStatement = { sql: string }; +type RunPoisonedBatch = ( + statements: SqlStatement[], + delegate: () => Promise, +) => Promise; + /** * Reject the first batch or transactional statement whose SQL matches * `matches`, then delegate every subsequent write to the real client. * Stored-ID answers use `db.batch`; plaintext answers use `db.transaction`. */ export const withPoisonedWrite = - (matches: (sql: string) => boolean, message: string) => + (matches: (sql: string) => boolean, message: string): PoisonedWrite => async (body: () => Promise): Promise => { const db = getDb(); const realDbBatch = db.batch.bind(db); const realTransaction = db.transaction.bind(db); let poisoned = true; - db.batch = (( - statements: Array<{ sql: string }>, - mode: "read" | "write", - ) => { + const runPoisonedBatch: RunPoisonedBatch = (statements, delegate) => { const matched = statements.find((statement) => matches(statement.sql)); if (poisoned && matched) { poisoned = false; return Promise.reject(new Error(message)); } - return realDbBatch(statements as never, mode); - }) as typeof db.batch; + return delegate(); + }; + db.batch = ((statements: SqlStatement[], mode: "read" | "write") => + runPoisonedBatch(statements, () => + realDbBatch(statements as never, mode), + )) as typeof db.batch; db.transaction = (async (mode: "read" | "write" = "write") => { const tx = await realTransaction(mode); const realBatch = tx.batch.bind(tx); const realExecute = tx.execute.bind(tx); - tx.batch = ((statements: Array<{ sql: string }>) => { - const matched = statements.find((statement) => matches(statement.sql)); - if (poisoned && matched) { - poisoned = false; - return Promise.reject(new Error(message)); - } - return realBatch(statements as never); - }) as typeof tx.batch; + tx.batch = ((statements: SqlStatement[]) => + runPoisonedBatch(statements, () => + realBatch(statements as never), + )) as typeof tx.batch; tx.execute = ((stmt: { sql: string }) => { if (poisoned && matches(stmt.sql)) { poisoned = false; diff --git a/test/test-utils/processed-payments.ts b/test/test-utils/processed-payments.ts index a858d73d66..9f73dc6e57 100644 --- a/test/test-utils/processed-payments.ts +++ b/test/test-utils/processed-payments.ts @@ -1,3 +1,4 @@ +import { assert } from "@std/assert"; import { expect } from "@std/expect"; import { executeBatch, queryOne } from "#shared/db/client.ts"; import { batchFinalizeStatements } from "#shared/db/payment-finalize.ts"; @@ -20,7 +21,12 @@ export const expectSessionFailed = async (sessionId: string): Promise => { const record = await getProcessedPayment(sessionId); if (!record) throw new Error(`Processed payment ${sessionId} was not stored`); expect(record.attendee_id).toBeNull(); - expect(record.failure_data).not.toBe(""); + const failureData: unknown = record.failure_data; + assert( + typeof failureData === "string", + `Processed payment ${sessionId} failure data was not a string`, + ); + expect(failureData.length).toBeGreaterThan(0); }; /** Finalize a reserved payment through the same guarded batch as checkout. */ From bba8f4da74bf4c4d4f45f970f37396706c780906 Mon Sep 17 00:00:00 2001 From: Stefan Date: Fri, 7 Aug 2026 06:48:40 +0100 Subject: [PATCH 4/5] Name positional batch results --- AGENTS.md | 1413 +++++++++++------ .../api/payment-processing/snapshot/io.ts | 84 +- 2 files changed, 1005 insertions(+), 492 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b3ab4ada10..5029477644 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,18 +17,21 @@ For one command, run it through the shell instead of entering it: nix develop -c deno task precommit ``` -Do not use `mise` or a host-installed `deno` directly when Nix is available. -All `deno ...` commands in this file assume you are already inside -`nix develop`; non-interactive agents should prefix them with -`nix develop -c`. On systems without Nix, `./setup.sh` remains the fallback. +Do not use `mise` or a host-installed `deno` directly when Nix is available. All +`deno ...` commands in this file assume you are already inside `nix develop`; +non-interactive agents should prefix them with `nix develop -c`. On systems +without Nix, `./setup.sh` remains the fallback. ## Runtime Environment - **Production**: Bunny Edge Scripting (Deno-based runtime on Bunny CDN) -- **Development/Testing**: Deno (for `deno task test`, `deno task start`, `deno coverage`, package management) -- **Build**: `esbuild` with `platform: "browser"` bundles to a single edge-compatible file +- **Development/Testing**: Deno (for `deno task test`, `deno task start`, + `deno coverage`, package management) +- **Build**: `esbuild` with `platform: "browser"` bundles to a single + edge-compatible file -Code must work in both environments. The edge runtime is Deno-based, so development with Deno ensures parity. +Code must work in both environments. The edge runtime is Deno-based, so +development with Deno ensures parity. ## Deno Version @@ -60,93 +63,363 @@ GOBIN="$PWD/.bin" go install github.com/stripe/stripe-mock@v0.188.0 ``` Pin the same version the harness expects (`STRIPE_MOCK_VERSION` in -`scripts/stripe-mock/install.ts`). Once `.bin/stripe-mock` exists the harness uses it -as-is and skips the download, so `deno task test`, `deno task test:files`, and -`--harness` mutation runs all work offline from GitHub. +`scripts/stripe-mock/install.ts`). Once `.bin/stripe-mock` exists the harness +uses it as-is and skips the download, so `deno task test`, +`deno task test:files`, and `--harness` mutation runs all work offline from +GitHub. ## Preferences -- **Use FP methods**: Prefer curried functional utilities from `#fp` over imperative loops -- **Plain language for functional code**: Keep the functional style, but name helpers and write comments in simple domain words. Avoid CS jargon in code (`predicate`, `cohort`, `projection`, `fold`, `atom`, etc.) when a plain phrase works. A helper should explain itself like "Keeps only children that can still be booked for this ticket." Write for someone without a CS degree; a ten-year-old should understand the comment and the method name, even if the implementation uses `map`, `filter`, or `reduce`. -- **Comments describe current code**: Do not leave comments that compare current code with an old implementation or explain what the code replaced. They do not help someone understand the code as it works now. Git history preserves the old code if anyone needs it. Delete stale historical comments when you find them. -- **Comments are short, because the code says the rest**: Well-named methods and values already say *what* the code does, so a comment only needs to add what the reader cannot see — a *why*, a constraint, a surprise. One or two lines is the norm; a paragraph above a few lines of code is a smell, and usually a sign the code should be clearer instead. Never re-narrate the lines below in prose, never restate a name (`/** Save the listing. */` above `saveListing`), and never explain a language feature. If a comment is growing to explain a tangle, fix the tangle: rename the thing, or pull the confusing part into a named helper whose name carries the explanation. The bar to clear is "would a competent reader be surprised or misled without this?" — if not, delete it. This applies to prose in commit messages and PR descriptions too: say what changed and why, then stop. -- **Zero code duplication**: jscpd runs at a non-negotiable 0% threshold. Fix duplication with a helper or currying — see [Code Duplication](#code-duplication). `jscpd:ignore` is reserved for import blocks, essentially nothing else. The warning is a *positive signal* pointing at a real merge to make — never work around it by changing a structure so the matcher stops matching (config objects, namespace imports, reordering, lifting to a named const, all to dodge the token match) while leaving two parallel implementations standing. Every merge is warranted; the merges are the whole goal. After each dedup, zoom out and fold the new helper into other call sites and older siblings it now subsumes. -- **100% test coverage**: All code must have complete test coverage - run `deno coverage` to find uncovered lines/branches. Coverage must also be *deterministic*: a line or branch reached only through a spawned subprocess or e2e test (e.g. the `cli/` scripts, exercised by `test/e2e/cli-api.test.ts` via `deno run`) is covered non-deterministically — the child process's coverage is collected through `DENO_COVERAGE_DIR` and is environment-sensitive, so it can pass CI on one run and fail on the next. Give any branch that must stay covered a direct in-process unit test, not just incidental subprocess coverage. -- **Hardest first, no need to ask**: When the only open question is *what order to build several things in*, the answer is always "do the more difficult one first" — just proceed, don't ask. -- **Always the complete version**: When choosing between a result that is less accurate/complete and the full, correct version, always do the complete version — even if it means changing more files than originally estimated. Our aim is always to create the most perfect software; don't ask permission to do it properly. -- **Feature-complete by default**: When the open question is "should we take this feature all the way to complete?", the answer is yes — build the whole thing, not a partial slice, and don't ask permission to finish it. The *only* reason to stop short is when finishing would genuinely complicate the codebase: more branching, more special cases, a worse overall shape. That trade-off — completeness against simplicity — is the sole deciding factor; never the effort or the number of files touched. If the two genuinely pull apart, say so explicitly and let the codebase's health break the tie. -- **Unify systems — the answer is yes**: When the question is "should we unify these two systems / collapse these two paths into one?", the answer is yes. A core aim is to *reduce the lines of code* needed to accomplish the same thing: the codebase has a finite size it must stay within, so it is critical we reuse and refactor toward one shared mechanism rather than maintain two parallel ones. Two things doing almost the same job are a standing invitation to find the single abstraction that does both — take it. The recent `serve-app.ts` extraction is the reference: it collapsed the Bunny edge and Deno Deploy entry points onto one shared production request handler, leaving `edge.ts`/`deploy.ts` as thin platform wrappers. -- **No alias exports — expose the shared mechanism itself**: Never export a name that is just another name for an existing method (`export const getChildIds = byParent.getIds`). Expose the underlying helper/object directly and let callers use it (`listingChildren.getIds(parentId)`). Exposing our internals is a feature: it encourages us to make them universal, understandable, and neat, while an alias layer hides the one shared mechanism behind per-module vocabulary and gives the same behavior two names. A thin wrapper that *adds* something — a default, a transformation, a guard — is not an alias and is fine. -- **No internal compatibility layers**: We own every internal caller. When replacing an internal API, migrate every caller in the same change and delete the old surface instead of keeping wrappers, aliases, re-exports, or "compatibility" shims. Keep adapters only at true external boundaries (provider APIs, serialized data/import formats, browser/platform contracts) or for an explicitly staged data migration with a named removal path. -- **Remove dead code — always the answer**: When code has no production caller — an unused export, an unreferenced helper, a guard/page whose only consumer is itself unused, an unreachable branch — delete it. Removal is *always* the right call; never keep it "for symmetry" or "for future use" (add it back when the future arrives, from git history), and never paper over it with a test-only import or a lint/usage-check exemption. If a check surfaces an export that's used only by tests, that's a signal the export is dead, not a reason to allow-list it: remove the export (and its now-pointless test). A symmetric-but-unused API is still dead code. The reference: `agentPage`/`requireAgentOr` were an agent-only page+guard pair with no route wiring (agents are gated via `deliveryPage`/`requireDeliveryOr`), so both were deleted rather than exempted. -- **Keep files under ~400 lines**: When refactoring a file, aim to keep it under 400 lines — and if hitting that target means splitting one file into several, so be it: a new file is cheaper than an overloaded one. When you end up with a handful of files all about the same thing, group them in a folder and give them shorter names that don't repeat the folder's name (`ledger/project.ts`, not `ledger/ledger-project.ts` — see the `src/shared/ledger/` and `src/shared/db/attendees/` examples in [Modularised](#modularised)). While you're at it, use the split as a chance to separate pure from non-pure code — push the data-in/data-out logic into its own file and keep the IO in a thin shell (see [Pure, functional](#pure-functional)). **The same 400-line limit applies to test files**, and matters just as much: smaller, more specific test files let us run mutation tests far faster, because a source file's mutants only need to run against the narrow test file that covers it, not one giant suite. Biome enforces a hard 1,000-line ceiling as a lint error (`nursery.noExcessiveLinesPerFile` in `biome.json`); it applies to every file, with no exceptions — never add an override to let one file past it. (Expect a known side effect when splitting: jscpd cannot fully scan very large files, so a split routinely *surfaces* duplication that was silently passing inside the monolith — budget for extracting helpers, not just moving tests.) -- **Good citizen — fix what you spot**: If you notice a bug, a coverage gap, or a flaky/fragile test while working — even in code you were not asked to touch and did not write — fix it in passing rather than stepping around it. A green build you helped produce is your responsibility too. -- **Every bug fix ships with a regression test**: Never fix a bug without also adding a test that fails before the fix and passes after it. The test must exercise the real bug — reproduce the exact condition that was broken so it would have caught the original defect — not merely touch the changed lines for coverage. Write the failing test first, confirm it fails for the right reason, then apply the fix and watch it go green. This locks the bug out for good and proves the fix actually addresses it. -- **Offensive, not defensive, programming**: Fail loudly and immediately instead of tolerating bad states — never suppress, default away, or paper over an error. See [Offensive Programming](#offensive-programming--never-suppress-errors) for the full rules. -- **Trust application invariants**: Do not design normal code paths around database states the application says are impossible. If an impossible state is observed, raise it as an error and repair the data explicitly rather than silently accepting or normalising it. -- **Don't defend against the impossible**: Do not add fallbacks, placeholders, or `try/catch`es for failures that can only happen when a foundational system is already broken — the encryption/data key won't decrypt, the database has vanished, a core invariant the app guarantees is violated. You will never reach such a branch without the whole app already being down: you cannot render a page whose data won't decrypt, because the *same* key protects the attendee's own PII, so the request dies long before your guard runs. Such a guard only hides a system-wide failure behind an untestable, never-exercised branch (and a coverage gap). Let it throw, loudly. Reserve resilience for failures that genuinely occur in normal operation — a flaky network call, a provider timeout, a refund that already settled, a write that lost a race. Be confident in our own systems. -- **Trust request key setup**: If the site is processing a request, startup has already validated `DB_ENCRYPTION_KEY`. If it is processing any route other than setup, the atomic setup ceremony has already created the owner and public key. Do not spend CPU cycles or source bytes checking that either key exists in request code, and do not test states that can only be made by corrupting this setup. -- **One path for one-or-many — a single item is an array of one**: Don't write a separate "single" code path beside a "multiple" one (no `getThing(id)` next to `getThings(ids)`, no `length === 1` branch that renders/loads/books differently from the N-item case). Model the operation over a collection once and call it with an array of one when there's a single item; derive the singular answer from the array result (`(await getHiddenPackageMemberIds([id])).size > 0`). A thin singular wrapper that *delegates* to the array implementation is fine (it's still one path); two parallel implementations that can drift are not. The multi-group membership refactor is the reference: a listing's groups are always an array, never a special-cased single `group_id`. This keeps behaviour identical for 1 and N, and kills the class of bug where the single case is fixed but the batch case isn't (or vice-versa). -- **Schema over organic structure**: Prefer a declarative schema plus functional composition (map/filter/`compact` over data) to hand-nested or imperative construction — *even for content that looks organic*, like help/FAQ pages, navigation, form layouts, or report sections. Model the thing as data (a typed list of sections/entries/fields), render it with one shared function, and let the types make invalid arrangements unrepresentable. The admin guide (`src/ui/templates/admin/guide/`) is the reference example: each topic exports a `GuideSection[]`, `renderGuideSections` turns it into markup, and because a section's `entries` can never be a section, a sub-section can't be mis-nested mid-list and drag unrelated questions under the wrong heading. When you catch yourself authoring repetitive nested JSX/markup by hand, lift it into a schema first. -- **Shared interfaces over branch-per-case**: Prefer one tightly-defined shared interface that every case implements over a chain of "if this kind of situation, do this; else that". Branch-per-case does not grow naturally — each new case is another arm bolted onto every dispatcher, and a forgotten arm fails silently rather than loudly. Model the cases as data instead: a typed union plus an *exhaustive* `Record` keyed by it (so a new case is a compile error in every dispatcher), or per-entry predicates/handlers that carry their own rules, folded over uniformly. Schema-tizing this way is always a good end — it turns invalid arrangements into unrepresentable ones and makes the system additive to extend. The recent listing-defaults work is the reference: its `kind` dispatch was rewritten from parallel if/ternary chains (each silently falling through to a default arm) into exhaustive `Record` maps, and `resolveListingDefaults` became a plain fold over `LISTING_DEFAULT_FIELDS` whose per-field `appliesTo` predicates replaced the inline `if logistics-off / if renewal-tier` special-casing — the invariants now live with the fields they guard. -- **Malleable software**: Prefer being up front with operators about the underlying data structure over hiding it. Where it's safe, expose stored records directly and give the operator a page to view and edit them — including aggregated/derived numbers — rather than treating the DB as a black box. The per-contact record editor at `/admin/history/:hmac` (raw booking/message counts plus the private note, keyed by the contact's HMAC) is the reference example. Repairing data should be a first-class operator action, not a manual DB surgery. -- **Never render a dead or forbidden link**: Don't emit a link the viewer can't follow — one whose target would 404, or whose page the current user's admin level can't open. A rendered link is a promise that it works, so gate it on the same condition the target enforces; when that condition fails, show plain text or an indicator in its place rather than a link that breaks on click. The no-quantity attendee's ticket cell is the reference: a quantity-0-only attendee has no live `/t` page (it 404s), so admin views render a "No quantity" indicator instead of the `/t` link. This holds for permission-gated links too: an action a role can't reach must not be linked for that role. Mind the blind spot — a link to a restricted page still works when the page is viewed (or tested) as a high-privilege user, so the dead link the lower-privilege roles see goes unnoticed. Gate the link on the same permission the target enforces, and when testing visibility, render the page as each role rather than only the most-privileged one. -- **Operator decides genuine conflicts — a required choice, never a silent default**: When an action hits a conflict the system cannot unambiguously resolve (e.g. an attendee merge where both records booked the same listing, or where each side carries a real payment), do NOT auto-pick a resolution and quietly proceed. Surface the conflict and make the operator choose explicitly via a **required** field — the request fails closed until they decide. Silently moving money, voiding a leg, or keeping one side by default hides a real decision behind a guess; an explicit operator choice keeps the irreversible call — especially anything that touches the money ledger — with the human who can see the context. -- **Select only needed columns**: Avoid `SELECT *` and broad "load every row" helpers — query the specific columns a caller actually uses. See [Database Queries](#database-queries). -- **SQL table aliases**: Alias tables with the full singular word using `AS`, not a single letter — write `FROM listings AS listing`, never `FROM listings e` (the `e` is a leftover from when listings were called "events"). When one query references the same table more than once (e.g. correlated subqueries that compare a row against its group), give each occurrence a descriptive word alias — `listing` for the row being checked, `groupListing` for sibling rows in its group. -- **Annotate return types on exported functions, and keep types easy to compile**: Give every exported/public function an explicit return type instead of leaning on inference. A named annotation is more compact for the checker to record than a re-inferred anonymous type, and it fails loudly at the definition when the body drifts from the contract rather than leaking a surprising shape to callers. This is the [TypeScript performance guidance](https://github.com/microsoft/TypeScript/wiki/Performance) applied to our checker (`deno check` is the same compiler underneath): prefer an `interface`/base type that others extend over a large `type X = A & B & C` intersection or a wide bare union (comparing many members is quadratic), and give a complex conditional type its own name so the compiler caches it instead of re-deriving it at every use. A small two-way `A & B` merge, or a `v.variant`/discriminated union built from the schema-first patterns above, is already the right shape — this is about not hand-rolling sprawling anonymous ones. (The wiki's `tsconfig`/project-reference/tracing advice does not apply: we type-check with `deno check`, not `tsc`.) -- **Never lose work — commit WIP even if broken**: Uncommitted changes are lost if the working environment is reclaimed (it has happened). If you have non-trivial work in progress and are about to pause, hand off, delegate to a background agent, or end a turn with a dirty tree, **commit and push it** rather than leaving it uncommitted. A known-broken checkpoint is fine and expected — mark it unmistakably in the commit message (e.g. `WIP: — NOT GREEN, `) so it is never mistaken for finished work, and follow up with a green commit. Do not hold a commit back purely because the tree does not yet build or pass; losing the work is worse. -- **Answer every PR review thread you address**: When a pull request review leaves comments — from an automated reviewer (e.g. Codex) or a human — reply to **each** thread directly with a concise, proper note: how it was resolved (the mechanism + the regression test that locks it), or why it is not actionable/incorrect. Do this even when the commit message already explains the change — an open thread reads as unaddressed, so close the loop on the thread itself. This is a deliberate exception to general GitHub-comment frugality: resolution replies on review threads are expected, not noise. Keep each reply tight (a few sentences), and reference the fixing commit. **If a suggestion is valid but outside the current job's scope**, do not silently drop it — record it in `TODO.md` with enough context for a future person to pick it up without re-reading the PR (the file/path it concerns, what the reviewer proposed, why it's genuinely out of scope here, and a starting point), then reply on the thread pointing to the TODO entry. Scope is a real boundary, not an excuse to lose good ideas. -- **Finish by rewriting the PR name and description**: Once a feature is done, revisit its pull request and update the name and description to match what was actually built. A PR often starts life with a WIP or work-in-flight title; the finished PR should be thorough but written in simple, concise, understandable, non-technical language — the same plain language we want in our code, comments, and method names. Someone without a CS degree should be able to read the PR and know what changed, why, and what it means for the people using the site. -- **Final check**: Run `nix develop -c deno task precommit` before finishing any job with code or documentation changes. It is the only check that mirrors CI exactly — it typechecks the **test** files too, so `deno check ` plus `test:files` is not a substitute (a test-only type error will pass locally and still break CI). +- **Use FP methods**: Prefer curried functional utilities from `#fp` over + imperative loops +- **Plain language for functional code**: Keep the functional style, but name + helpers and write comments in simple domain words. Avoid CS jargon in code + (`predicate`, `cohort`, `projection`, `fold`, `atom`, etc.) when a plain + phrase works. A helper should explain itself like "Keeps only children that + can still be booked for this ticket." Write for someone without a CS degree; a + ten-year-old should understand the comment and the method name, even if the + implementation uses `map`, `filter`, or `reduce`. +- **Comments describe current code**: Do not leave comments that compare current + code with an old implementation or explain what the code replaced. They do not + help someone understand the code as it works now. Git history preserves the + old code if anyone needs it. Delete stale historical comments when you find + them. +- **Comments are short, because the code says the rest**: Well-named methods and + values already say _what_ the code does, so a comment only needs to add what + the reader cannot see — a _why_, a constraint, a surprise. One or two lines is + the norm; a paragraph above a few lines of code is a smell, and usually a sign + the code should be clearer instead. Never re-narrate the lines below in prose, + never restate a name (`/** Save the listing. */` above `saveListing`), and + never explain a language feature. If a comment is growing to explain a tangle, + fix the tangle: rename the thing, or pull the confusing part into a named + helper whose name carries the explanation. The bar to clear is "would a + competent reader be surprised or misled without this?" — if not, delete it. + This applies to prose in commit messages and PR descriptions too: say what + changed and why, then stop. +- **Zero code duplication**: jscpd runs at a non-negotiable 0% threshold. Fix + duplication with a helper or currying — see + [Code Duplication](#code-duplication). `jscpd:ignore` is reserved for import + blocks, essentially nothing else. The warning is a _positive signal_ pointing + at a real merge to make — never work around it by changing a structure so the + matcher stops matching (config objects, namespace imports, reordering, lifting + to a named const, all to dodge the token match) while leaving two parallel + implementations standing. Every merge is warranted; the merges are the whole + goal. After each dedup, zoom out and fold the new helper into other call sites + and older siblings it now subsumes. +- **100% test coverage**: All code must have complete test coverage - run + `deno coverage` to find uncovered lines/branches. Coverage must also be + _deterministic_: a line or branch reached only through a spawned subprocess or + e2e test (e.g. the `cli/` scripts, exercised by `test/e2e/cli-api.test.ts` via + `deno run`) is covered non-deterministically — the child process's coverage is + collected through `DENO_COVERAGE_DIR` and is environment-sensitive, so it can + pass CI on one run and fail on the next. Give any branch that must stay + covered a direct in-process unit test, not just incidental subprocess + coverage. +- **Hardest first, no need to ask**: When the only open question is _what order + to build several things in_, the answer is always "do the more difficult one + first" — just proceed, don't ask. +- **Always the complete version**: When choosing between a result that is less + accurate/complete and the full, correct version, always do the complete + version — even if it means changing more files than originally estimated. Our + aim is always to create the most perfect software; don't ask permission to do + it properly. +- **Feature-complete by default**: When the open question is "should we take + this feature all the way to complete?", the answer is yes — build the whole + thing, not a partial slice, and don't ask permission to finish it. The _only_ + reason to stop short is when finishing would genuinely complicate the + codebase: more branching, more special cases, a worse overall shape. That + trade-off — completeness against simplicity — is the sole deciding factor; + never the effort or the number of files touched. If the two genuinely pull + apart, say so explicitly and let the codebase's health break the tie. +- **Unify systems — the answer is yes**: When the question is "should we unify + these two systems / collapse these two paths into one?", the answer is yes. A + core aim is to _reduce the lines of code_ needed to accomplish the same thing: + the codebase has a finite size it must stay within, so it is critical we reuse + and refactor toward one shared mechanism rather than maintain two parallel + ones. Two things doing almost the same job are a standing invitation to find + the single abstraction that does both — take it. The recent `serve-app.ts` + extraction is the reference: it collapsed the Bunny edge and Deno Deploy entry + points onto one shared production request handler, leaving + `edge.ts`/`deploy.ts` as thin platform wrappers. +- **No alias exports — expose the shared mechanism itself**: Never export a name + that is just another name for an existing method + (`export const getChildIds = byParent.getIds`). Expose the underlying + helper/object directly and let callers use it + (`listingChildren.getIds(parentId)`). Exposing our internals is a feature: it + encourages us to make them universal, understandable, and neat, while an alias + layer hides the one shared mechanism behind per-module vocabulary and gives + the same behavior two names. A thin wrapper that _adds_ something — a default, + a transformation, a guard — is not an alias and is fine. +- **No internal compatibility layers**: We own every internal caller. When + replacing an internal API, migrate every caller in the same change and delete + the old surface instead of keeping wrappers, aliases, re-exports, or + "compatibility" shims. Keep adapters only at true external boundaries + (provider APIs, serialized data/import formats, browser/platform contracts) or + for an explicitly staged data migration with a named removal path. +- **Remove dead code — always the answer**: When code has no production caller — + an unused export, an unreferenced helper, a guard/page whose only consumer is + itself unused, an unreachable branch — delete it. Removal is _always_ the + right call; never keep it "for symmetry" or "for future use" (add it back when + the future arrives, from git history), and never paper over it with a + test-only import or a lint/usage-check exemption. If a check surfaces an + export that's used only by tests, that's a signal the export is dead, not a + reason to allow-list it: remove the export (and its now-pointless test). A + symmetric-but-unused API is still dead code. The reference: + `agentPage`/`requireAgentOr` were an agent-only page+guard pair with no route + wiring (agents are gated via `deliveryPage`/`requireDeliveryOr`), so both were + deleted rather than exempted. +- **Keep files under ~400 lines**: When refactoring a file, aim to keep it under + 400 lines — and if hitting that target means splitting one file into several, + so be it: a new file is cheaper than an overloaded one. When you end up with a + handful of files all about the same thing, group them in a folder and give + them shorter names that don't repeat the folder's name (`ledger/project.ts`, + not `ledger/ledger-project.ts` — see the `src/shared/ledger/` and + `src/shared/db/attendees/` examples in [Modularised](#modularised)). While + you're at it, use the split as a chance to separate pure from non-pure code — + push the data-in/data-out logic into its own file and keep the IO in a thin + shell (see [Pure, functional](#pure-functional)). **The same 400-line limit + applies to test files**, and matters just as much: smaller, more specific test + files let us run mutation tests far faster, because a source file's mutants + only need to run against the narrow test file that covers it, not one giant + suite. Biome enforces a hard 1,000-line ceiling as a lint error + (`nursery.noExcessiveLinesPerFile` in `biome.json`); it applies to every file, + with no exceptions — never add an override to let one file past it. (Expect a + known side effect when splitting: jscpd cannot fully scan very large files, so + a split routinely _surfaces_ duplication that was silently passing inside the + monolith — budget for extracting helpers, not just moving tests.) +- **Good citizen — fix what you spot**: If you notice a bug, a coverage gap, or + a flaky/fragile test while working — even in code you were not asked to touch + and did not write — fix it in passing rather than stepping around it. A green + build you helped produce is your responsibility too. +- **Every bug fix ships with a regression test**: Never fix a bug without also + adding a test that fails before the fix and passes after it. The test must + exercise the real bug — reproduce the exact condition that was broken so it + would have caught the original defect — not merely touch the changed lines for + coverage. Write the failing test first, confirm it fails for the right reason, + then apply the fix and watch it go green. This locks the bug out for good and + proves the fix actually addresses it. +- **Offensive, not defensive, programming**: Fail loudly and immediately instead + of tolerating bad states — never suppress, default away, or paper over an + error. See + [Offensive Programming](#offensive-programming--never-suppress-errors) for the + full rules. +- **Trust application invariants**: Do not design normal code paths around + database states the application says are impossible. If an impossible state is + observed, raise it as an error and repair the data explicitly rather than + silently accepting or normalising it. +- **Don't defend against the impossible**: Do not add fallbacks, placeholders, + or `try/catch`es for failures that can only happen when a foundational system + is already broken — the encryption/data key won't decrypt, the database has + vanished, a core invariant the app guarantees is violated. You will never + reach such a branch without the whole app already being down: you cannot + render a page whose data won't decrypt, because the _same_ key protects the + attendee's own PII, so the request dies long before your guard runs. Such a + guard only hides a system-wide failure behind an untestable, never-exercised + branch (and a coverage gap). Let it throw, loudly. Reserve resilience for + failures that genuinely occur in normal operation — a flaky network call, a + provider timeout, a refund that already settled, a write that lost a race. Be + confident in our own systems. +- **Trust request key setup**: If the site is processing a request, startup has + already validated `DB_ENCRYPTION_KEY`. If it is processing any route other + than setup, the atomic setup ceremony has already created the owner and public + key. Do not spend CPU cycles or source bytes checking that either key exists + in request code, and do not test states that can only be made by corrupting + this setup. +- **One path for one-or-many — a single item is an array of one**: Don't write a + separate "single" code path beside a "multiple" one (no `getThing(id)` next to + `getThings(ids)`, no `length === 1` branch that renders/loads/books + differently from the N-item case). Model the operation over a collection once + and call it with an array of one when there's a single item; derive the + singular answer from the array result + (`(await getHiddenPackageMemberIds([id])).size > 0`). A thin singular wrapper + that _delegates_ to the array implementation is fine (it's still one path); + two parallel implementations that can drift are not. The multi-group + membership refactor is the reference: a listing's groups are always an array, + never a special-cased single `group_id`. This keeps behaviour identical for 1 + and N, and kills the class of bug where the single case is fixed but the batch + case isn't (or vice-versa). +- **Schema over organic structure**: Prefer a declarative schema plus functional + composition (map/filter/`compact` over data) to hand-nested or imperative + construction — _even for content that looks organic_, like help/FAQ pages, + navigation, form layouts, or report sections. Model the thing as data (a typed + list of sections/entries/fields), render it with one shared function, and let + the types make invalid arrangements unrepresentable. The admin guide + (`src/ui/templates/admin/guide/`) is the reference example: each topic exports + a `GuideSection[]`, `renderGuideSections` turns it into markup, and because a + section's `entries` can never be a section, a sub-section can't be mis-nested + mid-list and drag unrelated questions under the wrong heading. When you catch + yourself authoring repetitive nested JSX/markup by hand, lift it into a schema + first. +- **Shared interfaces over branch-per-case**: Prefer one tightly-defined shared + interface that every case implements over a chain of "if this kind of + situation, do this; else that". Branch-per-case does not grow naturally — each + new case is another arm bolted onto every dispatcher, and a forgotten arm + fails silently rather than loudly. Model the cases as data instead: a typed + union plus an _exhaustive_ `Record` keyed by it (so a new case is a compile + error in every dispatcher), or per-entry predicates/handlers that carry their + own rules, folded over uniformly. Schema-tizing this way is always a good end + — it turns invalid arrangements into unrepresentable ones and makes the system + additive to extend. The recent listing-defaults work is the reference: its + `kind` dispatch was rewritten from parallel if/ternary chains (each silently + falling through to a default arm) into exhaustive `Record` maps, and + `resolveListingDefaults` became a plain fold over `LISTING_DEFAULT_FIELDS` + whose per-field `appliesTo` predicates replaced the inline + `if logistics-off / if renewal-tier` special-casing — the invariants now live + with the fields they guard. +- **Malleable software**: Prefer being up front with operators about the + underlying data structure over hiding it. Where it's safe, expose stored + records directly and give the operator a page to view and edit them — + including aggregated/derived numbers — rather than treating the DB as a black + box. The per-contact record editor at `/admin/history/:hmac` (raw + booking/message counts plus the private note, keyed by the contact's HMAC) is + the reference example. Repairing data should be a first-class operator action, + not a manual DB surgery. +- **Never render a dead or forbidden link**: Don't emit a link the viewer can't + follow — one whose target would 404, or whose page the current user's admin + level can't open. A rendered link is a promise that it works, so gate it on + the same condition the target enforces; when that condition fails, show plain + text or an indicator in its place rather than a link that breaks on click. The + no-quantity attendee's ticket cell is the reference: a quantity-0-only + attendee has no live `/t` page (it 404s), so admin views render a "No + quantity" indicator instead of the `/t` link. This holds for permission-gated + links too: an action a role can't reach must not be linked for that role. Mind + the blind spot — a link to a restricted page still works when the page is + viewed (or tested) as a high-privilege user, so the dead link the + lower-privilege roles see goes unnoticed. Gate the link on the same permission + the target enforces, and when testing visibility, render the page as each role + rather than only the most-privileged one. +- **Operator decides genuine conflicts — a required choice, never a silent + default**: When an action hits a conflict the system cannot unambiguously + resolve (e.g. an attendee merge where both records booked the same listing, or + where each side carries a real payment), do NOT auto-pick a resolution and + quietly proceed. Surface the conflict and make the operator choose explicitly + via a **required** field — the request fails closed until they decide. + Silently moving money, voiding a leg, or keeping one side by default hides a + real decision behind a guess; an explicit operator choice keeps the + irreversible call — especially anything that touches the money ledger — with + the human who can see the context. +- **Select only needed columns**: Avoid `SELECT *` and broad "load every row" + helpers — query the specific columns a caller actually uses. See + [Database Queries](#database-queries). +- **SQL table aliases**: Alias tables with the full singular word using `AS`, + not a single letter — write `FROM listings AS listing`, never + `FROM listings e` (the `e` is a leftover from when listings were called + "events"). When one query references the same table more than once (e.g. + correlated subqueries that compare a row against its group), give each + occurrence a descriptive word alias — `listing` for the row being checked, + `groupListing` for sibling rows in its group. +- **Name positional results at the boundary**: When a library returns an ordered + array of different results, destructure it into domain names as soon as it + enters our code. Keep the unavoidable ordering beside the call that creates + it; do not make readers trace `results[2]` or `rows[7]` through later mapping + code. If the number of results is not guaranteed, validate it at that boundary + before naming the values. +- **Use types where they remove noise**: Replace repeated inline object shapes + with a named type or interface when that makes a boundary contract clearer, + removes repeated field declarations, or lets related shapes share a small + base. Reuse or extend an existing type when it already describes the facts. Do + not create a new name for a one-off shape that is already easier to read + inline, and do not add aliases that give the same concept a second vocabulary. +- **Annotate return types on exported functions, and keep types easy to + compile**: Give every exported/public function an explicit return type instead + of leaning on inference. A named annotation is more compact for the checker to + record than a re-inferred anonymous type, and it fails loudly at the + definition when the body drifts from the contract rather than leaking a + surprising shape to callers. This is the + [TypeScript performance guidance](https://github.com/microsoft/TypeScript/wiki/Performance) + applied to our checker (`deno check` is the same compiler underneath): prefer + an `interface`/base type that others extend over a large `type X = A & B & C` + intersection or a wide bare union (comparing many members is quadratic), and + give a complex conditional type its own name so the compiler caches it instead + of re-deriving it at every use. A small two-way `A & B` merge, or a + `v.variant`/discriminated union built from the schema-first patterns above, is + already the right shape — this is about not hand-rolling sprawling anonymous + ones. (The wiki's `tsconfig`/project-reference/tracing advice does not apply: + we type-check with `deno check`, not `tsc`.) +- **Never lose work — commit WIP even if broken**: Uncommitted changes are lost + if the working environment is reclaimed (it has happened). If you have + non-trivial work in progress and are about to pause, hand off, delegate to a + background agent, or end a turn with a dirty tree, **commit and push it** + rather than leaving it uncommitted. A known-broken checkpoint is fine and + expected — mark it unmistakably in the commit message (e.g. + `WIP: — NOT GREEN, `) so it is never mistaken for finished + work, and follow up with a green commit. Do not hold a commit back purely + because the tree does not yet build or pass; losing the work is worse. +- **Answer every PR review thread you address**: When a pull request review + leaves comments — from an automated reviewer (e.g. Codex) or a human — reply + to **each** thread directly with a concise, proper note: how it was resolved + (the mechanism + the regression test that locks it), or why it is not + actionable/incorrect. Do this even when the commit message already explains + the change — an open thread reads as unaddressed, so close the loop on the + thread itself. This is a deliberate exception to general GitHub-comment + frugality: resolution replies on review threads are expected, not noise. Keep + each reply tight (a few sentences), and reference the fixing commit. **If a + suggestion is valid but outside the current job's scope**, do not silently + drop it — record it in `TODO.md` with enough context for a future person to + pick it up without re-reading the PR (the file/path it concerns, what the + reviewer proposed, why it's genuinely out of scope here, and a starting + point), then reply on the thread pointing to the TODO entry. Scope is a real + boundary, not an excuse to lose good ideas. +- **Finish by rewriting the PR name and description**: Once a feature is done, + revisit its pull request and update the name and description to match what was + actually built. A PR often starts life with a WIP or work-in-flight title; the + finished PR should be thorough but written in simple, concise, understandable, + non-technical language — the same plain language we want in our code, + comments, and method names. Someone without a CS degree should be able to read + the PR and know what changed, why, and what it means for the people using the + site. +- **Final check**: Run `nix develop -c deno task precommit` before finishing any + job with code or documentation changes. It is the only check that mirrors CI + exactly — it typechecks the **test** files too, so `deno check ` plus + `test:files` is not a substitute (a test-only type error will pass locally and + still break CI). ## Offensive Programming — Never Suppress Errors This codebase practices [offensive programming](https://en.wikipedia.org/wiki/Offensive_programming), -not defensive programming. Defensive code tolerates bad states to keep -running; offensive code makes bad states impossible to miss. A loud failure is -almost always better than a silent wrong answer — the crash points at the bug, -while a swallowed error corrupts data far from the cause. "Trust application -invariants" and "Don't defend against the impossible" in -[Preferences](#preferences) are this same philosophy; the rules below are how -it applies to everyday error handling. - -- **Let errors propagate.** Do not silence, swallow, or paper over them. Do - not wrap code in `try`/`catch` just to "make it more robust" — robustness - comes from correct assumptions, not from hiding broken ones. +not defensive programming. Defensive code tolerates bad states to keep running; +offensive code makes bad states impossible to miss. A loud failure is almost +always better than a silent wrong answer — the crash points at the bug, while a +swallowed error corrupts data far from the cause. "Trust application invariants" +and "Don't defend against the impossible" in [Preferences](#preferences) are +this same philosophy; the rules below are how it applies to everyday error +handling. + +- **Let errors propagate.** Do not silence, swallow, or paper over them. Do not + wrap code in `try`/`catch` just to "make it more robust" — robustness comes + from correct assumptions, not from hiding broken ones. - **A missing expected field from structured external data is a HARD no to default away.** JSON API response, database row, config, env var, webhook - payload, fetch result, file/CLI output — if the field is - documented/expected, missing means something is wrong upstream, and the - program must fail there, not invent a value. Validate at the boundary with a - valibot schema and pass typed values inward (`src/features/api/sms-webhook.ts`); - where a schema is overkill, check and throw the way `parseMessageId` does - (`src/shared/sms/gateway.ts` — a gateway response without a message id - throws, it does not return `""`) and `getDb` does for a missing `DB_URL` + payload, fetch result, file/CLI output — if the field is documented/expected, + missing means something is wrong upstream, and the program must fail there, + not invent a value. Validate at the boundary with a valibot schema and pass + typed values inward (`src/features/api/sms-webhook.ts`); where a schema is + overkill, check and throw the way `parseMessageId` does + (`src/shared/sms/gateway.ts` — a gateway response without a message id throws, + it does not return `""`) and `getDb` does for a missing `DB_URL` (`src/shared/db/client.ts`). - **Don't use `??` / `||` / `?.` to make a missing value someone else's problem.** Coercing `null`/`undefined` into `""`, `0`, or `[]` to keep the pipeline moving converts a detectable failure into corrupt data. These - operators are for *genuinely optional* values (next bullet), not for - papering over a value that should always exist. Unchecked assertions — the - non-null `!` and `as` casts that claim a shape the data hasn't been checked - against — are a different trap: they run no code at all, they just tell - TypeScript to stop checking, so the failure surfaces wherever the - impossible value is first touched instead of where it went missing. Parse, - don't pretend. + operators are for _genuinely optional_ values (next bullet), not for papering + over a value that should always exist. Unchecked assertions — the non-null `!` + and `as` casts that claim a shape the data hasn't been checked against — are a + different trap: they run no code at all, they just tell TypeScript to stop + checking, so the failure surfaces wherever the impossible value is first + touched instead of where it went missing. Parse, don't pretend. - **No empty `catch`, no catch-and-continue.** Only catch when there is a real recovery path, catch at the narrowest point that has one, and re-raise (or log + re-raise) otherwise. Good catches look like `parseMessageId` — a `JSON.parse` of an external response caught and rethrown as a specific, contextful error — or a boundary handler turning an invalid webhook payload - into a 400. A `catch {}` whose body ignores the error is acceptable only - when the fallback *is* the documented behavior, stated in a comment (e.g. - `tryDecrypt` in `src/features/api/sms-webhook.ts`, whose contract is - "fall back to the raw value if it isn't encrypted"). + into a 400. A `catch {}` whose body ignores the error is acceptable only when + the fallback _is_ the documented behavior, stated in a comment (e.g. + `tryDecrypt` in `src/features/api/sms-webhook.ts`, whose contract is "fall + back to the raw value if it isn't encrypted"). - **A function that looks something up, resolves, computes, or finds something - must THROW when it can't** — never return `null` / `""` / `0` / `-1` / `[]` - as a "not found" stand-in — unless "not found" is a genuinely expected, + must THROW when it can't** — never return `null` / `""` / `0` / `-1` / `[]` as + a "not found" stand-in — unless "not found" is a genuinely expected, documented outcome the caller branches on. This is the case that keeps recurring: a helper iterates looking for a value (an id, a match, a record) and falls off the end. The right tail is @@ -157,14 +430,13 @@ it applies to everyday error handling. must exist (inputs already filtered to guarantee it), the miss is a bug: surface it loudly. - **Defaults, optional chaining, `catch`, and nullable returns are acceptable - only when the absence is genuinely expected and semantically meaningful.** - An optional query-string parameter (`searchParams.get(key) ?? ""` in - `src/features/url.ts`), an accumulator's first visit - (`totals.get(key) ?? 0`), a record that may legitimately not exist yet. In - that case, name it for what it is — the `*OrNull` suffix - (`decryptAttendeeOrNull`, `firstRowOrNull`) and a `| null` return type are - the house convention — and comment why the absence is expected, so a reader - can tell a deliberate branch from a suppressed failure. + only when the absence is genuinely expected and semantically meaningful.** An + optional query-string parameter (`searchParams.get(key) ?? ""` in + `src/features/url.ts`), an accumulator's first visit (`totals.get(key) ?? 0`), + a record that may legitimately not exist yet. In that case, name it for what + it is — the `*OrNull` suffix (`decryptAttendeeOrNull`, `firstRowOrNull`) and a + `| null` return type are the house convention — and comment why the absence is + expected, so a reader can tell a deliberate branch from a suppressed failure. ## Simple Language — How We Talk To Users @@ -178,7 +450,7 @@ reader needs** — concise, never clipped. This is not "dumbing down". Assume the reader understands the domain concepts the platform runs on — a percentage, a deposit, gross vs net, a refund. Do not -stop to teach those. Explain *our system's* behaviour in plain words, and never +stop to teach those. Explain _our system's_ behaviour in plain words, and never pad a message with general knowledge the reader already has. ### Where the copy lives @@ -186,9 +458,9 @@ pad a message with general knowledge the reader already has. All user-facing text is in the message catalog at `src/locales/en/*.json`, reached through `t("key")` (see `src/shared/i18n.ts`). Changing what a user reads is a **catalog edit, not a template edit** — the `i18n-coverage` test -(`test/scripts/i18n-coverage.test.ts`) fails the build when a new hard-coded string -appears in a template. Write copy once, in the catalog, and every surface that -shows it stays worded the same. +(`test/scripts/i18n-coverage.test.ts`) fails the build when a new hard-coded +string appears in a template. Write copy once, in the catalog, and every surface +that shows it stays worded the same. ### How to write it @@ -205,7 +477,7 @@ shows it stays worded the same. typed." - **Active voice, speaking to "you".** "You must accept the terms to continue." — not "The terms must be accepted before continuing." -- **No implementation jargon.** Words like *HMAC*, *hash*, *token*, *idempotent* +- **No implementation jargon.** Words like _HMAC_, _hash_, _token_, _idempotent_ are for code, not for operators. Name a thing by what it does ("a one-way code"), not how it is built. The one exception is developer-facing API documentation, where literal technical terms (`JSON`, an endpoint path) are @@ -222,7 +494,8 @@ pattern rather than inventing a new phrasing: - **Errors** state the problem, and the fix where there is one, as a full sentence: `"{label} is required"`, `"Password must be at least 8 characters"`, `"Too many login attempts. Please try again later."` A confirm-by-typing error - is always `" name does not match. Please type the exact name to + is always + `" name does not match. Please type the exact name to confirm."` - **Warnings** before a destructive action open with `Warning:` and say plainly and completely what will happen (see `admin.attendees.delete_warning`). @@ -233,8 +506,8 @@ pattern rather than inventing a new phrasing: buttons, labels, headers, and messages alike. Some older keys are still Title Case; align them to sentence case when you next touch that surface. - **End full sentences with a full stop; never a label, button, or column - header.** A message that is a sentence gets its full stop; a fragment used as a - control does not. + header.** A message that is a sentence gets its full stop; a fragment used as + a control does not. ### What is checked automatically @@ -252,13 +525,13 @@ change, which is what the rest of this section is for. ### Before → after -| Don't | Do | -| ---------------------------------------------- | ------------------------------------------------------------------------------- | -| "Click here to view your ticket" | "View your ticket" | -| "Click here if the payment window didn't open" | "Open the payment window" | +| Don't | Do | +| ---------------------------------------------- | --------------------------------------------------------------------------------- | +| "Click here to view your ticket" | "View your ticket" | +| "Click here if the payment window didn't open" | "Open the payment window" | | "…keyed by its anonymised HMAC." | "It is found by a one-way code, so the real email or phone is never stored here." | -| "For nerdy debug info click here." | "See debug info." | -| "In order to confirm, type the name." | "Type the name to confirm." | +| "For nerdy debug info click here." | "See debug info." | +| "In order to confirm, type the name." | "Type the name to confirm." | ## Designing New Systems @@ -269,66 +542,71 @@ systems we want more of. ### Schema-tized -Model the thing as data first — a typed schema plus a few functions folded -over it — and derive everything else (types, validation, rendering, routes) -from that one declaration. The philosophy is in the Preferences ("Schema over -organic structure", "Shared interfaces over branch-per-case"); these are the -mechanisms to copy: +Model the thing as data first — a typed schema plus a few functions folded over +it — and derive everything else (types, validation, rendering, routes) from that +one declaration. The philosophy is in the Preferences ("Schema over organic +structure", "Shared interfaces over branch-per-case"); these are the mechanisms +to copy: -- **A valibot schema as the single source of truth for a value type.** - Declare once; derive the TS type, the runtime guard, and the options list: +- **A valibot schema as the single source of truth for a value type.** Declare + once; derive the TS type, the runtime guard, and the options list: ```typescript - export const ContactFieldSchema = v.picklist(["email", "phone", "address", "special_instructions"]); + export const ContactFieldSchema = v.picklist([ + "email", + "phone", + "address", + "special_instructions", + ]); export type ContactField = v.InferOutput; export const CONTACT_FIELDS = ContactFieldSchema.options; export const isContactField = (s: string): s is ContactField => v.is(ContactFieldSchema, s); ``` - See `src/shared/types.ts` (six of these) and `src/shared/price-modifier.ts` - (a whole family). For structured values, compose `v.object` schemas into a - discriminated union with `v.variant("kind", […])` and a single `v.is` guard - — `src/shared/bulk-email-targets.ts` is the reference. + See `src/shared/types.ts` (six of these) and `src/shared/price-modifier.ts` (a + whole family). For structured values, compose `v.object` schemas into a + discriminated union with `v.variant("kind", […])` and a single `v.is` guard — + `src/shared/bulk-email-targets.ts` is the reference. - **Declarative tables.** `defineTable`/`defineIdTable` (`src/shared/db/table.ts`, `src/shared/db/define-id-table.ts`): a `columns` config built from the `col.*` builders (`col.boolean`, `col.encrypted`, - `col.generated`, …) drives serialization, encryption, and the derived - `Input` type. Never hand-write row mapping. -- **Config-driven CRUD.** `defineCrudApi` (`src/shared/rest/crud-api.ts`) - turns one config object into the five standard admin API routes; + `col.generated`, …) drives serialization, encryption, and the derived `Input` + type. Never hand-write row mapping. +- **Config-driven CRUD.** `defineCrudApi` (`src/shared/rest/crud-api.ts`) turns + one config object into the five standard admin API routes; `defineResource`/`defineNamedResource` (`src/shared/rest/resource.ts`) turn - `{table, fields, toInput, validate}` into typed operations that the - handlers in `src/shared/rest/handlers.ts` wire to HTTP. + `{table, fields, toInput, validate}` into typed operations that the handlers + in `src/shared/rest/handlers.ts` wire to HTTP. - **Schema-driven forms.** `defineForm` + a `Field[]` - (`src/shared/forms/definition.ts`): - one field list drives the HTML rendering, the parsing/validation, and the - `FormValuesFor<>` value types; `createFormRoute`/`createAuthedFormRoute` - (`src/shared/app-forms.ts`) wire that same schema to both the GET (render) - and POST (validate) handlers. -- **Form section headers are a `FormSection[]`, never a hand-rolled heading.** - A form's grouped sections are modelled as data — a `FormSection[]` (`legend` - + `children`) rendered by `FormSections` - (`src/ui/templates/components/aggregate-sections.tsx`), which turns each entry - into a legend-led `SectionFieldset`. A single section uses `SectionFieldset` - directly. Never head a form section with an `

`/`

` — a `` is - the section header, and routing every section through `FormSections`/ - `SectionFieldset` keeps that so. The listing form (`listings/form-sections.tsx`) - and the attendee form (`admin/attendee-form.tsx`) both build a `FormSection[]`; - see them for conditional sections (`compact` drops the ones that don't apply). + (`src/shared/forms/definition.ts`): one field list drives the HTML rendering, + the parsing/validation, and the `FormValuesFor<>` value types; + `createFormRoute`/`createAuthedFormRoute` (`src/shared/app-forms.ts`) wire + that same schema to both the GET (render) and POST (validate) handlers. +- **Form section headers are a `FormSection[]`, never a hand-rolled heading.** A + form's grouped sections are modelled as data — a `FormSection[]` (`legend` + - `children`) rendered by `FormSections` + (`src/ui/templates/components/aggregate-sections.tsx`), which turns each + entry into a legend-led `SectionFieldset`. A single section uses + `SectionFieldset` directly. Never head a form section with an `

`/`

` + — a `` is the section header, and routing every section through + `FormSections`/ `SectionFieldset` keeps that so. The listing form + (`listings/form-sections.tsx`) and the attendee form + (`admin/attendee-form.tsx`) both build a `FormSection[]`; see them for + conditional sections (`compact` drops the ones that don't apply). - **One vocabulary for "attached to any record".** `defineRecordTarget` (`src/shared/db/record-target.ts`): a domain says which kinds of record it accepts and which two columns hold the kind and the id, and gets back the naming (`of("listing")(7)`), a stable `key`/`fromKey` pair, the `where`/`whereMany`/`whereChosenBy` clauses, the matching deletes, and an - existence check. Notes - (`src/shared/db/notes/target.ts`), image links (`src/shared/db/images.ts`), - and site page items (`src/shared/site-pages/target.ts`) all use it — a fourth - "attach something to any record" feature declares its kinds, it does not - invent a fourth vocabulary. + existence check. Notes (`src/shared/db/notes/target.ts`), image links + (`src/shared/db/images.ts`), and site page items + (`src/shared/site-pages/target.ts`) all use it — a fourth "attach something to + any record" feature declares its kinds, it does not invent a fourth + vocabulary. - **A data table plus one fold.** `LISTING_DEFAULT_FIELDS` + - `resolveListingDefaults` (`src/shared/listing-defaults.ts`); the admin - guide's `GuideSection[]` + `renderGuideSections`. + `resolveListingDefaults` (`src/shared/listing-defaults.ts`); the admin guide's + `GuideSection[]` + `renderGuideSections`. If your plan contains a hand-rolled dispatcher, an ad-hoc form, bespoke CRUD routes, or hand-written row (de)serialization, stop: there is a `define*` @@ -336,99 +614,98 @@ factory for that already. Use it — or extend it for every caller. ### Pure, functional -Write the core of a feature as pure data-in/data-out functions and keep IO -(DB reads, settings, fetches) in a thin shell around it. Pure modules are -trivially unit-testable, which is what keeps 100% coverage and a 100% -mutation kill rate cheap to sustain. - -- `src/shared/largest-remainder.ts` — a complete allocation algorithm with - zero imports; the hardest logic in the money paths and the easiest to test. -- `src/shared/listing-defaults.ts` — the header states "This module is - pure": callers fetch, it computes. -- `src/shared/ledger/project.ts` — pure projections over a slice of - transfers; every derived total reuses the single `allBalances` fold, so no - two totals can disagree. -- `src/shared/phone.ts`, `src/shared/countries.ts` — pure normalization, and - a pure data table with total accessors. +Write the core of a feature as pure data-in/data-out functions and keep IO (DB +reads, settings, fetches) in a thin shell around it. Pure modules are trivially +unit-testable, which is what keeps 100% coverage and a 100% mutation kill rate +cheap to sustain. + +- `src/shared/largest-remainder.ts` — a complete allocation algorithm with zero + imports; the hardest logic in the money paths and the easiest to test. +- `src/shared/listing-defaults.ts` — the header states "This module is pure": + callers fetch, it computes. +- `src/shared/ledger/project.ts` — pure projections over a slice of transfers; + every derived total reuses the single `allBalances` fold, so no two totals can + disagree. +- `src/shared/phone.ts`, `src/shared/countries.ts` — pure normalization, and a + pure data table with total accessors. Prefer the curried utilities from `#fp` over imperative loops (see [FP Imports](#fp-imports)). When a module needs both computation and -configuration, split it the way `src/shared/dates.ts` does: the pure -functions take the timezone as an argument, and thin wrappers inject -`settings.timezone` — the pure core stays testable without a database. +configuration, split it the way `src/shared/dates.ts` does: the pure functions +take the timezone as an argument, and thin wrappers inject `settings.timezone` — +the pure core stays testable without a database. ### Modularised -One concept per file; one layer per directory. `REPO_STRUCTURE.md` defines -where things go (`src/features/*` routes, `src/shared/*` domain logic, -`src/ui/*` presentation). Within `shared/`, the shapes to copy: +One concept per file; one layer per directory. `REPO_STRUCTURE.md` defines where +things go (`src/features/*` routes, `src/shared/*` domain logic, `src/ui/*` +presentation). Within `shared/`, the shapes to copy: -- `src/shared/rest/` — `resource.ts` (the resource abstraction), - `handlers.ts` (HTTP wiring), `crud-api.ts` (the JSON API): each file is - one layer, named for its job. -- `src/shared/ledger/` — `types.ts`, `project.ts`, `account.ts`, - `reconcile.ts`: a domain split into files you can navigate blind. +- `src/shared/rest/` — `resource.ts` (the resource abstraction), `handlers.ts` + (HTTP wiring), `crud-api.ts` (the JSON API): each file is one layer, named for + its job. +- `src/shared/ledger/` — `types.ts`, `project.ts`, `account.ts`, `reconcile.ts`: + a domain split into files you can navigate blind. - `src/shared/db/attendees/` — `queries.ts`, `pii.ts`, `capacity.ts`, `stats.ts`, `delete.ts`: a big table's concerns separated instead of one 1,500-line module. -A new system should arrive as a small directory of single-purpose files, not -one grab-bag module — and not as fragments scattered through unrelated -existing files. +A new system should arrive as a small directory of single-purpose files, not one +grab-bag module — and not as fragments scattered through unrelated existing +files. ### Well-named files The filename states the concept; the concept fills the file. `largest-remainder.ts`, `phone.ts`, `slug.ts`, `define-id-table.ts`, -`request-cache.ts`, `keyed-cache.ts` — you can guess each file's exports -from its name and vice versa. Function names carry contracts the same way: -the `Raw` suffix on `getAttendeesRaw`/`getAttendeeRaw` means "PII still -encrypted — decrypt before display", and `getUserDisplayFields` names the -exact narrow column set it selects. If you can't name the file in a couple -of words, it is probably two concepts — split it. +`request-cache.ts`, `keyed-cache.ts` — you can guess each file's exports from +its name and vice versa. Function names carry contracts the same way: the `Raw` +suffix on `getAttendeesRaw`/`getAttendeeRaw` means "PII still encrypted — +decrypt before display", and `getUserDisplayFields` names the exact narrow +column set it selects. If you can't name the file in a couple of words, it is +probably two concepts — split it. ### Valibot and standard libraries -Validation is valibot; collections are `@std/collections` (via `#fp`); -paths, media types, and cookies are `@std/path`, `@std/media-types`, and +Validation is valibot; collections are `@std/collections` (via `#fp`); paths, +media types, and cookies are `@std/path`, `@std/media-types`, and `@std/http/cookie`; date/timezone math is `Temporal` (temporal-polyfill); formatting is `Intl`. Valibot patterns to copy: - **Branded scalar** — `src/shared/validation/email.ts`: `v.pipe(v.string(), v.trim(), v.toLowerCase(), v.email(), v.brand("ValidEmail"))`. - A `ValidEmail` can only be produced by validation, so downstream code - needs no re-checks. + A `ValidEmail` can only be produced by validation, so downstream code needs no + re-checks. - **Coercing schema factory** — `src/shared/validation/number.ts`: - `createIntSchema(minimum)` validates digits *before* `v.transform(Number)` - (closing the `parseInt("5abc")` hole); `PositiveIntSchema` and friends are - its specializations. -- **Boundary validation** — `src/features/api/sms-webhook.ts`: `v.safeParse` - an envelope `v.object` immediately after `JSON.parse`, 400 on failure. - Validate at the boundary; pass typed values inward. + `createIntSchema(minimum)` validates digits _before_ `v.transform(Number)` + (closing the `parseInt("5abc")` hole); `PositiveIntSchema` and friends are its + specializations. +- **Boundary validation** — `src/features/api/sms-webhook.ts`: `v.safeParse` an + envelope `v.object` immediately after `JSON.parse`, 400 on failure. Validate + at the boundary; pass typed values inward. - **Deliberate non-use is fine when the platform is better** — `src/shared/validation/timestamp.ts` delegates instant validation to - `Temporal.Instant.from` (valibot's `isoTimestamp` accepts overflow days) - and documents why. + `Temporal.Instant.from` (valibot's `isoTimestamp` accepts overflow days) and + documents why. ### Don't reinvent the wheel Before writing an algorithm, formatter, or parser, check `deno.json` — the -answer is usually already a dependency. When the project's calling -convention differs from a library's, write a thin adapter; don't -re-implement: +answer is usually already a dependency. When the project's calling convention +differs from a library's, write a thin adapter; don't re-implement: - `src/fp.ts` — `unique`, `uniqueBy`, `mapNotNullish`, `sumOf`, `chunk` are one-line curried adapters over `@std/collections`. -- `src/shared/db/table.ts` — `toCamelCase`/`toSnakeCase` delegate to - valibot's case actions rather than bespoke regexes. +- `src/shared/db/table.ts` — `toCamelCase`/`toSnakeCase` delegate to valibot's + case actions rather than bespoke regexes. - `src/shared/timezone.ts` — all DST/offset math is `Temporal`; `src/shared/currency.ts` gets currency symbols and decimal places from - `Intl.NumberFormat` instead of a hand-maintained table; - `src/shared/slug.ts` validates with `v.slug()`. + `Intl.NumberFormat` instead of a hand-maintained table; `src/shared/slug.ts` + validates with `v.slug()`. -When you genuinely must hand-roll, document the reason at the definition the -way `#fp`'s `groupBy` does (it exists because `@std/collections` lacks the -ordering guarantee its callers rely on). +When you genuinely must hand-roll, document the reason at the definition the way +`#fp`'s `groupBy` does (it exists because `@std/collections` lacks the ordering +guarantee its callers rely on). ### Curried helpers @@ -438,8 +715,8 @@ configuration, the returned function takes the data. - `makeOutcome(succeeded)` → `export const ok = makeOutcome(true)` / `fail = makeOutcome(false)` (`src/shared/response.ts`). -- `roleIn(levels)` → `isStaffRole`, `isDeliveryRole` (`src/shared/types.ts`) - — predicate factories instead of near-identical functions. +- `roleIn(levels)` → `isStaffRole`, `isDeliveryRole` (`src/shared/types.ts`) — + predicate factories instead of near-identical functions. - `balanceOf(account)` → `(transfers) => number` and friends (`src/shared/ledger/project.ts`) — curried projections that compose. - At larger scale the same shape becomes the config-driven factories: @@ -448,47 +725,45 @@ configuration, the returned function takes the data. ### Built for cold starts -Most production requests land on a freshly booted edge isolate with a -~500ms startup budget and a limited subrequest budget +Most production requests land on a freshly booted edge isolate with a ~500ms +startup budget and a limited subrequest budget (`scripts/bench/cold-start/bundle-load.ts` measures single-file load and -`scripts/bench/cold-start/first-request.ts` measures request round trips). The rules, with their -reference implementations: +`scripts/bench/cold-start/first-request.ts` measures request round trips). The +rules, with their reference implementations: - **Nothing heavy at module load.** Entry points only register the handler - (`src/edge.ts`); app boot runs `once()` on the *first request* - (`src/serve-app.ts`). Module-load work is fine only when pure and cheap - (e.g. `defineTable` building its schemas once). -- **Lazy singletons via `once`/`lazyRef` from `#fp`.** The DB client - (`getDb` in `src/shared/db/client.ts`), the dynamically imported Stripe - SDK (`src/shared/stripe.ts`), the Liquid email engine, crypto key material - — all first-use, never import-time. + (`src/edge.ts`); app boot runs `once()` on the _first request_ + (`src/serve-app.ts`). Module-load work is fine only when pure and cheap (e.g. + `defineTable` building its schemas once). +- **Lazy singletons via `once`/`lazyRef` from `#fp`.** The DB client (`getDb` in + `src/shared/db/client.ts`), the dynamically imported Stripe SDK + (`src/shared/stripe.ts`), the Liquid email engine, crypto key material — all + first-use, never import-time. - **Request-scoped memoization, not global state.** `requestCache` - (`src/shared/request-cache.ts`) shares one fetch among all callers within - a request. Any new per-request state is built on one of the three factories - in `src/shared/request-scoped.ts` (`createScope`, `createScopedValue`, - `createRequestScoped`) — the only module allowed to touch - `AsyncLocalStorage` — so two concurrent requests on one isolate can't - clobber each other and a leaked post-request context always reads as - "outside a request". - Isolate-lived caches are best-effort and bounded - (`src/shared/db/keyed-cache.ts`; the settings version-stamp cache in - `src/shared/db/settings.ts`) — never authoritative for security - decisions, and invalidated automatically by the write-sniffing db client - (`src/shared/cache-registry.ts`). + (`src/shared/request-cache.ts`) shares one fetch among all callers within a + request. Any new per-request state is built on one of the three factories in + `src/shared/request-scoped.ts` (`createScope`, `createScopedValue`, + `createRequestScoped`) — the only module allowed to touch `AsyncLocalStorage` + — so two concurrent requests on one isolate can't clobber each other and a + leaked post-request context always reads as "outside a request". Isolate-lived + caches are best-effort and bounded (`src/shared/db/keyed-cache.ts`; the + settings version-stamp cache in `src/shared/db/settings.ts`) — never + authoritative for security decisions, and invalidated automatically by the + write-sniffing db client (`src/shared/cache-registry.ts`). - **Compile once, render many.** ICU message templates (including the `I18N_REPLACEMENTS` rebranding pass) compile once and cache (`src/shared/i18n.ts`), so rendering is a plain format call. - **Respect the subrequest budget.** Fixed-cost designs like - `src/shared/limits.ts` (one SELECT plus one batch regardless of batch - size), `UPDATE … RETURNING` instead of update-then-select - (`src/shared/db/table.ts`), and `queryBatch` for multi-read round-trips. - Bunny has a hard limit of 50 subrequests per request. One libsql `execute`, - batch, transaction begin/statement/commit/rollback, or external fetch counts - as one; statements inside one batch still count as one. The client guard - blocks database call 51, but routes that also call providers or storage must - target at most 40 database calls so those other fetches still fit. -- **Model realistic database latency.** The request benchmark uses 0, 5, 10, - and 20 ms per libsql round trip. Treat 20 ms as the expected worst case for a + `src/shared/limits.ts` (one SELECT plus one batch regardless of batch size), + `UPDATE … RETURNING` instead of update-then-select (`src/shared/db/table.ts`), + and `queryBatch` for multi-read round-trips. Bunny has a hard limit of 50 + subrequests per request. One libsql `execute`, batch, transaction + begin/statement/commit/rollback, or external fetch counts as one; statements + inside one batch still count as one. The client guard blocks database call 51, + but routes that also call providers or storage must target at most 40 database + calls so those other fetches still fit. +- **Model realistic database latency.** The request benchmark uses 0, 5, 10, and + 20 ms per libsql round trip. Treat 20 ms as the expected worst case for a replicated database; do not publish 50 or 100 ms projections as realistic production measurements without evidence from production. @@ -497,14 +772,14 @@ per-request whole-table read is a cold-start regression even if it works. ### Efficient SQL -The rules live in [Database Queries](#database-queries) (narrow column -lists, bounded reads, batches vs interactive transactions). Beyond those, -copy these shapes: +The rules live in [Database Queries](#database-queries) (narrow column lists, +bounded reads, batches vs interactive transactions). Beyond those, copy these +shapes: - **Enforce invariants in the mutating statement itself.** `src/shared/db/capacity.ts` embeds the capacity check in the same - INSERT/UPDATE that books the attendee — no read-modify-write race, no - second round-trip. + INSERT/UPDATE that books the attendee — no read-modify-write race, no second + round-trip. - **Trigger-maintained aggregates instead of scans.** `listings.booked_quantity`/`tickets_count` are maintained by triggers on `listing_attendees` @@ -517,28 +792,27 @@ copy these shapes: ### Decrypt only what you need -Encrypted data stays encrypted until the moment of display, and lookups -never require decryption: +Encrypted data stays encrypted until the moment of display, and lookups never +require decryption: -- **Blind HMAC indexes for lookups.** Alongside each searchable encrypted - value sits a deterministic `hmacHash` index column: `username_index` +- **Blind HMAC indexes for lookups.** Alongside each searchable encrypted value + sits a deterministic `hmacHash` index column: `username_index` (`src/shared/db/users.ts`), `ticket_token_index` (`src/shared/crypto/hashing.ts`, `src/shared/db/attendees/queries.ts`), `phone_index` for inbound SMS (`src/shared/db/attendee-phone-index.ts`), - `code_index` on modifiers. Query `WHERE …_index = ?`; never - scan-and-decrypt. (The one sanctioned scan-decrypt — invite codes in - `users.ts` — is documented and bounded by a tiny keyspace.) -- **One blob, one decrypt, decrypt late.** All attendee PII lives in a - single `pii_blob` (`src/shared/db/attendees/pii.ts`); list queries select - it without decrypting (`getAttendeesRaw` and friends in + `code_index` on modifiers. Query `WHERE …_index = ?`; never scan-and-decrypt. + (The one sanctioned scan-decrypt — invite codes in `users.ts` — is documented + and bounded by a tiny keyspace.) +- **One blob, one decrypt, decrypt late.** All attendee PII lives in a single + `pii_blob` (`src/shared/db/attendees/pii.ts`); list queries select it without + decrypting (`getAttendeesRaw` and friends in `src/shared/db/attendees/queries.ts`), and `decryptAttendees` runs only at render time. `decryptPiiBlob`'s `paidListing` flag even gates which fields come out of the blob, and `getAttendeeNamesByIds` decrypts just the name. - **Skip encrypted columns entirely when you can.** `getUserAuthFieldsById` (`SELECT id, admin_level`) and `getAttendeeKindsByIds` (`SELECT id, kind`) - answer their questions without touching a ciphertext — the same - discipline as "Select only needed columns", applied to - plaintext-in-memory. + answer their questions without touching a ciphertext — the same discipline as + "Select only needed columns", applied to plaintext-in-memory. - **Declarative encryption at the column layer.** `col.encrypted`/`col.encryptedText` in `src/shared/db/table.ts` decrypt lazily, per present column; a new encrypted column is declared, not @@ -550,7 +824,7 @@ never require decryption: ## FP Imports ```typescript -import { pipe, filter, map, reduce, compact, unique } from "#fp"; +import { compact, filter, map, pipe, reduce, unique } from "#fp"; ``` ### Common Patterns @@ -580,28 +854,28 @@ const result = reduce((acc, item) => { These are the curried helpers actually exported from `#fp`. Several are thin adapters over `@std/collections` (noted below) so the standard library does the work while the project keeps its pipe-friendly calling convention. For -collection operations not covered here (partitioning, keying, picking -object keys, etc.), reach for `@std/collections` directly rather than -hand-rolling — wrap it in a curried `#fp` adapter if it will be reused across -the `pipe`-based code. Note `@std/collections` has **no** `groupBy` export -(it was removed in favour of the runtime built-ins) — use native -`Object.groupBy` / `Map.groupBy` for grouping. - -| Function | Purpose | -| ------------------ | ------------------------------- | -| `pipe(...fns)` | Compose functions left-to-right | -| `filter(pred)` | Curried array filter | -| `map(fn)` | Curried array map | -| `flatMap(fn)` | Curried array flatMap | -| `mapNotNullish(fn)`| Map, dropping nullish results (std mapNotNullish) | -| `reduce(fn, init)` | Curried array reduce | -| `sort(cmp)` | Non-mutating sort | -| `unique(arr)` | Remove duplicates (std distinct) | -| `uniqueBy(fn)` | Dedupe by key (std distinctBy) | -| `compact(arr)` | Remove null/undefined | -| `chunk(size)` | Split array into chunks (std chunk) | -| `sumOf(selector)` | Sum by selector (std sumOf) | -| `sum(arr)` | Sum an array of numbers | +collection operations not covered here (partitioning, keying, picking object +keys, etc.), reach for `@std/collections` directly rather than hand-rolling — +wrap it in a curried `#fp` adapter if it will be reused across the `pipe`-based +code. Note `@std/collections` has **no** `groupBy` export (it was removed in +favour of the runtime built-ins) — use native `Object.groupBy` / `Map.groupBy` +for grouping. + +| Function | Purpose | +| ------------------- | ------------------------------------------------- | +| `pipe(...fns)` | Compose functions left-to-right | +| `filter(pred)` | Curried array filter | +| `map(fn)` | Curried array map | +| `flatMap(fn)` | Curried array flatMap | +| `mapNotNullish(fn)` | Map, dropping nullish results (std mapNotNullish) | +| `reduce(fn, init)` | Curried array reduce | +| `sort(cmp)` | Non-mutating sort | +| `unique(arr)` | Remove duplicates (std distinct) | +| `uniqueBy(fn)` | Dedupe by key (std distinctBy) | +| `compact(arr)` | Remove null/undefined | +| `chunk(size)` | Split array into chunks (std chunk) | +| `sumOf(selector)` | Sum by selector (std sumOf) | +| `sum(arr)` | Sum an array of numbers | ## Code Duplication @@ -616,7 +890,7 @@ guidance. Fix the duplication; do not silence it: **Then review your work before committing — zoom out one step further.** The first small curry you reach for is often not the best one; a larger, more holistic curry across the call sites is very frequently far better. -3. **`jscpd:ignore` is the last resort.** It is excusable for basically *one* +3. **`jscpd:ignore` is the last resort.** It is excusable for basically _one_ thing: **import blocks** (plus the rare unavoidable scrap of boilerplate/infrastructure we have no control over). If the duplicated code is not an import block, you almost certainly want option 1 or 2 — an @@ -629,39 +903,64 @@ merge waiting to happen, and the whole point of this exercise. So: - **Never work around the warning by changing a structure so the matcher stops matching.** Swapping positional params for a config object, renaming to a namespace import, reordering fields, lifting a line to a named const — any - edit whose *purpose* is to break the token match while leaving two parallel + edit whose _purpose_ is to break the token match while leaving two parallel implementations in place is the opposite of what we want. It hides the signal and keeps the duplication. If you find yourself asking "how do I make jscpd - stop flagging this," you are on the wrong track: the question is "how do I make - these two things one thing." + stop flagging this," you are on the wrong track: the question is "how do I + make these two things one thing." - **Every merge is warranted — the merges are the goal.** When jscpd flags a new helper against an existing one (as it will the moment you extract something), that is not a problem to route around; it is telling you the new helper and the old one are the same operation and should be unified into a single mechanism. Do that unification. Reducing the codebase to one shared way of doing each thing is the aim; the warning is just the to-do list. -- **After a dedup, zoom out and integrate further.** Once your new helper exists, - search the codebase for the *other* places that could now fold into it or into - an existing sibling. A dedup pass rarely ends at the sites that first tripped - the check — the biggest wins come from noticing that the helper you just wrote - subsumes three more call sites, or that it and an older helper are the same - thing wearing two names. Keep pulling the thread until the merges are genuinely - exhausted. +- **After a dedup, zoom out and integrate further.** Once your new helper + exists, search the codebase for the _other_ places that could now fold into it + or into an existing sibling. A dedup pass rarely ends at the sites that first + tripped the check — the biggest wins come from noticing that the helper you + just wrote subsumes three more call sites, or that it and an older helper are + the same thing wearing two names. Keep pulling the thread until the merges are + genuinely exhausted. ## Database Queries Avoid `SELECT *`, and avoid loading more rows or columns than the caller needs. -- **Prefer explicit, narrow column lists.** Write `SELECT id, name, admin_level FROM …`, never `SELECT *` — list only the columns the caller reads. This keeps less plaintext/PII in memory, skips decrypting columns nobody uses, and makes each query's data dependencies obvious. Copy the existing examples: `getUserDisplayFields` (`id, username_hash, admin_level`), `getAllUserIds` (`id`), `getAllAttendeePiiBlobs` (`pii_blob`), `getAllRawEmailTemplates` (`id, subject, body`). -- **"Get all rows" is rarely the right shape.** About the only legitimate reason to read a whole table is rendering an admin collection page (e.g. `/admin/listings`, `/admin/questions`) — and even then, select only the columns those rows display, not every column on the table. Everything else should be a bounded query (by id, by key, or with a `WHERE`/`LIMIT`). - -Some reads legitimately need the full row — these are the exceptions, not the rule: - -- **An entity cache that also backs single-record reads.** When one request-scoped cache serves both the collection view and the `getById`/`getByKey` detail/auth reads (listings, users, groups, holidays, built-sites, attendee-statuses), it loads the full entity once so the detail, edit, and login paths it feeds have every column. Narrowing the cache load would break those reads. (`getAllListings`' `SELECT listing.*` is deliberately wide — it also carries the trigger-maintained `booked_quantity`/`income`/`tickets_count` aggregate columns.) -- **Full-table backup/restore** (`backup.ts`) — a dump needs every column to round-trip. -- **A table's whole-row read** (`table.read.one`/`read.many` with no columns named, in `table-reader.ts`) — it selects every stored column by design and feeds edit pages that need the whole row; a read that wants less names its columns with `read.pick`, and specific tables narrow at the cache `fetchAll` layer instead. - -Even when a caller genuinely needs many columns, list them explicitly rather than `SELECT *`, so adding a column later doesn't silently widen every read. +- **Prefer explicit, narrow column lists.** Write + `SELECT id, name, admin_level FROM …`, never `SELECT *` — list only the + columns the caller reads. This keeps less plaintext/PII in memory, skips + decrypting columns nobody uses, and makes each query's data dependencies + obvious. Copy the existing examples: `getUserDisplayFields` + (`id, username_hash, admin_level`), `getAllUserIds` (`id`), + `getAllAttendeePiiBlobs` (`pii_blob`), `getAllRawEmailTemplates` + (`id, subject, body`). +- **"Get all rows" is rarely the right shape.** About the only legitimate reason + to read a whole table is rendering an admin collection page (e.g. + `/admin/listings`, `/admin/questions`) — and even then, select only the + columns those rows display, not every column on the table. Everything else + should be a bounded query (by id, by key, or with a `WHERE`/`LIMIT`). + +Some reads legitimately need the full row — these are the exceptions, not the +rule: + +- **An entity cache that also backs single-record reads.** When one + request-scoped cache serves both the collection view and the + `getById`/`getByKey` detail/auth reads (listings, users, groups, holidays, + built-sites, attendee-statuses), it loads the full entity once so the detail, + edit, and login paths it feeds have every column. Narrowing the cache load + would break those reads. (`getAllListings`' `SELECT listing.*` is deliberately + wide — it also carries the trigger-maintained + `booked_quantity`/`income`/`tickets_count` aggregate columns.) +- **Full-table backup/restore** (`backup.ts`) — a dump needs every column to + round-trip. +- **A table's whole-row read** (`table.read.one`/`read.many` with no columns + named, in `table-reader.ts`) — it selects every stored column by design and + feeds edit pages that need the whole row; a read that wants less names its + columns with `read.pick`, and specific tables narrow at the cache `fetchAll` + layer instead. + +Even when a caller genuinely needs many columns, list them explicitly rather +than `SELECT *`, so adding a column later doesn't silently widen every read. ### Transactions and Batches @@ -670,27 +969,26 @@ interactive transactions over firing independent `execute` calls. Independent calls neither share a transaction (a later failure can't undo an earlier write) nor a round-trip (each one is a separate request to the primary). The helpers in `src/shared/db/client.ts` already wrap libsql's transaction APIs — reach for -them rather than calling `getDb().batch`/`getDb().transaction` directly, so query -logging and table-scoped cache invalidation stay automatic. +them rather than calling `getDb().batch`/`getDb().transaction` directly, so +query logging and table-scoped cache invalidation stay automatic. - **Batch — multiple statements, no logic between them.** When you know all the statements up front and none depends on the result of an earlier one, use a batch. It runs them sequentially in one implicit transaction over a single round-trip: success commits everything, any failure rolls the whole thing - back. Use `executeBatch` (writes, discards results), - `executeBatchWithResults` (writes, returns each `ResultSet` — ideal for - cascading deletes and multi-step writes), `queryBatch` (reads in one - round-trip), or `queryBatchPrimary` (reads pinned to the primary when you must - read your own just-committed writes). `deleteByFieldBatch` is a ready-made - multi-table delete. + back. Use `executeBatch` (writes, discards results), `executeBatchWithResults` + (writes, returns each `ResultSet` — ideal for cascading deletes and multi-step + writes), `queryBatch` (reads in one round-trip), or `queryBatchPrimary` (reads + pinned to the primary when you must read your own just-committed writes). + `deleteByFieldBatch` is a ready-made multi-table delete. - **Interactive transaction — logic between steps.** When a later statement depends on the result of an earlier one — e.g. read a balance, validate it, then conditionally update; or create → check capacity → finalize, where a - zero-row guard must abort and undo everything — use `withTransaction`. It hands - your callback a `TxScope` whose `execute` runs inside one interactive write - transaction, committing on success and rolling back (then rethrowing) on any - error. The write lock is acquired with a short retry so concurrent writers + zero-row guard must abort and undo everything — use `withTransaction`. It + hands your callback a `TxScope` whose `execute` runs inside one interactive + write transaction, committing on success and rolling back (then rethrowing) on + any error. The write lock is acquired with a short retry so concurrent writers serialize rather than failing; a database that stays locked surfaces as `DatabaseBusyError`. Read-only statements and batches also retry fleeting upstream HTTP errors (BunnyDB 421 and Turso 502/503/504). Interactive @@ -704,41 +1002,120 @@ logging and table-scoped cache invalidation stay automatic. ## Scripts - `deno task start` - Run the server -- `deno task dev` - Run the server with `--watch`, restarting it whenever a source file changes. `build:static` runs once at the start, so an edit to a static asset still needs the task restarted. With `DB_URL=:memory:` each restart begins with an empty database, so pass `DB_URL=file:./local.db` to keep one across edits -- `deno task serve` - The bare server command that `start` and `dev` both call, so the permissions and entry point live in one place. `dev` sets `SERVE_WATCH=--watch` to add the watcher. Prefer `start` or `dev`, which build the static assets first +- `deno task dev` - Run the server with `--watch`, restarting it whenever a + source file changes. `build:static` runs once at the start, so an edit to a + static asset still needs the task restarted. With `DB_URL=:memory:` each + restart begins with an empty database, so pass `DB_URL=file:./local.db` to + keep one across edits +- `deno task serve` - The bare server command that `start` and `dev` both call, + so the permissions and entry point live in one place. `dev` sets + `SERVE_WATCH=--watch` to add the watcher. Prefer `start` or `dev`, which build + the static assets first - `deno task test` - Run the full suite - `deno task test:coverage` - Run the full suite with coverage -- `deno task test:files ...` - Run only the given test files with the same setup as the full runner (makes sure the static assets are current, starts stripe-mock, cleans up after) -- `deno task test:screenshot-contract` - Run the real-browser screenshot timing and responsive-layout contracts (requires Chromium) -- `deno task specs` - Run every Cucumber Feature through the shared test harness and write ignored Messages, HTML, and JUnit reports under `reports/` -- `deno task specs:evidence` - Run only cases with declared screenshot captures, one at a time, and write the versioned manifest plus PNG assets under `reports/evidence/`; the task requires a clean Git worktree so the manifest commit matches the captured code -- `deno task specs:check` - Parse every Feature and validate the strict authored profile and stable catalog -- `deno task specs:files ... [--tags ]` - Run selected Features through the shared harness -- `deno task lint` - Format and lint all code with Biome (`check --write`; auto-fixes in place). Biome is the sole formatter and linter. -- `deno task lint:ci` - Strict, read-only lint (`check --error-on-warnings`, no `--write`). Fails on lint warnings (e.g. cognitive complexity) and on any code that *would* be reformatted, without touching the checkout. This is the lint `deno task precommit` runs in **every** environment, so a clean `precommit` locally means the lint step will pass in CI too. Run `deno task lint` to auto-fix before re-running. +- `deno task test:files ...` - Run only the given test files with the same + setup as the full runner (makes sure the static assets are current, starts + stripe-mock, cleans up after) +- `deno task test:screenshot-contract` - Run the real-browser screenshot timing + and responsive-layout contracts (requires Chromium) +- `deno task specs` - Run every Cucumber Feature through the shared test harness + and write ignored Messages, HTML, and JUnit reports under `reports/` +- `deno task specs:evidence` - Run only cases with declared screenshot captures, + one at a time, and write the versioned manifest plus PNG assets under + `reports/evidence/`; the task requires a clean Git worktree so the manifest + commit matches the captured code +- `deno task specs:check` - Parse every Feature and validate the strict authored + profile and stable catalog +- `deno task specs:files ... [--tags ]` - Run selected + Features through the shared harness +- `deno task lint` - Format and lint all code with Biome (`check --write`; + auto-fixes in place). Biome is the sole formatter and linter. +- `deno task lint:ci` - Strict, read-only lint (`check --error-on-warnings`, no + `--write`). Fails on lint warnings (e.g. cognitive complexity) and on any code + that _would_ be reformatted, without touching the checkout. This is the lint + `deno task precommit` runs in **every** environment, so a clean `precommit` + locally means the lint step will pass in CI too. Run `deno task lint` to + auto-fix before re-running. - `deno task build:edge` - Build for Bunny Edge deployment -- `deno task backup` - Dump the database out-of-band to a `.zip`. Uploads to the configured storage zone by default (so it appears on the Backups page and lets the next migration skip its own inline backup); pass `--out ` to write a local file. Runs in a full Deno process, so unlike the in-edge backup it has no per-request subrequest budget and can dump arbitrarily large databases. -- `deno task restore ` - Restore the database named by `DB_URL` / `DB_TOKEN` in `.env` using its `DB_ENCRYPTION_KEY`. Shows the backup details, asks for typed confirmation, and reports each restore step in the console. -- `deno task snapshot --out ` - Sync the complete remote database to a standalone local SQLite file. The task prefers `DB_URL` and `DB_TOKEN` from `.env` over shell values. This developer-only task checkpoints and verifies the file, refuses to overwrite an existing path, and removes its temporary replica on success or failure. -- `deno task migrate:turso` - Interactively copy a remote libSQL database into a new Turso database through Turso's native SQLite file upload. The task asks for source credentials and the destination name, uses `TURSO_API_TOKEN`, `TURSO_ORGANIZATION`, and `TURSO_GROUP` from `.env` when available, checks that the destination is free before downloading, and removes an incomplete destination after a failed upload. -- `deno task migrate:sites` - Interactive menu for moving built sites off Bunny databases. Reads the live master site's `POST /instance/site-credentials` endpoint to list every built site and which company runs its database, migrates the chosen site to a new Turso database through a temporary SQLite file, then sets that site's `DB_URL` and `DB_TOKEN` secrets through the Bunny API so it uses the new database. Reads `MAIN_INSTANCE_URL`, `MAIN_INSTANCE_KEY`, `BUNNY_API_KEY`, `TURSO_API_TOKEN`, `TURSO_ORGANIZATION`, and `TURSO_GROUP` from `.env` when set, and asks for anything missing. It confirms by typed site name before changing anything, and prints the new `DB_URL`/`DB_TOKEN` so they can be set by hand if the secret update fails. The site keeps its existing `DB_ENCRYPTION_KEY`. +- `deno task backup` - Dump the database out-of-band to a `.zip`. Uploads to the + configured storage zone by default (so it appears on the Backups page and lets + the next migration skip its own inline backup); pass `--out ` to write a + local file. Runs in a full Deno process, so unlike the in-edge backup it has + no per-request subrequest budget and can dump arbitrarily large databases. +- `deno task restore ` - Restore the database named by `DB_URL` / + `DB_TOKEN` in `.env` using its `DB_ENCRYPTION_KEY`. Shows the backup details, + asks for typed confirmation, and reports each restore step in the console. +- `deno task snapshot --out ` - Sync the complete remote database + to a standalone local SQLite file. The task prefers `DB_URL` and `DB_TOKEN` + from `.env` over shell values. This developer-only task checkpoints and + verifies the file, refuses to overwrite an existing path, and removes its + temporary replica on success or failure. +- `deno task migrate:turso` - Interactively copy a remote libSQL database into a + new Turso database through Turso's native SQLite file upload. The task asks + for source credentials and the destination name, uses `TURSO_API_TOKEN`, + `TURSO_ORGANIZATION`, and `TURSO_GROUP` from `.env` when available, checks + that the destination is free before downloading, and removes an incomplete + destination after a failed upload. +- `deno task migrate:sites` - Interactive menu for moving built sites off Bunny + databases. Reads the live master site's `POST /instance/site-credentials` + endpoint to list every built site and which company runs its database, + migrates the chosen site to a new Turso database through a temporary SQLite + file, then sets that site's `DB_URL` and `DB_TOKEN` secrets through the Bunny + API so it uses the new database. Reads `MAIN_INSTANCE_URL`, + `MAIN_INSTANCE_KEY`, `BUNNY_API_KEY`, `TURSO_API_TOKEN`, `TURSO_ORGANIZATION`, + and `TURSO_GROUP` from `.env` when set, and asks for anything missing. It + confirms by typed site name before changing anything, and prints the new + `DB_URL`/`DB_TOKEN` so they can be set by hand if the secret update fails. The + site keeps its existing `DB_ENCRYPTION_KEY`. - `deno task precommit` - Run all checks (typecheck, lint, tests) -- `deno task precommit:mutation` - The precommit mutation gate, runnable on its own: mutation-test every `src/` file this branch changed and demand a 100% kill rate. All of a source's mirror-located direct tests run first, whether or not those tests changed; changed tests under `test/integration/`, `test/e2e/`, or `specs/` run only for direct-test survivors. A changed Cucumber step or support file selects every Feature. The changed set is the branch's committed diff against the integration branch (`origin/main`, else a local `main`) via `base...HEAD` — three-dot/merge-base, so it's the branch's full diff vs main and stays bounded to the branch's own commits (precommit runs post-commit on a clean tree, so the index is empty). Skips cheaply when there is no base ref or no changed `src/` files. If a badly stale local `origin/main` balloons the changed set past `STALE_BASE_SOURCE_LIMIT`, it skips with a "run `git fetch origin main`" hint instead of mutating most of the tree. See [Mutation Testing](#mutation-testing). -- `deno task mutation ` - Mutation-test your tests on demand in an isolated `.mutation-runs//work` copy: mutate operators in the source and check your tests catch it (see [Mutation Testing](#mutation-testing)) +- `deno task precommit:mutation` - The precommit mutation gate, runnable on its + own: mutation-test every `src/` file this branch changed and demand a 100% + kill rate. All of a source's mirror-located direct tests run first, whether or + not those tests changed; changed tests under `test/integration/`, `test/e2e/`, + or `specs/` run only for direct-test survivors. A changed Cucumber step or + support file selects every Feature. The changed set is the branch's committed + diff against the integration branch (`origin/main`, else a local `main`) via + `base...HEAD` — three-dot/merge-base, so it's the branch's full diff vs main + and stays bounded to the branch's own commits (precommit runs post-commit on a + clean tree, so the index is empty). Skips cheaply when there is no base ref or + no changed `src/` files. If a badly stale local `origin/main` balloons the + changed set past `STALE_BASE_SOURCE_LIMIT`, it skips with a "run + `git fetch origin main`" hint instead of mutating most of the tree. See + [Mutation Testing](#mutation-testing). +- `deno task mutation ` - Mutation-test your tests on + demand in an isolated `.mutation-runs//work` copy: mutate operators in the + source and check your tests catch it (see + [Mutation Testing](#mutation-testing)) ### Running Individual Test Files -**Do NOT use `deno task test -- --filter`** to debug a specific test — it still loads the entire test suite and is very slow. - -Instead, use `deno task test:files`, which runs only the files you pass but reuses the full runner's setup — it makes sure the static client assets the app reads at import time are current, and starts stripe-mock with `STRIPE_MOCK_HOST/PORT` exported. This means a fresh checkout can run a subset of the suite without manual preparation. - -Both runners *skip* the asset build when nothing it depends on has changed. After a build they record every file it read and wrote in `.static-assets-cache.json`, each one as a hash of its contents, and the next run hashes them again: if every file is byte-for-byte what it was, the assets on disk are already correct and esbuild and sass are never even loaded. That is about 0.8s off every run, so the built assets are now left in the tree afterwards (they are gitignored build output, and keeping them is what makes the next run fast). *Change* any client source, stylesheet, `deno.json`, or `deno.lock` — or delete one of the built files — and the next run rebuilds. Re-saving a file without changing its contents does not: the bytes decide, not the timestamp. +**Do NOT use `deno task test -- --filter`** to debug a specific test — it still +loads the entire test suite and is very slow. + +Instead, use `deno task test:files`, which runs only the files you pass but +reuses the full runner's setup — it makes sure the static client assets the app +reads at import time are current, and starts stripe-mock with +`STRIPE_MOCK_HOST/PORT` exported. This means a fresh checkout can run a subset +of the suite without manual preparation. + +Both runners _skip_ the asset build when nothing it depends on has changed. +After a build they record every file it read and wrote in +`.static-assets-cache.json`, each one as a hash of its contents, and the next +run hashes them again: if every file is byte-for-byte what it was, the assets on +disk are already correct and esbuild and sass are never even loaded. That is +about 0.8s off every run, so the built assets are now left in the tree +afterwards (they are gitignored build output, and keeping them is what makes the +next run fast). _Change_ any client source, stylesheet, `deno.json`, or +`deno.lock` — or delete one of the built files — and the next run rebuilds. +Re-saving a file without changing its contents does not: the bytes decide, not +the timestamp. ```bash deno task test:files test/shared/dates.test.ts ``` -Arguments are forwarded verbatim to `deno test`, so multiple files, directories, and flags such as `--filter` all work: +Arguments are forwarded verbatim to `deno test`, so multiple files, directories, +and flags such as `--filter` all work: ```bash deno task test:files test/shared/dates.test.ts --filter "formats date" @@ -749,13 +1126,19 @@ deno task test:files test/shared/payments.test.ts specs/payments/capacity-after- #### Lower-level alternative -For a pure unit test that imports neither the app nor Stripe, you can skip the harness and run `deno test` directly on the file (fastest, but it fails on a missing `src/ui/static/*.js` asset or an unstarted stripe-mock if the test does import them): +For a pure unit test that imports neither the app nor Stripe, you can skip the +harness and run `deno test` directly on the file (fastest, but it fails on a +missing `src/ui/static/*.js` asset or an unstarted stripe-mock if the test does +import them): ```bash deno test --no-check --allow-all test/shared/dates.test.ts ``` -To do this for a test that depends on stripe-mock (anything importing Stripe), start the mock first (`deno task test:files` or `deno task test` does this for you, or run `.bin/stripe-mock -http-port 12111` manually) and set the env vars to the port you chose: +To do this for a test that depends on stripe-mock (anything importing Stripe), +start the mock first (`deno task test:files` or `deno task test` does this for +you, or run `.bin/stripe-mock -http-port 12111` manually) and set the env vars +to the port you chose: ```bash STRIPE_MOCK_HOST=localhost STRIPE_MOCK_PORT=12111 deno test --no-check --allow-all test/scripts/stripe-mock/ports.test.ts @@ -763,21 +1146,22 @@ STRIPE_MOCK_HOST=localhost STRIPE_MOCK_PORT=12111 deno test --no-check --allow-a ## Environment Variables -Environment variables are configured as **Bunny native secrets** in the Bunny Edge Scripting dashboard. They are read at runtime via `process.env`. +Environment variables are configured as **Bunny native secrets** in the Bunny +Edge Scripting dashboard. They are read at runtime via `process.env`. The optional static CDN is different: `CDN_URL`, `CDN_BUNNY_STORAGE_ZONE_NAME`, `CDN_BUNNY_STORAGE_ZONE_KEY`, `CDN_BUNNY_STORAGE_HOST`, and -`CDN_BUNNY_PULL_ZONE_ID` are GitHub repository secrets used only while -building. When all five are set, the build uploads site-independent browser -assets and image-codec WASM under an immutable content-addressed path, purges the -pull zone with the existing `BUNNY_ACCESS_KEY` repository secret, verifies every -public object byte-for-byte, then bakes -those public URLs and their CSP origin into the edge script. They must not be -added to the running Bunny script. With all five absent, assets stay embedded; -a partial set fails the build. Site-bound assets such as `embed.js` and the -dynamic `/order.js` body remain in each script. Use the Storage API hostname -shown on Bunny's Storage **Access** page for `CDN_BUNNY_STORAGE_HOST` (for -example, `storage.bunnycdn.com` or `uk.storage.bunnycdn.com`). +`CDN_BUNNY_PULL_ZONE_ID` are GitHub repository secrets used only while building. +When all five are set, the build uploads site-independent browser assets and +image-codec WASM under an immutable content-addressed path, purges the pull zone +with the existing `BUNNY_ACCESS_KEY` repository secret, verifies every public +object byte-for-byte, then bakes those public URLs and their CSP origin into the +edge script. They must not be added to the running Bunny script. With all five +absent, assets stay embedded; a partial set fails the build. Site-bound assets +such as `embed.js` and the dynamic `/order.js` body remain in each script. Use +the Storage API hostname shown on Bunny's Storage **Access** page for +`CDN_BUNNY_STORAGE_HOST` (for example, `storage.bunnycdn.com` or +`uk.storage.bunnycdn.com`). ### Required (configure in Bunny dashboard) @@ -788,47 +1172,136 @@ example, `storage.bunnycdn.com` or `uk.storage.bunnycdn.com`). ### Optional - `PORT` - Server port (defaults to 3000, local dev only) -- `BUNNY_API_KEY` - Bunny API key (required for custom domain management, with `BUNNY_SCRIPT_ID`) -- `BUNNY_SCRIPT_ID` - Bunny Edge Script ID (required for custom domain management, with `BUNNY_API_KEY`) +- `BUNNY_API_KEY` - Bunny API key (required for custom domain management, with + `BUNNY_SCRIPT_ID`) +- `BUNNY_SCRIPT_ID` - Bunny Edge Script ID (required for custom domain + management, with `BUNNY_API_KEY`) - `STORAGE_ZONE_NAME` - Bunny CDN storage zone name (required for image uploads) -- `STORAGE_ZONE_KEY` - Bunny CDN storage zone access key (required for image uploads) -- `BACKUP_PAGE_SIZE` - Rows read per keyset page when dumping a table for backup (default 500). Each page is one libsql response, so this bounds the response size to stay under libsqld's "Response is too large" payload cap. Used by `deno task backup` and the admin Backups page; migrations no longer back up inline (the edge subrequest budget can't fit a full dump), so backups are taken out-of-band. -- `MAIN_INSTANCE_KEY` - Shared secret authorizing the inter-instance site-credentials endpoint (`POST /instance/site-credentials`). When set on a builder/main instance, that endpoint returns built sites' DB URL + token to a caller presenting this key as a bearer token, so the upgrade workflow can back each site up to the builder's storage before deploying. The returned token is each site's own full-access credential (the same one the site runs with) — callers only read, but must treat the response as write-capable production secrets. The caller passes the release tier it is publishing as `?tier=alpha|beta|release` (a tier-less call defaults to `release` ⇒ the whole fleet, which is what the single-site `backup-site` action relies on); each site carries an `updates` channel and only the sites at that tier or more eager are returned (a `release` deploy reaches every site, `beta` reaches beta + alpha sites, `alpha` only alpha sites — an unknown tier is a 400). The response echoes the applied `tier` so a caller can confirm the server actually filtered: a pre-tier build ignores the query string and omits it, letting the canary workflow fail closed instead of fanning a non-release deploy out to the whole fleet. Unset `MAIN_INSTANCE_KEY` ⇒ the endpoint is disabled (404). The upgrade workflow receives the key as a run-time input, not a stored GitHub secret. -- `DENO_DEPLOY_TOKEN` - Deno Deploy organization access token. Required with `DENO_DEPLOY_ORG_ID` and `DENO_DEPLOY_ORG_SLUG` to build sites on Deno Deploy. -- `DENO_DEPLOY_ORG_ID` - Deno Deploy organization ID used by the app creation API. -- `DENO_DEPLOY_ORG_SLUG` - Deno Deploy organization slug used in each app's managed `..deno.net` production domain. -- `BUNNY_DNS_ZONE_ID` - Bunny DNS zone ID for subdomain registration (enables subdomain feature when set with `BUNNY_API_KEY`) -- `BUNNY_DNS_SUBDOMAIN_SUFFIX` - Suffix appended to user-chosen subdomain (e.g. `.tickets`) -- `NTFY_URL` - Ntfy endpoint URL for error notifications (e.g. `https://ntfy.sh/your-topic`). Sends domain and error code only, no personal or encrypted data. -- `SENTRY_URL` - Sentry DSN for server-side error reporting (e.g. a self-hosted Bugsink: `https://@bugs.example.com/`). When set, the same classified server errors that log to the console and ping ntfy are also captured by Sentry, with a real stack trace when the originating exception is available. Unset ⇒ Sentry is disabled (the SDK never initializes). The release is `chobble-tickets@`, matching the source maps the deploy workflows upload; readable (un-minified) traces additionally require the `SENTRY_AUTH_TOKEN`, `SENTRY_CLI_URL` (the instance base URL, e.g. `https://bugs.example.com/`), `SENTRY_ORG`, and `SENTRY_PROJECT` GitHub Actions secrets so the deploy can inject debug IDs and upload the maps. Without those secrets the deploy still works; traces just stay minified. -- `UPTIME_KUMA_URL` - Uptime Kuma 2.4 or newer base URL used by builder instances to inspect and add built-site scheduled maintenance monitors. Requires `CAN_BUILD_SITES=true`, `UPTIME_KUMA_USERNAME`, and `UPTIME_KUMA_PASSWORD`. -- `UPTIME_KUMA_USERNAME` - Uptime Kuma username. Must be set with `UPTIME_KUMA_URL` and `UPTIME_KUMA_PASSWORD`. -- `UPTIME_KUMA_PASSWORD` - Uptime Kuma password. Must be set with `UPTIME_KUMA_URL` and `UPTIME_KUMA_USERNAME`. -- `UPTIME_KUMA_INTERVAL_MINUTES` - Optional positive whole number controlling how often new built-site monitors run. Defaults to `15`. -- `DEBUG_KEY` - Optional diagnostic key. `GET /health` returns a plain `Up :)` by default; a request with a matching `X-Debug-Key` header instead returns JSON build diagnostics (commit, build timestamp, server time) — non-private but useful to operators. Unset ⇒ verbose health disabled. The running build also records its commit into `settings.current_script_commit` on boot, so a backup carries the commit the site was on and a restore can surface which commit to redeploy (via `.github/workflows/restore-deploy.yml`). -- `BOTPOISON_PUBLIC_KEY` - Optional Botpoison public key (sent to the browser). The contact form works without it; setting it together with `BOTPOISON_SECRET_KEY` adds proof-of-work spam protection as a progressive enhancement. The owner still enables the form under Site → Contact and sets a business email. -- `BOTPOISON_SECRET_KEY` - Optional Botpoison secret key. Used server-side to verify contact form submissions when Botpoison is enabled. Never sent to the browser. -- `ADMIN_EMAIL_ADDRESS` - Enables a superuser recovery option in owner settings. The local-part (before `@`) must be a valid app username (2–32 characters, letters, numbers, hyphens, underscores). Email delivery must be configured before the superuser can be enabled. Also enables the owner-only **Support** page (`/admin/support`), where the operator can message this address. -- `SUPPORT_PAGE_TEXT` - Optional markdown shown at the top of the Support page (requires `ADMIN_EMAIL_ADDRESS`). Use literal `\n` for line breaks since Bunny secrets can't hold real newlines. When unset, a placeholder note is shown instead. The support form below it (which delivers to `ADMIN_EMAIL_ADDRESS`) needs a business email to be set, like the public contact form. -- `SUPPORT_FORM_NAG_DAYS` - Optional positive integer (default `7`). For this many days after a support-form submission, the Support page shows a "you last submitted this form …" notice to discourage duplicate messages. -- `I18N_REPLACEMENTS` - Optional comma-separated `from|to` substring replacements that rebrand the **translatable copy** of every rendered message, e.g. `ticket|booking,attendee|guest`. Matching is case-insensitive and by substring (`ticket|booking` turns `tickets` into `bookings`), and the output copies the source word's capitalisation — `Ticket` → `Booking`, `ticket` → `booking` (only lowercase and title-case occur in real copy). It is applied to each message **template** once at load, and the rebranded template is compiled and cached, so rendering stays a plain ICU format with no per-call cost (important on a cold-booting edge runtime). It deliberately leaves alone: HTML tags and attributes (so link `href`s survive), `` examples (literal route/CLI text), interpolated values such as a stored listing name (so "type this exact name" confirmations still match), and the fallback key returned for a missing translation. Avoid terms that collide with ICU keywords or placeholder names (`name`, `count`, `plural`, …). -- `APPLE_WALLET_PASS_TYPE_ID` - Apple Wallet Pass Type ID (e.g. `pass.com.example.tickets`) +- `STORAGE_ZONE_KEY` - Bunny CDN storage zone access key (required for image + uploads) +- `BACKUP_PAGE_SIZE` - Rows read per keyset page when dumping a table for backup + (default 500). Each page is one libsql response, so this bounds the response + size to stay under libsqld's "Response is too large" payload cap. Used by + `deno task backup` and the admin Backups page; migrations no longer back up + inline (the edge subrequest budget can't fit a full dump), so backups are + taken out-of-band. +- `MAIN_INSTANCE_KEY` - Shared secret authorizing the inter-instance + site-credentials endpoint (`POST /instance/site-credentials`). When set on a + builder/main instance, that endpoint returns built sites' DB URL + token to a + caller presenting this key as a bearer token, so the upgrade workflow can back + each site up to the builder's storage before deploying. The returned token is + each site's own full-access credential (the same one the site runs with) — + callers only read, but must treat the response as write-capable production + secrets. The caller passes the release tier it is publishing as + `?tier=alpha|beta|release` (a tier-less call defaults to `release` ⇒ the whole + fleet, which is what the single-site `backup-site` action relies on); each + site carries an `updates` channel and only the sites at that tier or more + eager are returned (a `release` deploy reaches every site, `beta` reaches + beta + alpha sites, `alpha` only alpha sites — an unknown tier is a 400). The + response echoes the applied `tier` so a caller can confirm the server actually + filtered: a pre-tier build ignores the query string and omits it, letting the + canary workflow fail closed instead of fanning a non-release deploy out to the + whole fleet. Unset `MAIN_INSTANCE_KEY` ⇒ the endpoint is disabled (404). The + upgrade workflow receives the key as a run-time input, not a stored GitHub + secret. +- `DENO_DEPLOY_TOKEN` - Deno Deploy organization access token. Required with + `DENO_DEPLOY_ORG_ID` and `DENO_DEPLOY_ORG_SLUG` to build sites on Deno Deploy. +- `DENO_DEPLOY_ORG_ID` - Deno Deploy organization ID used by the app creation + API. +- `DENO_DEPLOY_ORG_SLUG` - Deno Deploy organization slug used in each app's + managed `..deno.net` production domain. +- `BUNNY_DNS_ZONE_ID` - Bunny DNS zone ID for subdomain registration (enables + subdomain feature when set with `BUNNY_API_KEY`) +- `BUNNY_DNS_SUBDOMAIN_SUFFIX` - Suffix appended to user-chosen subdomain (e.g. + `.tickets`) +- `NTFY_URL` - Ntfy endpoint URL for error notifications (e.g. + `https://ntfy.sh/your-topic`). Sends domain and error code only, no personal + or encrypted data. +- `SENTRY_URL` - Sentry DSN for server-side error reporting (e.g. a self-hosted + Bugsink: `https://@bugs.example.com/`). When set, the same + classified server errors that log to the console and ping ntfy are also + captured by Sentry, with a real stack trace when the originating exception is + available. Unset ⇒ Sentry is disabled (the SDK never initializes). The release + is `chobble-tickets@`, matching the source maps the deploy workflows + upload; readable (un-minified) traces additionally require the + `SENTRY_AUTH_TOKEN`, `SENTRY_CLI_URL` (the instance base URL, e.g. + `https://bugs.example.com/`), `SENTRY_ORG`, and `SENTRY_PROJECT` GitHub + Actions secrets so the deploy can inject debug IDs and upload the maps. + Without those secrets the deploy still works; traces just stay minified. +- `UPTIME_KUMA_URL` - Uptime Kuma 2.4 or newer base URL used by builder + instances to inspect and add built-site scheduled maintenance monitors. + Requires `CAN_BUILD_SITES=true`, `UPTIME_KUMA_USERNAME`, and + `UPTIME_KUMA_PASSWORD`. +- `UPTIME_KUMA_USERNAME` - Uptime Kuma username. Must be set with + `UPTIME_KUMA_URL` and `UPTIME_KUMA_PASSWORD`. +- `UPTIME_KUMA_PASSWORD` - Uptime Kuma password. Must be set with + `UPTIME_KUMA_URL` and `UPTIME_KUMA_USERNAME`. +- `UPTIME_KUMA_INTERVAL_MINUTES` - Optional positive whole number controlling + how often new built-site monitors run. Defaults to `15`. +- `DEBUG_KEY` - Optional diagnostic key. `GET /health` returns a plain `Up :)` + by default; a request with a matching `X-Debug-Key` header instead returns + JSON build diagnostics (commit, build timestamp, server time) — non-private + but useful to operators. Unset ⇒ verbose health disabled. The running build + also records its commit into `settings.current_script_commit` on boot, so a + backup carries the commit the site was on and a restore can surface which + commit to redeploy (via `.github/workflows/restore-deploy.yml`). +- `BOTPOISON_PUBLIC_KEY` - Optional Botpoison public key (sent to the browser). + The contact form works without it; setting it together with + `BOTPOISON_SECRET_KEY` adds proof-of-work spam protection as a progressive + enhancement. The owner still enables the form under Site → Contact and sets a + business email. +- `BOTPOISON_SECRET_KEY` - Optional Botpoison secret key. Used server-side to + verify contact form submissions when Botpoison is enabled. Never sent to the + browser. +- `ADMIN_EMAIL_ADDRESS` - Enables a superuser recovery option in owner settings. + The local-part (before `@`) must be a valid app username (2–32 characters, + letters, numbers, hyphens, underscores). Email delivery must be configured + before the superuser can be enabled. Also enables the owner-only **Support** + page (`/admin/support`), where the operator can message this address. +- `SUPPORT_PAGE_TEXT` - Optional markdown shown at the top of the Support page + (requires `ADMIN_EMAIL_ADDRESS`). Use literal `\n` for line breaks since Bunny + secrets can't hold real newlines. When unset, a placeholder note is shown + instead. The support form below it (which delivers to `ADMIN_EMAIL_ADDRESS`) + needs a business email to be set, like the public contact form. +- `SUPPORT_FORM_NAG_DAYS` - Optional positive integer (default `7`). For this + many days after a support-form submission, the Support page shows a "you last + submitted this form …" notice to discourage duplicate messages. +- `I18N_REPLACEMENTS` - Optional comma-separated `from|to` substring + replacements that rebrand the **translatable copy** of every rendered message, + e.g. `ticket|booking,attendee|guest`. Matching is case-insensitive and by + substring (`ticket|booking` turns `tickets` into `bookings`), and the output + copies the source word's capitalisation — `Ticket` → `Booking`, `ticket` → + `booking` (only lowercase and title-case occur in real copy). It is applied to + each message **template** once at load, and the rebranded template is compiled + and cached, so rendering stays a plain ICU format with no per-call cost + (important on a cold-booting edge runtime). It deliberately leaves alone: HTML + tags and attributes (so link `href`s survive), `` examples (literal + route/CLI text), interpolated values such as a stored listing name (so "type + this exact name" confirmations still match), and the fallback key returned for + a missing translation. Avoid terms that collide with ICU keywords or + placeholder names (`name`, `count`, `plural`, …). +- `APPLE_WALLET_PASS_TYPE_ID` - Apple Wallet Pass Type ID (e.g. + `pass.com.example.tickets`) - `APPLE_WALLET_TEAM_ID` - Apple Developer Team ID (e.g. `ABC1234567`) - `APPLE_WALLET_SIGNING_CERT` - PEM-encoded signing certificate - `APPLE_WALLET_SIGNING_KEY` - PEM-encoded signing private key - `APPLE_WALLET_WWDR_CERT` - PEM-encoded Apple WWDR intermediate certificate -Apple Wallet can be configured via env vars (all 5 required) or via the admin settings page. Admin settings (encrypted) take priority over env vars. If neither is configured, the feature is disabled. +Apple Wallet can be configured via env vars (all 5 required) or via the admin +settings page. Admin settings (encrypted) take priority over env vars. If +neither is configured, the feature is disabled. ### Stripe Configuration -Stripe is configured via the admin settings page (`/admin/settings`), not environment variables: +Stripe is configured via the admin settings page (`/admin/settings`), not +environment variables: - Enter your Stripe secret key in the admin settings - The webhook endpoint is automatically created in your Stripe account - The webhook signing secret is stored encrypted in the database -Admin password and currency code are set through the web-based setup page at `/setup/` and stored encrypted in the database. +Admin password and currency code are set through the web-based setup page at +`/setup/` and stored encrypted in the database. ## Deno Configuration @@ -842,7 +1315,8 @@ The project uses `deno.json` for configuration: Tests use Deno standard library packages directly: -- `@std/testing/bdd` — `describe`, `it` (aliased as `test`), `beforeEach`, `afterEach` +- `@std/testing/bdd` — `describe`, `it` (aliased as `test`), `beforeEach`, + `afterEach` - `@std/expect` — `expect()` assertions - `@std/testing/mock` — `spy()`, `stub()` for mocking - `@std/expect/fn` — `fn()` for mock functions @@ -900,20 +1374,36 @@ All tests must meet these mandatory criteria: ### 7. Assertion Strength and Mutation Resistance -- Treat 100% coverage as a hygiene floor, not proof that tests would catch meaningful regressions. -- Prefer assertions that would fail under realistic mutants: wrong arithmetic/operator, skipped validation, inverted permission checks, missing persistence, or omitted escaping. -- Avoid compound boolean assertions such as `expect(a && b).toBe(true)`; assert the observable contract directly with exact values, object shape, persisted rows, HTTP status/body, or rendered content. -- Avoid ending a test at `toBeTruthy()` / `toBeDefined()` unless mere existence is the actual user-visible contract. If existence matters, pair it with format, value, range, ordering, persistence, or security invariants. -- For pure functions, add table-driven or property-style examples that cover families of inputs and state the invariant being protected. Keep any generated cases deterministic. -- For critical flows, include negative-path, idempotency, concurrency, and metamorphic tests: e.g. payment/webhook replay does not double-credit, capacity cannot go below zero across edits/deletes, role downgrades remove access, and PII/secrets remain encrypted or absent from responses/logs. -- When generated or bulk-added tests are involved, run `deno task test:quality-audit` and review assertionless, truthiness, presence-only, and compound-boolean findings before trusting the coverage number. +- Treat 100% coverage as a hygiene floor, not proof that tests would catch + meaningful regressions. +- Prefer assertions that would fail under realistic mutants: wrong + arithmetic/operator, skipped validation, inverted permission checks, missing + persistence, or omitted escaping. +- Avoid compound boolean assertions such as `expect(a && b).toBe(true)`; assert + the observable contract directly with exact values, object shape, persisted + rows, HTTP status/body, or rendered content. +- Avoid ending a test at `toBeTruthy()` / `toBeDefined()` unless mere existence + is the actual user-visible contract. If existence matters, pair it with + format, value, range, ordering, persistence, or security invariants. +- For pure functions, add table-driven or property-style examples that cover + families of inputs and state the invariant being protected. Keep any generated + cases deterministic. +- For critical flows, include negative-path, idempotency, concurrency, and + metamorphic tests: e.g. payment/webhook replay does not double-credit, + capacity cannot go below zero across edits/deletes, role downgrades remove + access, and PII/secrets remain encrypted or absent from responses/logs. +- When generated or bulk-added tests are involved, run + `deno task test:quality-audit` and review assertionless, truthiness, + presence-only, and compound-boolean findings before trusting the coverage + number. ### Mutation Testing -`test:quality-audit` only *guesses* which assertions look weak. `deno task -mutation` **proves** it: it mutates operators in your source and checks whether -your tests fail. A mutant your tests still pass on ("survived") is a real gap — -a code change nothing would have caught. +`test:quality-audit` only _guesses_ which assertions look weak. +`deno task +mutation` **proves** it: it mutates operators in your source and +checks whether your tests fail. A mutant your tests still pass on ("survived") +is a real gap — a code change nothing would have caught. ```bash # Mutate a module's operators and run its mapped tests @@ -939,18 +1429,18 @@ one fresh state from the mutant and shares it across all integration-test batches. How it works (and why it is bespoke): it mutates the source file **in place**, -runs the mapped tests in a fresh `deno test` subprocess, then restores the -file. The normal `deno task mutation` command first copies the current checkout +runs the mapped tests in a fresh `deno test` subprocess, then restores the file. +The normal `deno task mutation` command first copies the current checkout (including dirty source/test edits, excluding `.git`, cache/report folders, local databases, secrets, and generated assets) to `.mutation-runs//work`; all in-place writes and per-mutant bundle rebuilds happen inside that copy, not the live files. A run deletes that copy as soon as it ends — reporting the -failure if it cannot — so -`.mutation-runs/` does not fill up with checkout copies. While a run is going — -and until the *next* run starts — it has a small `.mutation-runs//run.json` -holding the child PID/status, so a stray run is easy to find and stop. Starting -a run clears out the folders of every earlier run that is no longer going, -including any whose `run.json` is unreadable because it was killed mid-write: +failure if it cannot — so `.mutation-runs/` does not fill up with checkout +copies. While a run is going — and until the _next_ run starts — it has a small +`.mutation-runs//run.json` holding the child PID/status, so a stray run is +easy to find and stop. Starting a run clears out the folders of every earlier +run that is no longer going, including any whose `run.json` is unreadable +because it was killed mid-write: ```bash deno task mutation --list @@ -959,8 +1449,8 @@ deno task mutation --clean finished # or: / all ``` In-place mutation inside the copied checkout is what makes mutations bind -through `#…` import-map aliases. The operator tables and AST walk are vendored from -[Mutasaurus](https://github.com/christoshrousis/mutasaurus) (MIT); its own +through `#…` import-map aliases. The operator tables and AST walk are vendored +from [Mutasaurus](https://github.com/christoshrousis/mutasaurus) (MIT); its own execution model writes a temp copy but runs the original tests, so every mutant falsely "survives" on an alias-based project — see `scripts/mutation/LICENSE.mutasaurus.md`. As a manual tool it is **targeted** @@ -971,15 +1461,14 @@ this branch changed** (its committed diff against `origin/main`/`main`): the `precommit:mutation` step runs each source's mirror-located direct tests first, whether or not the direct tests changed, then runs changed `test/integration/`, `test/e2e/`, and `specs/` files only for survivors. A changed Cucumber step or -support file selects every Feature. Tests for unchanged sources, scripts, -and test helpers are outside that src mutation run. A standalone mutation -command still rejects any explicit test that neither mirrors a selected source -nor lives in an integration folder or `specs/`. The gate demands a 100% kill -rate, so the cost stays bounded to the source files you actually changed. Run -`deno task precommit:mutation` before merging a -branch that changes `src/` files; the standard `deno task precommit` no longer -runs it (it was too slow for every commit). -Known-equivalent survivors recorded in +support file selects every Feature. Tests for unchanged sources, scripts, and +test helpers are outside that src mutation run. A standalone mutation command +still rejects any explicit test that neither mirrors a selected source nor lives +in an integration folder or `specs/`. The gate demands a 100% kill rate, so the +cost stays bounded to the source files you actually changed. Run +`deno task precommit:mutation` before merging a branch that changes `src/` +files; the standard `deno task precommit` no longer runs it (it was too slow for +every commit). Known-equivalent survivors recorded in `scripts/mutation/equivalent-mutants/` are suppressed, as with a manual run. Never record `=== → ==`/`!== → !=` mutants: Biome's `noDoubleEquals` rule is configured to reject loose comparisons even against `null`, and the runner @@ -995,22 +1484,22 @@ one was edited meanwhile, which fails the run instead of overwriting the edit. Before it runs the mapped tests, the runner puts every mutant through two cheap **static gates**, ordered cheapest-first: a per-file Biome **lint** and then a -`deno check` **type-check**. Keep the Biome calls one-shot unless a new benchmark -proves otherwise: with pinned Biome 2.4.16, 20 warm one-file runs measured a -17.3 ms standalone median and a 51.2 ms `--use-server` median. Either gate -exiting non-zero kills the mutant without spending a full `deno test` on it — -both a forbidden lint diagnostic and a type error are build failures, so the +`deno check` **type-check**. Keep the Biome calls one-shot unless a new +benchmark proves otherwise: with pinned Biome 2.4.16, 20 warm one-file runs +measured a 17.3 ms standalone median and a 51.2 ms `--use-server` median. Either +gate exiting non-zero kills the mutant without spending a full `deno test` on it +— both a forbidden lint diagnostic and a type error are build failures, so the mutant could never ship, and static checks are far faster than the suite. The type-check gate catches the mutants that turn valid code into a type error — e.g. a `+ → *` swap on a string concatenation (`"a" * "b"` doesn't type-check), or any operator change that violates a parameter/return type. Each gate is only -trusted after the runner confirms the *unmutated* target passes it (the baseline +trusted after the runner confirms the _unmutated_ target passes it (the baseline probe): a standalone `deno task mutation` doesn't run `lint:ci`/`typecheck` first, so if the target isn't already clean the run aborts loudly rather than -scoring a bogus 100%. This means a mutant recorded in -`equivalent-mutants/` must be one that survives *both* gates *and* the tests; -a mutation that produces a type error never reaches the ignore-list because the -type-check gate kills it first. +scoring a bogus 100%. This means a mutant recorded in `equivalent-mutants/` must +be one that survives _both_ gates _and_ the tests; a mutation that produces a +type error never reaches the ignore-list because the type-check gate kills it +first. When a manual mutation run (or the precommit gate) surfaces survivors on a file you are touching — even on lines you did not change in this PR — they are yours @@ -1018,22 +1507,22 @@ to fix. Never determine whether a survivor "predates main" (no `git stash`, no diffing against the base to excuse it): the bar is 100%, and a survivor on a line in your changed file is a real gap in that file's tests that you are now the person best placed to close. Either write the assertion that kills it, or -record the mutant in `scripts/mutation/equivalent-mutants/` with a proof that -no input can distinguish it. "It was already there" is not a resolution; leaving -it just guarantees the next person trips over the same survivor. This is the -[Good citizen](#preferences) rule applied to mutation testing. -It is a best-effort check with two documented blind spots (see the header of -`scripts/precommit/mutation-step.ts`): it scopes to the -*committed* diff, so uncommitted work isn't checked until committed; and it -diffs against your *local* `origin/main`, never -re-fetching, so a stale local ref under a branch built on newer main commits can -leak upstream src into the set (run `git fetch origin main` first; a branch's own -author is unaffected). In each case, reach for `deno task mutation` on the -specific module. +record the mutant in `scripts/mutation/equivalent-mutants/` with a proof that no +input can distinguish it. "It was already there" is not a resolution; leaving it +just guarantees the next person trips over the same survivor. This is the +[Good citizen](#preferences) rule applied to mutation testing. It is a +best-effort check with two documented blind spots (see the header of +`scripts/precommit/mutation-step.ts`): it scopes to the _committed_ diff, so +uncommitted work isn't checked until committed; and it diffs against your +_local_ `origin/main`, never re-fetching, so a stale local ref under a branch +built on newer main commits can leak upstream src into the set (run +`git fetch origin main` first; a branch's own author is unaffected). In each +case, reach for `deno task mutation` on the specific module. ### Coverage Requirements -100% test coverage is required to merge into main. To find which specific lines are uncovered, run: +100% test coverage is required to merge into main. To find which specific lines +are uncovered, run: ```bash deno task test:coverage @@ -1047,9 +1536,9 @@ Use helpers from `#test-utils` instead of defining locally: ```typescript import { - mockRequest, - mockFormRequest, createTestDb, + mockFormRequest, + mockRequest, resetDb, } from "#test-utils"; ``` @@ -1067,57 +1556,57 @@ import { ### Fast Tests After every run the suite prints each test slower than 500ms -(`SLOW_TEST_THRESHOLD_MS` in `scripts/test-durations.ts`). Treat entries in -that report as regressions to fix, not ambient noise. These are the patterns -that keep tests fast — reach for them when writing the test, not after it -shows up in the report: - -- **The full runner shares isolates between test files.** `deno task test` - deals the suite's files into generated group entries - (`scripts/test-groups.ts`), so the app module graph is evaluated once per - group instead of once per file, and the harness prebuilds the test database - state — golden schema DB plus the captured setup ceremony — once per run - (`test/test-utils/test-state.ts`) instead of once per file. Two rules keep - a file groupable: never register a *global* BDD hook (a `beforeAll` / - `afterEach` at module level, including via a helper function called at - module level — put hooks inside your `describe`), and never rely on a - virgin isolate (module state you switch is visible to files that run after - you, so reset what you change — and state *other* files switched may be - visible to you, so pin what you assert on). A file that genuinely needs its - own isolate carries a `// test-groups: run-alone` comment. `deno task - test:files` never groups: you always debug exactly the files you name, one - isolate each, and `TICKETS_TEST_UNGROUPED=1 deno task test` runs the whole - suite that way to rule grouping out when chasing cross-file state. +(`SLOW_TEST_THRESHOLD_MS` in `scripts/test-durations.ts`). Treat entries in that +report as regressions to fix, not ambient noise. These are the patterns that +keep tests fast — reach for them when writing the test, not after it shows up in +the report: + +- **The full runner shares isolates between test files.** `deno task test` deals + the suite's files into generated group entries (`scripts/test-groups.ts`), so + the app module graph is evaluated once per group instead of once per file, and + the harness prebuilds the test database state — golden schema DB plus the + captured setup ceremony — once per run (`test/test-utils/test-state.ts`) + instead of once per file. Two rules keep a file groupable: never register a + _global_ BDD hook (a `beforeAll` / `afterEach` at module level, including via + a helper function called at module level — put hooks inside your `describe`), + and never rely on a virgin isolate (module state you switch is visible to + files that run after you, so reset what you change — and state _other_ files + switched may be visible to you, so pin what you assert on). A file that + genuinely needs its own isolate carries a `// test-groups: run-alone` comment. + `deno task + test:files` never groups: you always debug exactly the files you + name, one isolate each, and `TICKETS_TEST_UNGROUPED=1 deno task test` runs the + whole suite that way to rule grouping out when chasing cross-file state. - **Never run repo tooling as a subprocess inside a test.** jscpd, Biome, and typechecking are dedicated precommit/CI steps; a test that shells out to `deno task cpd` re-runs a minute of CPU inside every suite run to enforce a gate that already exists elsewhere. - **Never sleep for real.** A test driving a retry/backoff path (the write-lock - retry, migration verify retries — anything built on `retryWithBackoff`) - wraps the operation in `withVirtualBackoff` from `#test-utils`, which - advances a `FakeTime` clock timer-by-timer instead of genuinely waiting the - 50/150/350ms backoffs out. -- **Render once, assert many.** A suite making many assertions about ONE page - in its default fixture state uses `cachedAdminPage(path)` — the page - renders a single time and every test asserts against the cached HTML + retry, migration verify retries — anything built on `retryWithBackoff`) wraps + the operation in `withVirtualBackoff` from `#test-utils`, which advances a + `FakeTime` clock timer-by-timer instead of genuinely waiting the 50/150/350ms + backoffs out. +- **Render once, assert many.** A suite making many assertions about ONE page in + its default fixture state uses `cachedAdminPage(path)` — the page renders a + single time and every test asserts against the cached HTML (`test/integration/server/guide.test.ts` is the reference). Tests that alter config, env, or fixture data still fetch their own copy. - **Seed volume with a batch, not a loop.** When a test needs many rows (e.g. - filling a pagination page), create ONE record through the production path - and clone its rows in a single batch — see `seedFillerAttendees` in + filling a pagination page), create ONE record through the production path and + clone its rows in a single batch — see `seedFillerAttendees` in `test/test-utils/db-helpers/attendee-seeding.ts` — instead of running the full production write path N times. -- **Shard inherently heavy suites.** A suite that is minutes of sequential - work by nature (the migration restore/chain suites) is split into shard - files driven by one factory so `deno test --parallel` spreads it across - workers — see `test/integration/db/migration-restore/` (shard by - `index % shardCount`, which stays balanced as the list grows). -- **Keep heavy SDKs out of module load.** Every test isolate — a group of - files under the full runner, each named file under `test:files` — evaluates - the whole app module graph, so an import-time SDK evaluation is paid once - per isolate, dozens of times per run. Dynamically import heavy - dependencies on first use; `stripe.ts` and `sentry.ts` are the references - (this is the [cold-start rule](#built-for-cold-starts) applied to tests). +- **Shard inherently heavy suites.** A suite that is minutes of sequential work + by nature (the migration restore/chain suites) is split into shard files + driven by one factory so `deno test --parallel` spreads it across workers — + see `test/integration/db/migration-restore/` (shard by `index % shardCount`, + which stays balanced as the list grows). +- **Keep heavy SDKs out of module load.** Every test isolate — a group of files + under the full runner, each named file under `test:files` — evaluates the + whole app module graph, so an import-time SDK evaluation is paid once per + isolate, dozens of times per run. Dynamically import heavy dependencies on + first use; `stripe.ts` and `sentry.ts` are the references (this is the + [cold-start rule](#built-for-cold-starts) applied to tests). - **`expect(bigHtml).toContain(...)` is safe here** because `#test-utils` overrides the matcher (`test/test-utils/fast-expect.ts`): the @std/expect built-in pretty-prints the entire searched value even when the assertion diff --git a/src/features/api/payment-processing/snapshot/io.ts b/src/features/api/payment-processing/snapshot/io.ts index d635a5eaa4..eb31f2b3b0 100644 --- a/src/features/api/payment-processing/snapshot/io.ts +++ b/src/features/api/payment-processing/snapshot/io.ts @@ -176,6 +176,20 @@ type RawGroupRow = { id: number; name: EnvKeyEncrypted; }; +type ChildEdgeRow = { + child_listing_id: number; + parent_listing_id: number; +}; +type LedgerRow = { + has_legs: number; + owner_attendee_id: number | null; +}; +type ListingIdRow = { + listing_id: number; +}; +type ModifierScopeRow = ListingIdRow & { + modifier_id: number; +}; type RawModifierRow = { calc_kind: SnapshotModifierRow["calcKind"]; calc_value: number; @@ -186,6 +200,17 @@ type RawModifierRow = { scope: SnapshotModifierRow["scope"]; trigger: SnapshotModifierRow["trigger"]; }; +type RawDayPriceRow = ListingIdRow & { + days: number; + group_id: number; + unit_price: number; +}; +type PublicStatusRow = { + id: number; +}; +type VisitCountRow = { + visits: number; +}; const mapListings = async ( rows: ListingRecordRow[], @@ -236,51 +261,50 @@ export const loadPaidOrderSnapshot = async ( bookingEventGroup(eventId), usableContactHashes(intent), ]); - const results = await queryBatch( - snapshotStatements(eventGroup, intent, contactHashes), - ); - const ledger = resultRows<{ - has_legs: number; - owner_attendee_id: number | null; - }>(results[0]!)[0]!; + const [ + ledgerResult, + listingsResult, + groupsResult, + membershipsResult, + dayPricesResult, + hiddenMembersResult, + childEdgesResult, + modifiersResult, + modifierScopesResult, + visitCountsResult, + publicStatusesResult, + ] = await queryBatch(snapshotStatements(eventGroup, intent, contactHashes)); + const ledger = resultRows(ledgerResult!)[0]!; const rows: SnapshotRows = { - childEdges: resultRows<{ - child_listing_id: number; - parent_listing_id: number; - }>(results[6]!).map((row) => ({ + childEdges: resultRows(childEdgesResult!).map((row) => ({ childId: row.child_listing_id, parentId: row.parent_listing_id, })), - groups: await mapGroups(resultRows(results[2]!)), - hiddenMemberIds: resultRows<{ listing_id: number }>(results[5]!).map( + groups: await mapGroups(resultRows(groupsResult!)), + hiddenMemberIds: resultRows(hiddenMembersResult!).map( (row) => row.listing_id, ), ledger: { hasLegs: ledger.has_legs === 1, ownerAttendeeId: ledger.owner_attendee_id, }, - listings: await mapListings(resultRows(results[1]!)), - memberships: resultRows(results[3]!), - modifierScopes: resultRows<{ listing_id: number; modifier_id: number }>( - results[8]!, - ).map((row) => ({ - listingId: row.listing_id, - modifierId: row.modifier_id, - })), - modifiers: await mapModifiers(resultRows(results[7]!)), - publicStatusIds: resultRows<{ id: number }>(results[10]!).map( + listings: await mapListings(resultRows(listingsResult!)), + memberships: resultRows(membershipsResult!), + modifierScopes: resultRows(modifierScopesResult!).map( + (row) => ({ + listingId: row.listing_id, + modifierId: row.modifier_id, + }), + ), + modifiers: await mapModifiers(resultRows(modifiersResult!)), + publicStatusIds: resultRows(publicStatusesResult!).map( (row) => row.id, ), - visitCounts: resultRows<{ visits: number }>(results[9]!).map( + visitCounts: resultRows(visitCountsResult!).map( (row) => row.visits, ), }; - const dayPrices = resultRows<{ - days: number; - group_id: number; - listing_id: number; - unit_price: number; - }>(results[4]!).map( + const dayPrices = resultRows(dayPricesResult!).map( (row): SnapshotDayPriceRow => ({ days: row.days, groupId: row.group_id, From 891073f1dbbc5b668eb02493c969016252aa2723 Mon Sep 17 00:00:00 2001 From: Stefan Date: Fri, 7 Aug 2026 07:00:38 +0100 Subject: [PATCH 5/5] Clarify policy file limits --- AGENTS.md | 40 +- TODO.md | 1253 +++++++++++++++++++++++++++-------------------------- 2 files changed, 670 insertions(+), 623 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5029477644..7cc04fd638 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -168,25 +168,27 @@ GitHub. `agentPage`/`requireAgentOr` were an agent-only page+guard pair with no route wiring (agents are gated via `deliveryPage`/`requireDeliveryOr`), so both were deleted rather than exempted. -- **Keep files under ~400 lines**: When refactoring a file, aim to keep it under - 400 lines — and if hitting that target means splitting one file into several, - so be it: a new file is cheaper than an overloaded one. When you end up with a - handful of files all about the same thing, group them in a folder and give - them shorter names that don't repeat the folder's name (`ledger/project.ts`, - not `ledger/ledger-project.ts` — see the `src/shared/ledger/` and - `src/shared/db/attendees/` examples in [Modularised](#modularised)). While - you're at it, use the split as a chance to separate pure from non-pure code — - push the data-in/data-out logic into its own file and keep the IO in a thin - shell (see [Pure, functional](#pure-functional)). **The same 400-line limit - applies to test files**, and matters just as much: smaller, more specific test - files let us run mutation tests far faster, because a source file's mutants - only need to run against the narrow test file that covers it, not one giant - suite. Biome enforces a hard 1,000-line ceiling as a lint error - (`nursery.noExcessiveLinesPerFile` in `biome.json`); it applies to every file, - with no exceptions — never add an override to let one file past it. (Expect a - known side effect when splitting: jscpd cannot fully scan very large files, so - a split routinely _surfaces_ duplication that was silently passing inside the - monolith — budget for extracting helpers, not just moving tests.) +- **Keep code and test files under ~400 lines**: When refactoring a code or test + file, aim to keep it under 400 lines — and if hitting that target means + splitting one file into several, so be it: a new file is cheaper than an + overloaded one. When you end up with a handful of files all about the same + thing, group them in a folder and give them shorter names that don't repeat + the folder's name (`ledger/project.ts`, not `ledger/ledger-project.ts` — see + the `src/shared/ledger/` and `src/shared/db/attendees/` examples in + [Modularised](#modularised)). While you're at it, use the split as a chance to + separate pure from non-pure code — push the data-in/data-out logic into its + own file and keep the IO in a thin shell (see + [Pure, functional](#pure-functional)). **The same 400-line limit applies to + test files**, and matters just as much: smaller, more specific test files let + us run mutation tests far faster, because a source file's mutants only need to + run against the narrow test file that covers it, not one giant suite. Biome + enforces a hard 1,000-line ceiling for every code and test file; never add an + override to let one past it. Root instruction files such as `AGENTS.md` are + exempt because their policy must be available as one automatically loaded + document, but their sections should still stay concise. (Expect a known side + effect when splitting: jscpd cannot fully scan very large files, so a split + routinely _surfaces_ duplication that was silently passing inside the monolith + — budget for extracting helpers, not just moving tests.) - **Good citizen — fix what you spot**: If you notice a bug, a coverage gap, or a flaky/fragile test while working — even in code you were not asked to touch and did not write — fix it in passing rather than stepping around it. A green diff --git a/TODO.md b/TODO.md index 0d680f02e1..f26d56553a 100644 --- a/TODO.md +++ b/TODO.md @@ -4,27 +4,28 @@ `deno task mutation --kill` signals the child pid stored in the run record (`signalRun` in `scripts/mutation/isolation.ts`). PR #2042 shrank the window -where that pid can be somebody else's — the record drops the pid the moment -the child's status resolves (`markChildEnded`) — but a kill that reads the -record in the few milliseconds between the child exiting and that record -write can still signal a pid the child no longer owns. CodeRabbit suggested -removing the race outright by making the stop supervisor-mediated: store the -supervisor's pid in the run record too, have `--kill` signal the supervisor, -and let the supervisor stop its own child (it holds the child handle, so no -reused pid can be confused with it). Out of scope for #2042 — it changes the -record shape and the kill flow rather than the locking this PR unified. -Starting point: `signalRun` and `markRunning` in -`scripts/mutation/isolation.ts` / `scripts/mutation/isolation-state.ts`. +where that pid can be somebody else's — the record drops the pid the moment the +child's status resolves (`markChildEnded`) — but a kill that reads the record in +the few milliseconds between the child exiting and that record write can still +signal a pid the child no longer owns. CodeRabbit suggested removing the race +outright by making the stop supervisor-mediated: store the supervisor's pid in +the run record too, have `--kill` signal the supervisor, and let the supervisor +stop its own child (it holds the child handle, so no reused pid can be confused +with it). Out of scope for #2042 — it changes the record shape and the kill flow +rather than the locking this PR unified. Starting point: `signalRun` and +`markRunning` in `scripts/mutation/isolation.ts` / +`scripts/mutation/isolation-state.ts`. --- ## Numbered SQL parameters — adopt the pattern beyond the limiters (from PR #2040) -PR #2040 rewrote the two rate-limiter upserts (`src/shared/db/login-attempts.ts`, -`src/shared/db/token-attempts.ts`) to use SQLite's numbered parameters -(`?1`..`?6`), with each number given a named fragment constant (`NOW`, -`TOKEN_LIMIT`, …) that the SQL template interpolates. That turned a 25-slot -repeated positional args array into one value per meaning. Follow-ups: +PR #2040 rewrote the two rate-limiter upserts +(`src/shared/db/login-attempts.ts`, `src/shared/db/token-attempts.ts`) to use +SQLite's numbered parameters (`?1`..`?6`), with each number given a named +fragment constant (`NOW`, `TOKEN_LIMIT`, …) that the SQL template interpolates. +That turned a 25-slot repeated positional args array into one value per meaning. +Follow-ups: - **Sweep other multi-use statements.** Any statement that binds the same value more than once is a candidate — look for args arrays that repeat a variable @@ -33,15 +34,36 @@ repeated positional args array into one value per meaning. Follow-ups: single-use `?` statements are fine as they are. - **Consider a small define-style helper.** Something like `defineStatement({ ip: v.string(), now: v.number() }, (p) => sql\`... ${p.ip} - ...\`)` could hand back `{ sql, bind({ip, now}) }` so the parameter order + ...\`)`could hand back`{ sql, bind({ip, now}) + }`so the parameter order lives in one place and callers pass an object instead of an ordered array — - the same schema-first shape as `defineTable`/`defineForm`. Only worth it if - the sweep finds enough call sites; two files may not justify the machinery. + the same schema-first shape as`defineTable`/`defineForm`. + Only worth it if the sweep finds enough call sites; two files may not justify + the machinery. - Starting point: the fragment-constant pattern at the top of `src/shared/db/token-attempts.ts`. --- +## Stop printing new database tokens during Turso migration (from PR #2048) + +CodeRabbit found that `scripts/turso-migration-steps.ts` prints the new +full-access `DB_TOKEN` to stdout after a successful migration. The site +migration also tells an operator to copy the printed token when automatic Bunny +secret updates fail. Removing that output without a replacement would remove the +only documented recovery path, so this needs a separate security design rather +than a payment-processing change. + +Choose and document a secure hand-off for newly created Turso credentials. It +must support recovery when `scripts/site-migration/run.ts` cannot update Bunny +secrets, without putting the token in terminal logs. Then remove every token +stdout path and update the success, failure, and recovery tests. Starting +points: `scripts/turso-migration-steps.ts`, `scripts/site-migration/run.ts`, +`test/scripts/turso-migration.test.ts`, and +`test/scripts/site-migration/run.test.ts`. + +--- + This file tracks work that was planned but **not yet done** when the root-level planning/design docs were retired (they had served their purpose once the bulk of each feature shipped). Each section is written to stand on its own — you @@ -64,8 +86,8 @@ everything still outstanding is captured below. ## Codex Security scan follow-ups -*Origin: Codex Security scan completed on 2026-07-29 at -`/home/user/.codex/state/plugins/codex-security/scans/tickets/codex-security-tickets-qkJ7hC/`.* +_Origin: Codex Security scan completed on 2026-07-29 at +`/home/user/.codex/state/plugins/codex-security/scans/tickets/codex-security-tickets-qkJ7hC/`._ Findings 2 and 4 are active worktree jobs: @@ -76,10 +98,10 @@ Findings 2 and 4 are active worktree jobs: Finding 1 (delivery-agent access to check-in attendee details) shipped on PR #1995. -These are the remaining scan items that still look worth doing under the -current trust model. They assume Bunny Edge remains the production runtime, -site owners are trusted with their own content and integrations, and deployment -operators own the risk of choosing deliberately hostile third-party endpoints. +These are the remaining scan items that still look worth doing under the current +trust model. They assume Bunny Edge remains the production runtime, site owners +are trusted with their own content and integrations, and deployment operators +own the risk of choosing deliberately hostile third-party endpoints. - **Preserve the client IP in production request scopes.** `src/edge.ts`, `src/deploy.ts`, and `src/serve-app.ts` should carry the platform connection @@ -88,21 +110,21 @@ operators own the risk of choosing deliberately hostile third-party endpoints. IPs do not share a limiter row. - **Stop cross-origin redirects from replaying secrets or PII.** The shared fetch path in `src/shared/safe-fetch.ts` is used by registration webhooks and - SMS delivery. Do not let a cross-origin redirect replay attendee data, - ticket capability links, or Basic credentials. Prefer failing closed on - cross-origin redirects unless a caller has a very narrow, tested reason to - follow one. -- **Make attachment caching match signed URL access.** `src/features/attachments.ts` - and the middleware currently let public caches keep a time-limited attachment - response longer than the URL authorization window. Set cache headers from the - signed URL expiry, or make private attachment responses non-publicly - cacheable, and test the exact header on a signed attachment download. -- **Escape spreadsheet formulas in attendee CSV exports.** CSV fields that - start with spreadsheet formula characters need a safe prefix before export. - Keep the escaping in the shared CSV writer if it applies to every human-opened - export, or in `src/features/admin/attendees-csv.ts` if attendee exports are - the only affected surface. Add a regression test with attacker-controlled - attendee names, emails, and answers. + SMS delivery. Do not let a cross-origin redirect replay attendee data, ticket + capability links, or Basic credentials. Prefer failing closed on cross-origin + redirects unless a caller has a very narrow, tested reason to follow one. +- **Make attachment caching match signed URL access.** + `src/features/attachments.ts` and the middleware currently let public caches + keep a time-limited attachment response longer than the URL authorization + window. Set cache headers from the signed URL expiry, or make private + attachment responses non-publicly cacheable, and test the exact header on a + signed attachment download. +- **Escape spreadsheet formulas in attendee CSV exports.** CSV fields that start + with spreadsheet formula characters need a safe prefix before export. Keep the + escaping in the shared CSV writer if it applies to every human-opened export, + or in `src/features/admin/attendees-csv.ts` if attendee exports are the only + affected surface. Add a regression test with attacker-controlled attendee + names, emails, and answers. - **Escape booking data in HTML notification emails.** Public booking contact fields flow into owner notification email HTML. Keep intentional template markup working, but escape user-supplied field values before they reach @@ -124,8 +146,8 @@ operators own the risk of choosing deliberately hostile third-party endpoints. ## Marketing screenshot visual cleanup -*Origin: visual audit of the mobile Retina screenshots generated from -`../tickets-site/scripts/screenshots/` on 2026-07-18.* +_Origin: visual audit of the mobile Retina screenshots generated from +`../tickets-site/scripts/screenshots/` on 2026-07-18._ The screenshots use real application pages with scenario-specific custom CSS. Keep fixes in those scenarios unless the same problem also appears in the normal @@ -150,11 +172,10 @@ actual size before marking an item complete. end with whichever control was filled last still focused, producing an unrelated black or white outline: `charity-family-fun-day-checkout.png`, `promo-codes-and-add-ons-checkout.png`, `equipment-hire-booking.png`, - `the-tempest-group-checkout.png`, and - `garden-party-package-checkout.png`. Add one shared scenario helper that blurs - the active control before capture, then use it for all filled checkout - scenarios. Keep deliberate focus only in a screenshot that is specifically - demonstrating keyboard focus. + `the-tempest-group-checkout.png`, and `garden-party-package-checkout.png`. Add + one shared scenario helper that blurs the active control before capture, then + use it for all filled checkout scenarios. Keep deliberate focus only in a + screenshot that is specifically demonstrating keyboard focus. - **Stack the Garden Party email field on mobile.** In `scripts/screenshots/packages.js`, “Your Email” and its input are squeezed onto one row in `garden-party-package-checkout.png`, unlike the name field @@ -165,10 +186,10 @@ actual size before marking an item complete. - **Shorten the bulk-email preview.** In `scripts/screenshots/bulk-email.js`, `bulk-email-preview.png` is about twice as tall as it needs to be because the - warning copy, line height, and section gaps are oversized. Reduce the - scenario font size/line height and vertical spacing without hiding or - rewriting the real warning. Keep the recipients, subject, warning, and full - message preview visible. + warning copy, line height, and section gaps are oversized. Reduce the scenario + font size/line height and vertical spacing without hiding or rewriting the + real warning. Keep the recipients, subject, warning, and full message preview + visible. - **Tighten the balance summary.** In `scripts/screenshots/deposits-and-balance-payments.js`, the three totals in `deposits-and-balance-payments.png` have large vertical gaps and the payment @@ -187,10 +208,10 @@ actual size before marking an item complete. accessible brown accent and confirm the selected state remains obvious. - **Reduce the listing-form crop height if it stays readable.** In `scripts/screenshots/listing-management.js`, - `summer-sessions-listing-form.png` is nearly 2,000 pixels tall despite - already being limited to the Basics fieldset. Tighten field hints, editor - height, and section spacing rather than removing the date or venue. Keep all - text comfortably readable at the rendered `split-image` size. + `summer-sessions-listing-form.png` is nearly 2,000 pixels tall despite already + being limited to the Basics fieldset. Tighten field hints, editor height, and + section spacing rather than removing the date or venue. Keep all text + comfortably readable at the rendered `split-image` size. **Final visual check:** @@ -204,7 +225,7 @@ actual size before marking an item complete. ## Booking unification — phases 3 & 4 -*Origin: `booking-unification.md`, `booking-unification-phase2.md`.* +_Origin: `booking-unification.md`, `booking-unification-phase2.md`._ **Background.** Bookings used to have three independently-grown models: a normal listing, parent/child listings (`listing_parents`), and packages (`is_package` @@ -214,10 +235,12 @@ fixed / hidden items are just configurations of one structure, walked by five generalized passes: render, fold, price, capacity, revalidate. **Already shipped (phases 1 & 2, PR #1462) — do not redo:** + - Tree model + pure builder: `src/shared/booking/tree.ts`, `build-tree.ts` - (`buildBookingTree`). The public renderer `src/ui/templates/public/ - reservations/` (entry point `ticket-page.tsx`) drives field names/rendering - off the tree. + (`buildBookingTree`). The public renderer + `src/ui/templates/public/ + reservations/` (entry point `ticket-page.tsx`) + drives field names/rendering off the tree. - Unified walks: `fold-tree.ts` (`foldBookingTree`), `price-tree.ts` (`effectivePrice`, `priceRuleByListingId`, `packageMemberPriceRule`), `capacity-tree.ts` (`packageQuantityCap`, own-cap + group-pool arms). @@ -225,11 +248,11 @@ generalized passes: render, fold, price, capacity, revalidate. adapter over `foldBookingTree`. Pricing flows through `effectivePrice` in `ticket-payment.ts`, `ticket-submit.ts`, `api/index.ts`, `payment-processing.ts`, `webhook.ts`. -- v2 signed per-node metadata: `BookingItemSchema` (`src/shared/.../payments.ts`, - ~line 67) is `{e,q,p}` plus optional edge tags `k` (`"p"`/`"g"`) and `r` - (group id); `signed-metadata.ts` (`signedEdgeFor`); webhook re-walk in - `payment-processing.ts` (`validateAllItems`, `packageBundleMismatch`, - `classifySession`). +- v2 signed per-node metadata: `BookingItemSchema` + (`src/shared/.../payments.ts`, ~line 67) is `{e,q,p}` plus optional edge tags + `k` (`"p"`/`"g"`) and `r` (group id); `signed-metadata.ts` (`signedEdgeFor`); + webhook re-walk in `payment-processing.ts` (`validateAllItems`, + `packageBundleMismatch`, `classifySession`). - A package member may itself be a parent: `isPackageableMember` (`src/shared/.../groups.ts`, ~line 115) now permits it. - **Row-level admin identity + per-path bookings (multi-package orders).** The @@ -239,13 +262,13 @@ generalized passes: render, fold, price, capacity, revalidate. each tagged `packageGroupId`; the booking-slot unique index and the merge/check-in row keys are widened with `package_group_id` (`2026-07-05_package_slot_identity` migration, `bookingKey`, - `bookingSlotKey`). `PagePackage` + `buildBookingTree` build one node per - path, and `/order` sells packages alongside listings via the pure - `#shared/order` evaluator (`options.ts`/`evaluate.ts`). The admin attendee - editor matches: one editable line per stored booking row (labelled with its - path), plus blank per-(package, member) lines behind a pure-CSS toggle, so - an operator can view, edit, and create every path combination a public - buyer could — JS-free (`attendee-form-model.ts`, `attendee-page-data.ts`). + `bookingSlotKey`). `PagePackage` + `buildBookingTree` build one node per path, + and `/order` sells packages alongside listings via the pure `#shared/order` + evaluator (`options.ts`/`evaluate.ts`). The admin attendee editor matches: one + editable line per stored booking row (labelled with its path), plus blank + per-(package, member) lines behind a pure-CSS toggle, so an operator can view, + edit, and create every path combination a public buyer could — JS-free + (`attendee-form-model.ts`, `attendee-page-data.ts`). **Remaining:** @@ -253,11 +276,11 @@ generalized passes: render, fold, price, capacity, revalidate. `listing_parents` and `group_listings` into a single edge table (or make one a view of the other). This is the only schema-migrating, hard-to-reverse phase, so only take it once a concrete need demands it. Shipping phases 1–2 and - stopping here is an explicitly *successful* outcome, not a half-finished one. + stopping here is an explicitly _successful_ outcome, not a half-finished one. - **Phase 4 — buyer-choice children inside a package (optional).** Let a package - member offer a buyer-selected child (the parent/child choice UI, nested under a - package). Build on demand when a real booking requires it. + member offer a buyer-selected child (the parent/child choice UI, nested under + a package). Build on demand when a real booking requires it. - **`/order` live availability: fold required-child demand into options.** The order gallery's evaluator (`#shared/order`) judges an option by its direct @@ -266,30 +289,30 @@ generalized passes: render, fold, price, capacity, revalidate. so two selections contending for a shared child pool read as available on the gallery and are refused at the form. Advisory-only today (the form is the authority — documented in `src/features/public/order.ts`); fixing it means - loading each option's children in `loadOrderCatalog` and adding the - guaranteed folded units (and their group pools) to `unitsByListingId`. - -- **Per-path sale amounts in the ledger projection.** A booking posts ONE - `sale` leg per listing (`bookingFactsFromOrder` sums the order's lines by - listing id; the leg reference is `["sale", listingId]`), and - `pricePaidFromLedger` splits that total across the listing's sibling rows in - quantity proportion. When one listing books through two paths at DIFFERENT - prices in one order (package override beside its own standalone row), the - per-row `price_paid` readback is therefore quantity-averaged — e.g. 4×400 - package units + 1×500 standalone reads back 1680/420 instead of 1600/500. - Order totals, revenue sums, and refunds are exact (the shares telescope); - only per-row display/merge granularity blurs, and only when per-path prices - differ. Fixing it needs a SQL-queryable per-path discriminator on sale legs - (a transfers schema addition — `reference` is a hash, `kind`/`dest_id` feed - reports) or re-storing the per-row amount, plus a fallback for pre-upgrade - rows whose legs are untagged. Do it when per-row money display matters more - than the schema stability of the append-only ledger. + loading each option's children in `loadOrderCatalog` and adding the guaranteed + folded units (and their group pools) to `unitsByListingId`. + +- **Per-path sale amounts in the ledger projection.** A booking posts ONE `sale` + leg per listing (`bookingFactsFromOrder` sums the order's lines by listing id; + the leg reference is `["sale", listingId]`), and `pricePaidFromLedger` splits + that total across the listing's sibling rows in quantity proportion. When one + listing books through two paths at DIFFERENT prices in one order (package + override beside its own standalone row), the per-row `price_paid` readback is + therefore quantity-averaged — e.g. 4×400 package units + 1×500 standalone + reads back 1680/420 instead of 1600/500. Order totals, revenue sums, and + refunds are exact (the shares telescope); only per-row display/merge + granularity blurs, and only when per-path prices differ. Fixing it needs a + SQL-queryable per-path discriminator on sale legs (a transfers schema addition + — `reference` is a hash, `kind`/`dest_id` feed reports) or re-storing the + per-row amount, plus a fallback for pre-upgrade rows whose legs are untagged. + Do it when per-row money display matters more than the schema stability of the + append-only ledger. - **Confirm the v1 drain bridge is genuinely unnecessary.** The original plan called for a bounded-window read-only parser for pre-cutover (v1) signed metadata plus a regression test for an old-shape session paid during the cutover window. No dedicated bridge was built. In practice the v2 schema added - `k`/`r` as *optional* fields to the existing `e/q/p` line shape, so old + `k`/`r` as _optional_ fields to the existing `e/q/p` line shape, so old sessions still parse (as standalone lines). Verify this covers every in-flight-session case and, if so, close this out; otherwise add the bridge + drain-window test. @@ -298,17 +321,20 @@ generalized passes: render, fold, price, capacity, revalidate. ## Entity pages migration — slices 4–5 -*Origin: `edit-pages.md`.* +_Origin: `edit-pages.md`._ **Background.** "Entity pages" is one declarative, schema-driven, tabbed framework (`defineEntityPage`) that replaces every hand-assembled admin "edit X" -page. A page becomes data: tabs of typed sections (summary / activity / actions / -custom), with per-tab authorization, path-segment tabs, and in-place 400-error +page. A page becomes data: tabs of typed sections (summary / activity / actions +/ custom), with per-tab authorization, path-segment tabs, and in-place 400-error re-rendering. Migration is deliberately gradual and hardest-first. **Already shipped — do not redo:** -- Framework: `src/shared/entity-pages/core.ts`, `src/features/admin/ - entity-pages.ts`, `src/ui/templates/admin/entity-pages.tsx`. + +- Framework: `src/shared/entity-pages/core.ts`, + `src/features/admin/ + entity-pages.ts`, + `src/ui/templates/admin/entity-pages.tsx`. - Attendees: `src/features/admin/attendee-page.ts` (slice 1, PRs #1500, #1502, #1503). - Listings: `src/features/admin/listing-page.ts` (slice 2). @@ -337,7 +363,7 @@ re-rendering. Migration is deliberately gradual and hardest-first. ## Servicing — read-only guard (optional variant) -*Origin: `servicing.md` (+ its review docs `review.md`, `tests.md`).* +_Origin: `servicing.md` (+ its review docs `review.md`, `tests.md`)._ **Background.** The servicing-events feature (attendee-kind rows that hold listing capacity without being customers) shipped and was hardened across PRs @@ -361,14 +387,15 @@ in `validateModifier` (so a percentage or multiplier keeps its precision). ## Test quality -*Origin: `TEST_QUALITY_IMPROVEMENTS.md`.* +_Origin: `TEST_QUALITY_IMPROVEMENTS.md`._ **Background.** The goal is to move past coverage-as-floor toward proving -*assertion strength*. The priority-1 initiative — **mutation testing as a gate** -— is fully shipped: `scripts/mutation.ts` + `scripts/mutation/`, `deno task -mutation` and `precommit:mutation` (staged-file gate, batched to bound file -descriptors — PRs #1478 and others). A weak-assertion audit script also exists: -`scripts/test-quality-audit.ts`. +_assertion strength_. The priority-1 initiative — **mutation testing as a gate** +— is fully shipped: `scripts/mutation.ts` + `scripts/mutation/`, +`deno task +mutation` and `precommit:mutation` (staged-file gate, batched to +bound file descriptors — PRs #1478 and others). A weak-assertion audit script +also exists: `scripts/test-quality-audit.ts`. **Remaining:** @@ -381,9 +408,9 @@ descriptors — PRs #1478 and others). A weak-assertion audit script also exists mutation cost comes down. - **Property-based tests (item 5).** `fast-check` is currently used in only one - test (`test/shared/booking/fold-tree.test.ts`). Add properties for: slug generation, CSV - round-trips (commas / quotes / CRLF), date formatting across timezones, token - parsers, and URL safety. + test (`test/shared/booking/fold-tree.test.ts`). Add properties for: slug + generation, CSV round-trips (commas / quotes / CRLF), date formatting across + timezones, token parsers, and URL safety. - **Weak-assertion audit lifecycle (item 6).** The script exists but isn't wired into CI. Escalate it: informational → CI warning → review gate for touched files. @@ -401,7 +428,7 @@ descriptors — PRs #1478 and others). A weak-assertion audit script also exists ## Settings on-demand loading — generation counter -*Origin: `settings-plan.md`.* +_Origin: `settings-plan.md`._ **Background.** The eager `settings.loadAll()` (decrypt every settings row on every request) was replaced by keyed, on-demand loading: @@ -432,10 +459,10 @@ keeps the bundles honest by failing when a route reads a key it didn't declare. the common configurations but is not a full per-candidate feasibility solver: partial-overlap cases stay approximate — pool-subset unions (e.g. three pick-1 slots over two 1-spot pools), a multi-pool candidate double-counted as - alternative supply, and jointly-infeasible cross-slot mixes. All fail SAFE (the - atomic submit write rejects; capacity is never clamped, only rejected), so the - cost is a rare dead-end submit or an over-advertised bundle, never overbooking. - Revisit only if a real configuration hits it. + alternative supply, and jointly-infeasible cross-slot mixes. All fail SAFE + (the atomic submit write rejects; capacity is never clamped, only rejected), + so the cost is a rare dead-end submit or an over-advertised bundle, never + overbooking. Revisit only if a real configuration hits it. - **Deferred choice-slot semantics.** Optional slots (a min/max pick count, e.g. "choose 0–2"); a per-slot "distinct picks" flag; per-package-unit pick mixes @@ -451,28 +478,28 @@ keeps the bundles honest by failing when a route reads a key it didn't declare. ## Test-suite speed — remaining opportunities -*Origin: the test-suite performance pass (lazy Sentry, fast `toContain`, -migration-suite sharding, `withVirtualBackoff`, `cachedAdminPage`; see the -Fast Tests section of AGENTS.md). These were identified during profiling but -deliberately left for later:* +_Origin: the test-suite performance pass (lazy Sentry, fast `toContain`, +migration-suite sharding, `withVirtualBackoff`, `cachedAdminPage`; see the Fast +Tests section of AGENTS.md). These were identified during profiling but +deliberately left for later:_ - **Per-file module-graph evaluation.** Every test file re-evaluates the app's module graph (~0.35s each after the lazy-Sentry fix, ~250 files ≈ 80-90s of CPU per run). The biggest remaining import-time chunks are `@libsql/client` (~65ms, needed) and the `#routes` feature tree (~150ms). Any further import-time work moved behind `once()`/dynamic import pays for itself ~250× - per run — profile with a `performance.now()` probe around `import("#test-utils")` - under `deno test` before and after. + per run — profile with a `performance.now()` probe around + `import("#test-utils")` under `deno test` before and after. - **`test/scripts/stripe-mock/ports.test.ts` (~4s)** spawns real child processes to test the harness's port handling; each spawn is inherently slow. If it - grows, the port-conflict cases could stub the child-process layer the same - way the supervisor tests do. + grows, the port-conflict cases could stub the child-process layer the same way + the supervisor tests do. --- ## Capacity rules — feature-layer adoption (stage 3) -*Origin: the capacity-rules consolidation (`src/shared/capacity-rules.ts`).* +_Origin: the capacity-rules consolidation (`src/shared/capacity-rules.ts`)._ Stages 1–2 shipped: the declarative `CAPACITY_RULES` table exists, and the SQL guard (`src/shared/db/capacity.ts`), the JS preflight (`src/shared/db/attendees/capacity.ts`, `update.ts`), and the booking-page @@ -480,12 +507,11 @@ limits (`booking/model.ts`, `booking/package-cap.ts`) all derive their per-date-vs-running-total decisions from it. Stage 3 shipped too: the feature-layer capacity-date call sites (`ticket-payment.ts` `bookingDateFields`, `qr-book.ts` `buildCheckoutIntent`, `api/listings.ts` child availability, -`api/booking.ts` `resolveBookingDate`) consult -`capacityDateFor`/`countsPerDate` instead of branching on -`listing_type === "daily"` by hand. Only the *capacity-date* decisions belong -to the table — the remaining calendar/UI daily branches (date pickers, -sorting, display, duration spans) are date-selection logic and should stay as -they are. Nothing further planned here. +`api/booking.ts` `resolveBookingDate`) consult `capacityDateFor`/`countsPerDate` +instead of branching on `listing_type === "daily"` by hand. Only the +_capacity-date_ decisions belong to the table — the remaining calendar/UI daily +branches (date pickers, sorting, display, duration spans) are date-selection +logic and should stay as they are. Nothing further planned here. --- @@ -497,28 +523,27 @@ 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. -- **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 - `loadPackagePricingByGroup` makes two sequential round-trips per group. Under - the edge subrequest budget these accumulate for larger orders. Fix direction: - add/use a batched `getListingsWithCount(ids)` for all order listing ids at once - and group the package-pricing loads, preserving the existing validation and - fail-closed behaviour. See the "Respect the subrequest budget" guidance in - AGENTS.md. +- **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 `loadPackagePricingByGroup` + makes two sequential round-trips per group. Under the edge subrequest budget + these accumulate for larger orders. Fix direction: add/use a batched + `getListingsWithCount(ids)` for all order listing ids at once and group the + package-pricing loads, preserving the existing validation and fail-closed + behaviour. See the "Respect the subrequest budget" guidance in AGENTS.md. ## Payment aggregate — safety behaviour (PR 1) New sales and existing payments are now resolved by different questions: `getActivePaymentProvider()` / `isPaymentsEnabled()` gate new checkouts; -`getPaymentProviderForExistingPayments()` resolves refunds, replayed -callbacks, and completion. When sales are off, the existing-payment path -falls back to the last activated provider. A site already on `none` -recovers when exactly one provider has stored credentials; when multiple -do, the operator must choose the provider in a recovery form that keeps new -sales off. `setPaymentProviderNone` reads the -current provider via an atomic INSERT ... SELECT subquery so a concurrent -activation cannot land between the read and the write. +`getPaymentProviderForExistingPayments()` resolves refunds, replayed callbacks, +and completion. When sales are off, the existing-payment path falls back to the +last activated provider. A site already on `none` recovers when exactly one +provider has stored credentials; when multiple do, the operator must choose the +provider in a recovery form that keeps new sales off. `setPaymentProviderNone` +reads the current provider via an atomic INSERT ... SELECT subquery so a +concurrent activation cannot land between the read and the write. The seven accepted safety rules are recorded as acceptance constraints in [`docs/payment-aggregate-acceptance.md`](docs/payment-aggregate-acceptance.md). @@ -526,13 +551,13 @@ The seven accepted safety rules are recorded as acceptance constraints in - **Track the provider each charge was captured with.** `main` stores only the opaque `payment_reference` per processed payment, not which provider captured it. So after an operator switches providers (Stripe → Square) and then selects - "none", the last-active fallback resolves every payment through Square, and - an older Stripe charge cannot be refunded or reconciled against the provider - that captured it. This predates PR 1 and is future aggregate work. Fix - direction: store the provider type on each `processed_payments` row at capture - time and dispatch existing-payment work from that per-charge provider instead - of one global fallback. Referenced from - `docs/payment-aggregate-acceptance.md` rule 2. + "none", the last-active fallback resolves every payment through Square, and an + older Stripe charge cannot be refunded or reconciled against the provider that + captured it. This predates PR 1 and is future aggregate work. Fix direction: + store the provider type on each `processed_payments` row at capture time and + dispatch existing-payment work from that per-charge provider instead of one + global fallback. Referenced from `docs/payment-aggregate-acceptance.md` + rule 2. - **Split payment-provider persistence out of `src/shared/db/settings.ts`.** Review of PR 1 correctly noted that the settings assembly is already over the @@ -553,23 +578,23 @@ The seven accepted safety rules are recorded as acceptance constraints in 800-line source-change limit. - **Split `src/features/api/webhooks.ts` below 400 lines.** Move the payment - callback and webhook processing paths into focused modules. This predates PR - 1 and is deferred because the split would exceed its strict source-change - limit. + callback and webhook processing paths into focused modules. This predates PR 1 + and is deferred because the split would exceed its strict source-change limit. ## Request performance: consolidate AsyncLocalStorage scopes -`src/features/app/request.ts` enters eleven nested request scopes for locale, client -IP, request ID, request cache, query logging, flash, session memoization, iframe -mode, CSRF, saved form data, and settings auditing. Replace them with one typed -`RequestContext` in one `AsyncLocalStorage`; retain domain methods where they add -behavior, but migrate every internal caller with no aliases or compatibility -wrappers. Preserve direct-render test behavior, production-disabled audit cost, -and concurrent/nested request isolation for every mutable field. Pending work -and storage overrides have different lifetimes and need a separate decision. -Benchmark before and after: the synthetic result was about 38us/request for -eleven scopes versus 2us for one. This needs a dedicated PR because it crosses -eleven state modules and their concurrency contracts. +`src/features/app/request.ts` enters eleven nested request scopes for locale, +client IP, request ID, request cache, query logging, flash, session memoization, +iframe mode, CSRF, saved form data, and settings auditing. Replace them with one +typed `RequestContext` in one `AsyncLocalStorage`; retain domain methods where +they add behavior, but migrate every internal caller with no aliases or +compatibility wrappers. Preserve direct-render test behavior, +production-disabled audit cost, and concurrent/nested request isolation for +every mutable field. Pending work and storage overrides have different lifetimes +and need a separate decision. Benchmark before and after: the synthetic result +was about 38us/request for eleven scopes versus 2us for one. This needs a +dedicated PR because it crosses eleven state modules and their concurrency +contracts. ## Dead-export scanner matches raw text (from PR #1745 review) @@ -580,11 +605,11 @@ comment, JSDoc, or string literal therefore registers a phantom "usage" — a CodeRabbit review on PR #1745 pointed out a JSDoc example in that very file doing this (fixed by rewording the comment), and the fixture strings in `detectors.test.ts` still contribute contrived names like `routeFoo` to the -test-corpus symbol set. Consequences are mild today: a phantom symbol in the -src corpus can silently mask a genuinely dead export of the same name; one in -the test corpus can only make an export look test-used (which then flags it, -loudly). This is a long-standing property of the whole detector file, not new -to the dynamic-import clauses. +test-corpus symbol set. Consequences are mild today: a phantom symbol in the src +corpus can silently mask a genuinely dead export of the same name; one in the +test corpus can only make an export look test-used (which then flags it, +loudly). This is a long-standing property of the whole detector file, not new to +the dynamic-import clauses. Proposed fix (the reviewer suggested syntax-aware parsing): a code-only preprocessing pass before matching. The file already has the pieces — the @@ -592,15 +617,15 @@ call-site scanner's `skipString`/`skipComment` lexer helpers skip comments and string literals correctly. The pass must drop BOTH comments and ordinary string/template-literal contents from the matchable text (a fixture string containing `import { foo }` is exactly the stated failure mode), while still -letting the lazyExport clause see its quoted name — lazyExport names live -INSIDE a string literal (`…, "routeAdmin")`), so either match the lazyExport -shape before stripping and stitch its names in, or blank string contents -except when the lexer sees the string directly in lazyExport's second-argument -position. Add regression coverage for import-shaped text in a line comment, a -JSDoc block, and an ordinary string/template literal, plus a lazyExport entry -that must still be detected after the pass. Out of scope for -PR #1745 (cold-start work; the detector change there was collateral hardening) -— the concrete self-match it introduced was fixed in-place instead. +letting the lazyExport clause see its quoted name — lazyExport names live INSIDE +a string literal (`…, "routeAdmin")`), so either match the lazyExport shape +before stripping and stitch its names in, or blank string contents except when +the lexer sees the string directly in lazyExport's second-argument position. Add +regression coverage for import-shaped text in a line comment, a JSDoc block, and +an ordinary string/template literal, plus a lazyExport entry that must still be +detected after the pass. Out of scope for PR #1745 (cold-start work; the +detector change there was collateral hardening) — the concrete self-match it +introduced was fixed in-place instead. ## Stop patching @std/expect's `toContain` (from PR #1712) @@ -614,16 +639,16 @@ harness (`scripts/test-harness.ts`) and the mutation runner (`scripts/mutation/runner.ts`). The preference is to **not** patch a standard library if we can avoid it. This -is genuinely out of scope for the barrel-removal PR (it would touch far more than -that PR's remit), so it's recorded here rather than done there. +is genuinely out of scope for the barrel-removal PR (it would touch far more +than that PR's remit), so it's recorded here rather than done there. Fix direction: replace the global `toContain` override with `@std/assert`'s native `assertStringIncludes` (and `assertArrayIncludes` where a `toContain` is -used on arrays), which is already fast — it does not pretty-print on success — so -no `@std` behaviour is patched. Migrate the `expect(bigHtml).toContain(...)` call -sites (thousands, mostly rendered-HTML assertions), then delete `fast-expect.ts`, -its test, the `--preload` flag in both runners, and the "Fast Tests" note that -documents the override. Confirm the suite's slow-test report +used on arrays), which is already fast — it does not pretty-print on success — +so no `@std` behaviour is patched. Migrate the `expect(bigHtml).toContain(...)` +call sites (thousands, mostly rendered-HTML assertions), then delete +`fast-expect.ts`, its test, the `--preload` flag in both runners, and the "Fast +Tests" note that documents the override. Confirm the suite's slow-test report (`SLOW_TEST_THRESHOLD_MS`) doesn't regress. Start points: `fast-expect.ts` for what it did and why, and grep `\.toContain(` under `test/` for the call sites. @@ -631,58 +656,60 @@ what it did and why, and grep `\.toContain(` under `test/` for the call sites. ## Restrictions audit — "why can't I combine X with Y?" follow-ups -*Origin: an audit of every place the app refuses a combination a user might +_Origin: an audit of every place the app refuses a combination a user might expect to work, aimed at cutting "why can't I select this?" support queries. Each restriction was judged on whether its reason is genuinely insurmountable (structure, money-correctness, capacity, privacy, security) or a soft limit worth relaxing. The clearest informative wins already shipped — the package "which listing and why" messages, the daily-add-on "needs a date" reason, the payment-provider "your other key is kept" note, and the free-text "can't set a -price" note. What's left is captured below, split into rule-relaxations (let -the combination through) and message/UX fixes (keep the rule, stop the user -hitting it blind). All are pre-existing behaviour — deliberate design choices, -except the percentage-surcharge cap noted below, which is a latent correctness -bug (harmless today because of the multiplier workaround).* +price" note. What's left is captured below, split into rule-relaxations (let the +combination through) and message/UX fixes (keep the rule, stop the user hitting +it blind). All are pre-existing behaviour — deliberate design choices, except +the percentage-surcharge cap noted below, which is a latent correctness bug +(harmless today because of the multiplier workaround)._ ### Keep the rule — stop the user hitting it blind - ~~**SumUp is offered on a currency it can't use.**~~ **Done.** The provider registry (`src/shared/payment-providers.ts`) now records each provider's - currencies (`null` = takes them all), and `providerCurrencyBlock(id, currency)` - turns that into the one sentence every surface shows. The settings page renders - an unusable provider switched off with the reason beside it, the provider - choice refuses to save, and the SumUp credentials save keeps its refusal. - -- **An answer's price-modifier dropdown silently omits the operator's modifier.** - `src/features/admin/questions.ts` (`answerTriggerModifiers`) only lists - `trigger === "answer"` modifiers, so a "+£5" built as *Automatic* or an add-on - never appears and reads as a bug. Fix: add a hint by the selector (in the - answers UI, `src/ui/templates/admin/questions.tsx`) — "only answer-triggered - modifiers appear here; create one on the Modifiers page." + currencies (`null` = takes them all), and + `providerCurrencyBlock(id, currency)` turns that into the one sentence every + surface shows. The settings page renders an unusable provider switched off + with the reason beside it, the provider choice refuses to save, and the SumUp + credentials save keeps its refusal. + +- **An answer's price-modifier dropdown silently omits the operator's + modifier.** `src/features/admin/questions.ts` (`answerTriggerModifiers`) only + lists `trigger === "answer"` modifiers, so a "+£5" built as _Automatic_ or an + add-on never appears and reads as a bug. Fix: add a hint by the selector (in + the answers UI, `src/ui/templates/admin/questions.tsx`) — "only + answer-triggered modifiers appear here; create one on the Modifiers page." - **Incompatible listings are offered by the add-listings picker.** The - group-homogeneity messages now live in the catalog and say why (`error.group_*` - in `src/locales/en/groups.json`), but the operator still only learns of a clash - when the save is refused. Better: grey out the listings that cannot join this - group in the add-listings picker, so the clash is visible before saving. The - rule to render from is `groupListingTypeError` (`src/shared/db/groups.ts`) — - same type, and same customisable-days setting, as the members already there. - -- **Two save-time either/ors would be clearer as disabled controls.** - (a) customisable-days vs Allow Pay More (`validateCustomisableDays`, + group-homogeneity messages now live in the catalog and say why + (`error.group_*` in `src/locales/en/groups.json`), but the operator still only + learns of a clash when the save is refused. Better: grey out the listings that + cannot join this group in the add-listings picker, so the clash is visible + before saving. The rule to render from is `groupListingTypeError` + (`src/shared/db/groups.ts`) — same type, and same customisable-days setting, + as the members already there. + +- **Two save-time either/ors would be clearer as disabled controls.** (a) + customisable-days vs Allow Pay More (`validateCustomisableDays`, `src/shared/listings-actions.ts`) — the two fields sit in different form - sections, so the operator never sees them as related; (b) a paid-default status - that is also a reservation (`src/features/admin/settings-statuses.ts` ~line 69) - — both checkboxes render side by side. Fix: mutually disable the paired - controls client-side with a one-line "why", turning a save-time error into an - obvious affordance. + sections, so the operator never sees them as related; (b) a paid-default + status that is also a reservation (`src/features/admin/settings-statuses.ts` + ~line 69) — both checkboxes render side by side. Fix: mutually disable the + paired controls client-side with a one-line "why", turning a save-time error + into an obvious affordance. - **"Refund processed but not recorded" reads like a failure.** - `src/shared/refund-ledger.ts` only auto-reverses a fully-paid clean account; on - a partial/credit/mixed account the provider refund fires but the operator sees - `error.refund_not_recorded` ("do not re-refund") with no next step. Fix: link - the manual-adjustment page straight from that flash and frame it as "one more - step", not an error. + `src/shared/refund-ledger.ts` only auto-reverses a fully-paid clean account; + on a partial/credit/mixed account the provider refund fires but the operator + sees `error.refund_not_recorded` ("do not re-refund") with no next step. Fix: + link the manual-adjustment page straight from that flash and frame it as "one + more step", not an error. - ~~**A multi-item cart with no shared date/length dies silently.**~~ **Done.** `src/shared/booking/cart-conflicts.ts` names the clashing items on the ticket @@ -690,53 +717,57 @@ bug (harmless today because of the multiplier workaround).* items with no shared booking length — and tells the buyer to book them separately. (See "The shared reasons shape" section below.) -- **A manager hits a bare "Forbidden" on owner-only pages.** `src/features/ - auth.ts` (~line 462) returns plain text for users/statuses/bulk-email/settings. - Fix: ensure the nav hides these for managers (the "never render a forbidden - link" rule) and give the 403 an "owner-only" hint. +- **A manager hits a bare "Forbidden" on owner-only pages.** + `src/features/ + auth.ts` (~line 462) returns plain text for + users/statuses/bulk-email/settings. Fix: ensure the nav hides these for + managers (the "never render a forbidden link" rule) and give the 403 an + "owner-only" hint. - **A child's duration mismatch with its parent is invisible until you open both day-price tables.** `children_err_child_duration` / `durationsCompatible` - (`src/shared/listing-parents-rules.ts`) states the rule but not the clash. Fix: - surface the actual mismatch at save time ("parent offers 2–3 days; this child - prices only 1"). + (`src/shared/listing-parents-rules.ts`) states the rule but not the clash. + Fix: surface the actual mismatch at save time ("parent offers 2–3 days; this + child prices only 1"). - **The order gallery advertises availability it can't honour** once required - children fold in — already tracked above under *Booking unification → - "`/order` live availability: fold required-child demand into options"*. Same + children fold in — already tracked above under _Booking unification → + "`/order` live availability: fold required-child demand into options"_. Same fix; cross-referenced here because it's the buyer-facing half of this audit. ### Relax the rule — let the combination through - **Only one payment provider active at a time.** `getActivePaymentProvider` (`src/shared/payments.ts`) reads a single `payment_provider` setting. This is - *not* forced by the webhook — `getWebhookSignatureHeader` already scans every + _not_ forced by the webhook — `getWebhookSignatureHeader` already scans every provider's signature header — so the block is the single scalar plus no per-order provider choice. Relaxing needs checkout-time provider selection, - header-based webhook dispatch, and a multi-select UI. Reasonable to leave for a - single-merchant site; revisit if operators ask. + header-based webhook dispatch, and a multi-select UI. Reasonable to leave for + a single-merchant site; revisit if operators ask. - **A status in use by attendees can't be deleted, with no way out.** `src/features/admin/settings-statuses.ts` (~lines 200–221) blocks the delete - outright. Fix: add a "reassign these N attendees to , then delete" flow - (the same move already used to retire a default status). - -- **The embed widget refuses to add a package to the cart.** `src/ui/client/ - order.ts` (~line 489) force-navigates away from a package ("it could never - combine with other listings"), but the internal cart (`src/features/public/ - cart.ts`) *does* combine packages with listings. Fix: add the package slug to - the running cart and build a multi-slug `/ticket/+` URL like the - internal gallery. + outright. Fix: add a "reassign these N attendees to , then delete" + flow (the same move already used to retire a default status). + +- **The embed widget refuses to add a package to the cart.** + `src/ui/client/ + order.ts` (~line 489) force-navigates away from a package + ("it could never combine with other listings"), but the internal cart + (`src/features/public/ + cart.ts`) _does_ combine packages with listings. Fix: + add the package slug to the running cart and build a multi-slug + `/ticket/+` URL like the internal gallery. - **An answer can trigger only one modifier.** `answers.modifier_id` is a scalar - (`src/shared/db/questions/aggregates.ts`). Everything downstream already handles - arbitrary modifier sets; only the link is one-to-one. Fix: an `answer_modifiers` - join table. Low frequency; do on demand. + (`src/shared/db/questions/aggregates.ts`). Everything downstream already + handles arbitrary modifier sets; only the link is one-to-one. Fix: an + `answer_modifiers` join table. Low frequency; do on demand. - **A package can't contain a pay-what-you-want listing.** `packageMemberBlock` (`src/shared/package-membership.ts`) blocks it because a package needs a fixed - member price. Relaxable if you define bundle pricing for a pay-more member (use - its base price, or let the buyer choose within the bundle) — a semantics + member price. Relaxable if you define bundle pricing for a pay-more member + (use its base price, or let the buyer choose within the bundle) — a semantics decision, not a structural wall. - **A manager can't edit the public site, but a lower-trust editor can.** @@ -746,37 +777,37 @@ bug (harmless today because of the multiplier workaround).* - **Two-level listing nesting (A→B, then B→C).** `childEdgeIneligibility` (`src/features/admin/listings-parents.ts`) caps nesting at one level; the booking fold-tree and `capacity-rules.ts` both assume exactly parent+child. - Real work (recursive fold + capacity), not a toggle — build only when a concrete - booking needs it. (See also the booking-unification phases above.) + Real work (recursive fold + capacity), not a toggle — build only when a + concrete booking needs it. (See also the booking-unification phases above.) - **Child-scoped opt-in add-ons.** An add-on reachable only through a folded-in child is blocked because "v1 has no child-scoped add-on render/parse path" (`src/features/admin/listings-parents.ts`, `modifier-resolve.ts`). The - `bookable_alone` flag is the current escape hatch; the real fix is to build that - render/parse path. + `bookable_alone` flag is the current escape hatch; the real fix is to build + that render/parse path. - **The same pay-what-you-want add-on under two parents must share one price.** - `foldChild` (`src/shared/booking/fold-tree.ts` ~line 281) keys the custom-price - map by listing id. Per-allocation pricing would allow different prices; niche, - do on demand. + `foldChild` (`src/shared/booking/fold-tree.ts` ~line 281) keys the + custom-price map by listing id. Per-allocation pricing would allow different + prices; niche, do on demand. --- ## The shared "reasons" shape for validation failures — shipped -*Origin: reviewing the package-restriction work (PR #1770); built once the -collect-all need (the multi-item "no shared date" diagnostic) arrived.* +_Origin: reviewing the package-restriction work (PR #1770); built once the +collect-all need (the multi-item "no shared date" diagnostic) arrived._ What shipped: - **The combinator.** `src/shared/reasons.ts`: a `Reason` answers with the - message to show or null, and one rule list serves both runners — - `firstReason` (fail-fast; list order is precedence) and `allReasons` - (name every problem at once). + message to show or null, and one rule list serves both runners — `firstReason` + (fail-fast; list order is precedence) and `allReasons` (name every problem at + once). - **The converged tables.** The parent/child edge rules (`src/shared/listing-parents-rules.ts`), the package member rules - (`src/shared/package-membership.ts` — messages render inside the rules, so - the separate block-code layer is gone), and the group homogeneity rules + (`src/shared/package-membership.ts` — messages render inside the rules, so the + separate block-code layer is gone), and the group homogeneity rules (`groupListingTypeError` in `src/shared/db/groups.ts`). `CAPACITY_RULES` deliberately did NOT converge: it classifies which checks apply, it does not refuse with a message — a genuinely different shape. @@ -802,17 +833,17 @@ child-duration clash at save time, and the chooser own-cap warning. ## Deferred Codex suggestions from PR #1975 (API documentation examples) -*Origin: Codex review of PR #1975, which made the API documentation examples +_Origin: Codex review of PR #1975, which made the API documentation examples checkable and fixed eighteen real inaccuracies in them. Both items below are valid and were deliberately left out: they guard mistakes nobody has made yet, -and each costs more machinery than the defect it would catch.* +and each costs more machinery than the defect it would catch._ - **Validate admin request fields against their production constraints.** `test/shared/admin-api-example/helpers.ts`'s `isBlank` judges a documented - request value by its sign and whether it is zero. A positive *fractional* + request value by its sign and whether it is zero. A positive _fractional_ value (Codex's example: `duration_days: 1.5` in the listing create body) - therefore passes, while `API_BODY_FIELD_RULES` requires a safe integer and - the real endpoint answers 400. Fixing it properly means running each request + therefore passes, while `API_BODY_FIELD_RULES` requires a safe integer and the + real endpoint answers 400. Fixing it properly means running each request example through the endpoint's own field rules rather than a hand-written check. Starting point: `API_BODY_FIELD_RULES` in `src/features/admin/api.ts`. @@ -827,23 +858,24 @@ and each costs more machinery than the defect it would catch.* ## Deferred CodeRabbit suggestions from PR #1772 (servicing test relocation) -*Origin: CodeRabbit review of PR #1772, which only `git mv`s the servicing +_Origin: CodeRabbit review of PR #1772, which only `git mv`s the servicing db-module tests into `test/shared/db/attendees/servicing/` (plus a 4-line cwd fix in `code-quality.test.ts`). CodeRabbit reviewed the moved content as if new and raised 13 findings; every one is on **pre-existing** test code carried over unchanged from `main`, so they were out of scope for a rename-only PR and -recorded here.* +recorded here._ **Done — the two vacuous tests + the corruption-repair cleanups (a follow-up PR).** Both suspects were confirmed and fixed: -- `corruption-repair.test.ts` — the `UPDATE … kind = 'staff'` did throw under the - CHECK and was swallowed by `catch { return }`, so the exclusion assertions +- `corruption-repair.test.ts` — the `UPDATE … kind = 'staff'` did throw under + the CHECK and was swallowed by `catch { return }`, so the exclusion assertions never ran (confirmed empirically). Now the corrupt row is written past the - CHECK via `PRAGMA ignore_check_constraints` (libsql supports it), so the reader - predicates are genuinely exercised — and a separate test asserts the CHECK - rejects the write directly. The dead `queryOne` import, the `string | null` - param on `insertRowWithKind`, and the redundant dynamic imports were removed. + CHECK via `PRAGMA ignore_check_constraints` (libsql supports it), so the + reader predicates are genuinely exercised — and a separate test asserts the + CHECK rejects the write directly. The dead `queryOne` import, the + `string | null` param on `insertRowWithKind`, and the redundant dynamic + imports were removed. - `lifecycle-concurrency.test.ts` (~87-110) — the raw SQL deletes were replaced with the production `deleteListing`, and the orphan assertion strengthened (attendee row survives; its booking on the deleted listing is gone). @@ -871,7 +903,7 @@ fixtures (`recordBoilerCost`, `postCustomerSale`, `listingProfitOf`, next to `parseFlashCookie`. A pure reorganisation — the same 40 tests run, no test behaviour changed. -*Nothing remains open in this section.* +_Nothing remains open in this section._ ## Logistics run sheet — should servicing events appear? @@ -894,24 +926,24 @@ exclusion is deliberate, not accidental. ## Test suite speed — remaining tail work -*Origin: the test-suite performance PR (grouped isolates + run-scoped test -state).* +_Origin: the test-suite performance PR (grouped isolates + run-scoped test +state)._ The full runner now shares isolates between test files (`scripts/test-groups.ts`) and prebuilds the DB setup state once per run -(`test/test-utils/test-state.ts`). The remaining wall-clock tail is a handful -of genuinely long suites, which now bound the slowest groups: +(`test/test-utils/test-state.ts`). The remaining wall-clock tail is a handful of +genuinely long suites, which now bound the slowest groups: -- **Migration chain shards** (`test/integration/db/migration-restore/`, ~20s each ×4 - shards). They already shard by `index % shardCount`; raising the shard count - (4 → 8) would halve each shard and shorten the tail groups. Purely +- **Migration chain shards** (`test/integration/db/migration-restore/`, ~20s + each ×4 shards). They already shard by `index % shardCount`; raising the shard + count (4 → 8) would halve each shard and shorten the tail groups. Purely mechanical — the factory takes the count. - **Slow-test report entries >2s** (printed after every full run): the migration/legacy-migration suites and a few e2e journeys dominate. Each one fixed shortens the longest group directly. -Starting point: run `deno task test`, read the slow-test report at the end, -and profile the top entry. +Starting point: run `deno task test`, read the slow-test report at the end, and +profile the top entry. ## Pre-existing issues surfaced during the min-tokens-20 dedup (PR #1795) @@ -920,14 +952,15 @@ pre-existing (the dedup preserved the behaviour, it did not introduce it), so they were left out of that PR's scope. - **Bulk email draft cleared after the send, not before** - (`src/features/admin/bulk-email.ts`, the `sendBulkEmails → recordContacts → - bulkEmailDraft("") → logActivity` sequence). `sendBulkEmails` is - non-idempotent, so if `recordContacts` throws after the send, a retry can - resend to the whole audience. Moving the draft-clear before the send trades - that for the opposite risk (a failed send loses the draft with no retry), so - it needs a deliberate decision — likely a "draft consumed" marker distinct - from "draft empty". Not a dedup regression: the ordering is byte-identical to - before the PR. + (`src/features/admin/bulk-email.ts`, the + `sendBulkEmails → recordContacts → + bulkEmailDraft("") → logActivity` + sequence). `sendBulkEmails` is non-idempotent, so if `recordContacts` throws + after the send, a retry can resend to the whole audience. Moving the + draft-clear before the send trades that for the opposite risk (a failed send + loses the draft with no retry), so it needs a deliberate decision — likely a + "draft consumed" marker distinct from "draft empty". Not a dedup regression: + the ordering is byte-identical to before the PR. - **Bulk-group-duplicate form loses inputs on a failed POST** (`src/ui/templates/admin/bulk-actions.tsx` `adminDuplicateGroupPage`). On a @@ -953,77 +986,79 @@ they were left out of that PR's scope. `src/features/admin/update.ts` and `built-sites.ts`). The success flash (`"${successPrefix} to ${name} — the new version will be active shortly"`) and the activity-log line (`"${logPrefix} to ${name} (${tag})"`) are built from - hardcoded `successPrefix`/`logPrefix`/tail strings rather than `t()` keys. This - copy is byte-identical to what lived in `update.ts` on `main` before the dedup - (the flash string `"Updated to … — the new version will be active shortly"` was - already there); the dedup only moved it into the shared helper. Fix: add ICU - keys with `{name}`/`{version}` placeholders and pass the two call sites' prefix - choices as keyed variants, so the flash and log line read from the catalog. - Out of scope for a dedup PR (pre-existing copy, not a new string). + hardcoded `successPrefix`/`logPrefix`/tail strings rather than `t()` keys. + This copy is byte-identical to what lived in `update.ts` on `main` before the + dedup (the flash string + `"Updated to … — the new version will be active shortly"` was already there); + the dedup only moved it into the shared helper. Fix: add ICU keys with + `{name}`/`{version}` placeholders and pass the two call sites' prefix choices + as keyed variants, so the flash and log line read from the catalog. Out of + scope for a dedup PR (pre-existing copy, not a new string). - **Admin API docs prose is hardcoded, not in the catalog** (`src/ui/templates/admin/api-keys.tsx` — the authentication intro - `"Admin API endpoints require authentication…"`, the `"Public API endpoints - require no authentication. All responses are JSON."` line, the admin-group - intro `"Requires Authorization: Bearer YOUR_API_KEY header."`, and - the `"Use it with: "` copy-notice line). These are all present + `"Admin API endpoints require authentication…"`, the + `"Public API endpoints + require no authentication. All responses are JSON."` + line, the admin-group intro + `"Requires Authorization: Bearer YOUR_API_KEY header."`, and the + `"Use it with: "` copy-notice line). These are all present unchanged on `main` — the dedup restructured the page onto `DocsSection`/ - `sectionsRenderer` but did not touch the wording. Developer-facing API-doc copy - may keep literal technical terms, but the surrounding prose still belongs in - `src/locales/en/*.json` (the sibling `api_keys.public_api_note` already is a - catalog key). Fix: add `api_keys.*` keys for the four strings, rendering the + `sectionsRenderer` but did not touch the wording. Developer-facing API-doc + copy may keep literal technical terms, but the surrounding prose still belongs + in `src/locales/en/*.json` (the sibling `api_keys.public_api_note` already is + a catalog key). Fix: add `api_keys.*` keys for the four strings, rendering the ``-bearing ones via `Raw`. Out of scope for a dedup PR (pre-existing copy). - **The `/api/*/book` docs show a free response for a priced sample** (`src/shared/admin-api-example.ts`). Both `POST /api/listings/:slug/book` and `POST /api/packages/:slug/book` document their response as - `API_BOOK_FREE_EXAMPLE_JSON` (`amountOwed: 0`, a ticket token), even though the - package sample request is a priced bundle whose real response would carry a - `checkoutUrl` (`API_BOOK_PAID_EXAMPLE_JSON` already exists). Pre-existing: on - `main` both endpoints used a local `API_EXAMPLE_BOOKING_RESPONSE` const that is - byte-identical to `API_BOOK_FREE_EXAMPLE_JSON`, and this dedup only merged that - duplicate into the shared constant — it did not change which example shows. Fix - (a doc-accuracy pass, not a dedup): pick the example per endpoint — a paid - response for the priced package bundle, or document both free and paid shapes — - so the sample response matches the sample request. + `API_BOOK_FREE_EXAMPLE_JSON` (`amountOwed: 0`, a ticket token), even though + the package sample request is a priced bundle whose real response would carry + a `checkoutUrl` (`API_BOOK_PAID_EXAMPLE_JSON` already exists). Pre-existing: + on `main` both endpoints used a local `API_EXAMPLE_BOOKING_RESPONSE` const + that is byte-identical to `API_BOOK_FREE_EXAMPLE_JSON`, and this dedup only + merged that duplicate into the shared constant — it did not change which + example shows. Fix (a doc-accuracy pass, not a dedup): pick the example per + endpoint — a paid response for the priced package bundle, or document both + free and paid shapes — so the sample response matches the sample request. ## Placeholder refund — replay marker gap when the atomic ledger batch fails -*Origin: Codex review on PR #1822 (atomic placeholder payment + refund ledger).* - -`recordPlaceholderRefund` (`src/shared/refund-ledger.ts`) posts the payment -and completed-refund legs as one atomic `postTransferGroups` batch, so a -refund-leg conflict rolls the payment back too (the PR's core requirement). -When that batch fails outright, NO ledger legs land for the booking event -group. The payment flow's durable replay guard is the ledger preflight -(`replaySessionFromLedger` → `bookingLedgerDisposition`: `unrecorded` when -no legs exist), and the primary guard (`markSessionFailed`'s `failure_data` -row) is pruned by `prunePayments` once it ages past retention. So after -pruning, a late webhook/redirect for the same already-refunded session -re-enters `processReservedSession`, sees `unrecorded`, and re-creates a -placeholder attendee + re-calls `tryRefund` (idempotent, so no double payout) -instead of acknowledging the session as already handled. +_Origin: Codex review on PR #1822 (atomic placeholder payment + refund ledger)._ + +`recordPlaceholderRefund` (`src/shared/refund-ledger.ts`) posts the payment and +completed-refund legs as one atomic `postTransferGroups` batch, so a refund-leg +conflict rolls the payment back too (the PR's core requirement). When that batch +fails outright, NO ledger legs land for the booking event group. The payment +flow's durable replay guard is the ledger preflight (`replaySessionFromLedger` → +`bookingLedgerDisposition`: `unrecorded` when no legs exist), and the primary +guard (`markSessionFailed`'s `failure_data` row) is pruned by `prunePayments` +once it ages past retention. So after pruning, a late webhook/redirect for the +same already-refunded session re-enters `processReservedSession`, sees +`unrecorded`, and re-creates a placeholder attendee + re-calls `tryRefund` +(idempotent, so no double payout) instead of acknowledging the session as +already handled. This is NOT fully new: on main before PR #1822 the same gap existed for a payment-post failure (the first `postTransfers` threw → no legs). PR #1822 -widens the failure surface from "payment-post failure only" to "payment-post -OR refund-post failure" (because both are now one atomic batch). Closing it +widens the failure surface from "payment-post failure only" to "payment-post OR +refund-post failure" (because both are now one atomic batch). Closing it properly needs a durable handled marker that survives idempotency-row pruning -without breaking the atomic rollback — e.g. a ledger leg that survives even -when the refund leg conflicts (which would violate #1822's acceptance -criterion: "a refund-reference collision proves neither transfer group is -committed"), or a separate replay-state row outside the prunable -`processed_payments` table. The staged-checkout runtime (deferred -foundations item 6 in `PR_SPLIT_PLAN.md`) carries the proper replay/activation -machinery to resolve this. Starting point: the preflight in -`src/features/api/payment-processing/index.ts` (`replaySessionFromLedger`), -the pruner in `src/shared/db/prune.ts` (`prunePayments`), and the -classification in `src/shared/session-ledger.ts`. +without breaking the atomic rollback — e.g. a ledger leg that survives even when +the refund leg conflicts (which would violate #1822's acceptance criterion: "a +refund-reference collision proves neither transfer group is committed"), or a +separate replay-state row outside the prunable `processed_payments` table. The +staged-checkout runtime (deferred foundations item 6 in `PR_SPLIT_PLAN.md`) +carries the proper replay/activation machinery to resolve this. Starting point: +the preflight in `src/features/api/payment-processing/index.ts` +(`replaySessionFromLedger`), the pruner in `src/shared/db/prune.ts` +(`prunePayments`), and the classification in `src/shared/session-ledger.ts`. ## Bunny subrequest budget follow-ups -*Origin: request-fan-out audit for PR #1820.* +_Origin: request-fan-out audit for PR #1820._ Bunny stops an edge request after 50 subrequests. PR #1820 adds a request-scoped database guard that blocks libsql call 51 and fixes the concrete failures found @@ -1037,8 +1072,8 @@ database-only cases fail loudly, but it cannot count provider or storage calls. `loadPackagePricingByGroup` loads every booked package through `loadPackageMemberPricingByGroupIds` in three. `validateAllItems` (`src/features/api/payment-processing/items.ts`) reads every order line's - listing in one batch instead of one call per line. - `getPackageDisplaysByIds` was already a single query. + listing in one batch instead of one call per line. `getPackageDisplaysByIds` + was already a single query. - **Outgoing webhook fan-out.** The database side is done: `logAndNotifyRegistration` writes every booking's activity row in one batch (`logActivities`), and `loadPackageOverrides` prices every booked package in @@ -1046,26 +1081,25 @@ database-only cases fail loudly, but it cannot count provider or storage calls. fetches every distinct webhook URL in the request, so an order spanning many listings with different URLs can still run out of Bunny's external-request budget. Persist outbound webhook jobs and deliver them out of band. -- **Multi-entry check-in.** `handleCheckinPost` in - `src/features/checkin.ts` calls `updateCheckedIn` once per eligible booking - line. A token set with 51 lines therefore makes 51 updates. Replace it with - one set-based update over all attendee/listing pairs. +- **Multi-entry check-in.** `handleCheckinPost` in `src/features/checkin.ts` + calls `updateCheckedIn` once per eligible booking line. A token set with 51 + lines therefore makes 51 updates. Replace it with one set-based update over + all attendee/listing pairs. - **Automatic built-site assignment.** `assignSitesForEntries` and `assignSiteWithRenewal` in `src/shared/site-assignment.ts` mix per-unit DB writes with provider calls. Eleven Deno site units, or nine Bunny site units, can exceed 50. Reserve assignments in one batch, queue provider provisioning, and batch-persist the successful renewal states. - **Old database migration.** `runPendingMigrations` in - `src/shared/db/migrations.ts` uses at least two marker calls per migration; - 25 pending migrations exceed the limit before their own work. + `src/shared/db/migrations.ts` uses at least two marker calls per migration; 25 + pending migrations exceed the limit before their own work. `applySchemaChanges` in `src/shared/db/migrations/schema-sync.ts` also runs - each missing-column ALTER separately. Move long migrations out of band or - make progress resumable in bounded request-sized steps, and batch safe ALTERs. + each missing-column ALTER separately. Move long migrations out of band or make + progress resumable in bounded request-sized steps, and batch safe ALTERs. - **Large in-app backups and storage cleanup.** After the first-page batch, `exportTable` in `src/shared/db/backup-snapshot.ts` still needs one call per later page; a 25,000-row table at the default page size needs about 50 pages - by itself. - `cleanupStalePendingFiles` in `src/features/admin/backup.ts` and + by itself. `cleanupStalePendingFiles` in `src/features/admin/backup.ts` and `pruneOldBackups` make one storage delete per stale object. Send large backups through the existing out-of-band workflow and cap cleanup work per request. - **Bulk email.** `sendBulkEmails` in `src/shared/email.ts` can create more than @@ -1077,8 +1111,8 @@ database-only cases fail loudly, but it cannot count provider or storage calls. synchronous route a strict cap. - **Admin seed generation.** `createSeeds` in `src/shared/seeds.ts` uses one attendee batch per 50 rows; 2,501 attendees exceed 50 calls, while the form - permits far more. Move seed generation to CLI/background work or cap the - total from the request budget. + permits far more. Move seed generation to CLI/background work or cap the total + from the request budget. - ~~**Remaining group admin reads.**~~ Done. `validateListingTypesForGroup` (`src/features/admin/groups.ts`) reads the group's members once and judges every candidate against that list with `groupListingTypeError`, and @@ -1099,7 +1133,7 @@ import already use bounded batches. ## Resumable paid-booking completion -*Origin: CodeRabbit review of PR #1833.* +_Origin: CodeRabbit review of PR #1833._ The attendee, booking rows, ledger, modifier use, contact activity, and payment finalization commit atomically, but `completePaidBooking` then saves answers, @@ -1132,7 +1166,7 @@ assignments, and renewal time are neither lost nor duplicated. ## Consistent database backup snapshots -*Origin: CodeRabbit review of PR #1836.* +_Origin: CodeRabbit review of PR #1836._ `createBackup` batches each table's first page, then `exportTable` reads later pages with standalone queries. A write during those reads can make a backup mix @@ -1144,47 +1178,48 @@ Add a dedicated read-only transaction or snapshot API in `src/shared/db/client.ts`. Do not reuse `withTransaction`: that helper opens a primary-routed write transaction, serializes writers, and enforces a write round-trip limit. Keep the first-page multi-table read efficient, account for -the edge subrequest budget, and use the same snapshot for every later page. -Add a regression test in `test/shared/db/backup-snapshot.test.ts` that changes -rows between page reads and proves the exported rows all come from one database +the edge subrequest budget, and use the same snapshot for every later page. Add +a regression test in `test/shared/db/backup-snapshot.test.ts` that changes rows +between page reads and proves the exported rows all come from one database state. --- ## Checkout stage attendee cleanup -*Origin: Codex review of PR #1840.* +_Origin: Codex review of PR #1840._ Before any runtime path writes `checkout_stages`, include those rows in attendee deletion, purge, and merge handling. The table has no foreign key, so leaving the current hard-coded dependent-table lists unchanged would keep a stage linked to an attendee that no longer exists. Start with -`src/shared/db/attendees/delete.ts` and -`src/shared/merge/attendee-merge.ts`. Add direct regressions proving deletion -removes a stage and merging repoints it without losing the unique attendee -invariant. If both attendees have stages, require an explicit conflict decision -instead of silently choosing or deleting one. +`src/shared/db/attendees/delete.ts` and `src/shared/merge/attendee-merge.ts`. +Add direct regressions proving deletion removes a stage and merging repoints it +without losing the unique attendee invariant. If both attendees have stages, +require an explicit conflict decision instead of silently choosing or deleting +one. --- ## Test improvements surfaced by PR #1873 (move-only) -*Origin: CodeRabbit review of PR #1873 — "Move eight integration tests to +_Origin: CodeRabbit review of PR #1873 — "Move eight integration tests to test/integration/". PR #1873 was a move-only refactor: files were relocated with -`git mv` and only relative import paths were updated. The four findings below are -about pre-existing test code that was already on `origin/main` before the move; -they are recorded here so a future PR can pick them up without re-reading the -review. Each item names the file/path, what CodeRabbit proposed, why it was out -of scope for #1873, and a starting point.* +`git mv` and only relative import paths were updated. The four findings below +are about pre-existing test code that was already on `origin/main` before the +move; they are recorded here so a future PR can pick them up without re-reading +the review. Each item names the file/path, what CodeRabbit proposed, why it was +out of scope for #1873, and a starting point._ - **Reuse shared `#test-utils` KEK helpers in `test/integration/kek-v2.test.ts` (lines 46–92).** `unwrapUserKey` and `ownerDataKey` repeat admin unwrap logic - that may already live in `test/test-utils/{crypto.ts,session.ts,test-state.ts}`. - A future PR should check whether a shared helper for "unwrap a v2 user's - DATA_KEY with the per-user-salted password KEK" and "unwrap the shared owner - DATA_KEY" already exists or should be extracted, then fold this file's local - copies into it. Keep `seedV1User` local (it constructs a legacy-only fixture) - and leave `sharesOwnerDataKey` as the spec-specific check. Start by searching + that may already live in + `test/test-utils/{crypto.ts,session.ts,test-state.ts}`. A future PR should + check whether a shared helper for "unwrap a v2 user's DATA_KEY with the + per-user-salted password KEK" and "unwrap the shared owner DATA_KEY" already + exists or should be extracted, then fold this file's local copies into it. + Keep `seedV1User` local (it constructs a legacy-only fixture) and leave + `sharesOwnerDataKey` as the spec-specific check. Start by searching `test/test-utils/` for `deriveKEKFromPassword`, `unwrapKey`, and `getUserByUsername` to see what is already shared. @@ -1198,27 +1233,27 @@ of scope for #1873, and a starting point.* when the row is absent. This aligns with the offensive-programming rule against `?.` papering over a value that should always exist. -- **Assert the computed cutoff in - `test/integration/renewals.test.ts` (lines 187–192).** The test is titled - "pushReadOnlyFrom is called exactly once with computed cutoff" but only checks - the call count via `expectReadOnlyFromPush(secretStub)`, discarding the - returned `{ scriptId, secretValue }`. If the cutoff month math regresses, the - test would still pass despite its name. A future PR should capture - `secretValue` from `expectReadOnlyFromPush` and assert it equals - `addMonthsIso(baseDate, 2)` (the expected quantity-2 cutoff) while keeping the - exactly-once assertion. `baseDate` is already destructured from - `withRenewalTest` in neighbouring tests. - -- **Assert the error log in - `test/integration/renewals.test.ts` (lines 204–210).** The test is titled - "siteToken present but no matching site logs error, no Bunny call" but only - asserts `expectNoBunnyCall(secretStub)` — the "logs error" half of the title is - unverified. A future PR should add a `console.error` assertion using the - existing error-spy helper (search `test/` for `spy(console, "error"` or an - `errorSpy` helper) so the test verifies the error is emitted for the missing - site-token match, or rename the test to drop the unverified claim. Start by - reading `applyRenewalsForEntries` in `src/shared/webhook.ts` to confirm it - calls `console.error` (or `logError`) on a missing site-token match. +- **Assert the computed cutoff in `test/integration/renewals.test.ts` (lines + 187–192).** The test is titled "pushReadOnlyFrom is called exactly once with + computed cutoff" but only checks the call count via + `expectReadOnlyFromPush(secretStub)`, discarding the returned + `{ scriptId, secretValue }`. If the cutoff month math regresses, the test + would still pass despite its name. A future PR should capture `secretValue` + from `expectReadOnlyFromPush` and assert it equals `addMonthsIso(baseDate, 2)` + (the expected quantity-2 cutoff) while keeping the exactly-once assertion. + `baseDate` is already destructured from `withRenewalTest` in neighbouring + tests. + +- **Assert the error log in `test/integration/renewals.test.ts` (lines + 204–210).** The test is titled "siteToken present but no matching site logs + error, no Bunny call" but only asserts `expectNoBunnyCall(secretStub)` — the + "logs error" half of the title is unverified. A future PR should add a + `console.error` assertion using the existing error-spy helper (search `test/` + for `spy(console, "error"` or an `errorSpy` helper) so the test verifies the + error is emitted for the missing site-token match, or rename the test to drop + the unverified claim. Start by reading `applyRenewalsForEntries` in + `src/shared/webhook.ts` to confirm it calls `console.error` (or `logError`) on + a missing site-token match. - **Extract scanning helpers from `test/integration/code-quality.test.ts` into a focused module.** CodeRabbit suggested (PR #1872 review) pulling @@ -1230,21 +1265,22 @@ of scope for #1873, and a starting point.* plumbing, not test assertions. The file is currently 699 lines (under the Biome 1,000-line hard ceiling but over the 400-line soft target). A `test/scripts/code-quality/scan-context.ts` module exporting `ScanContext`, - `loadScanContext`, `collectLineViolations`, `collectFileViolations`, and - the path constants would let the test file import them and keep only the - assertions and per-rule config. Start from the file-discovery helpers - already at the top of `code-quality.test.ts` (lines 295–360) and the - `ensureLoaded`/`forEachScannedFile`/`collect*Violations`/`scanSource*` - helpers inside the `describe("code quality", …)` block (lines 360–540). - This is a structural refactor (no behavior change); add a regression test - that re-runs the no-`../` rule against a fixture file via the extracted - helpers to prove parity with the inline implementation. + `loadScanContext`, `collectLineViolations`, `collectFileViolations`, and the + path constants would let the test file import them and keep only the + assertions and per-rule config. Start from the file-discovery helpers already + at the top of `code-quality.test.ts` (lines 295–360) and the + `ensureLoaded`/`forEachScannedFile`/`collect*Violations`/`scanSource*` helpers + inside the `describe("code quality", …)` block (lines 360–540). This is a + structural refactor (no behavior change); add a regression test that re-runs + the no-`../` rule against a fixture file via the extracted helpers to prove + parity with the inline implementation. + --- ## Recover paid SumUp checkouts without a webhook or redirect -*Origin: follow-up to the SumUp provider work, surfaced 2026-07-25 while -documenting SumUp in `README.md` / `src/docs/payments.ts` (PR #1918).* +_Origin: follow-up to the SumUp provider work, surfaced 2026-07-25 while +documenting SumUp in `README.md` / `src/docs/payments.ts` (PR #1918)._ SumUp does not sign its webhooks. If its webhook is lost and the customer never returns to the redirect URL, SumUp can charge the customer without creating a @@ -1265,20 +1301,20 @@ proves they create the attendee and ledger rows exactly once. ## Square PENDING refunds — propagate a pending result, not a plain false -*Origin: Codex review of PR #1911 (confirmed Square refund outcomes), thread -on `squareApi.refundPayment` (`src/shared/square.ts`). This PR deliberately -does NOT address it; recorded so the follow-on work can pick it up.* +_Origin: Codex review of PR #1911 (confirmed Square refund outcomes), thread on +`squareApi.refundPayment` (`src/shared/square.ts`). This PR deliberately does +NOT address it; recorded so the follow-on work can pick it up._ `squareApi.refundPayment` returns `false` for a Square refund that is still `PENDING` (an accepted-but-unsettled refund). That is the honest current-main boolean contract this PR ships, but it has a real downstream cost the reviewer flagged: the webhook/admin refund flow reads `refunded === false` as a failed -refund, so a pending Square refund releases the reservation, returns 503, and -— because each call mints a fresh `crypto.randomUUID()` idempotency key — a -redelivery posts another full-refund attempt instead of waiting on the -existing refund id. A PENDING Square refund is documented as a normal accepted -`RefundPayment` response, so collapsing it into `false` loses the "accepted, -not yet settled" signal. +refund, so a pending Square refund releases the reservation, returns 503, and — +because each call mints a fresh `crypto.randomUUID()` idempotency key — a +redelivery posts another full-refund attempt instead of waiting on the existing +refund id. A PENDING Square refund is documented as a normal accepted +`RefundPayment` response, so collapsing it into `false` loses the "accepted, not +yet settled" signal. Update: PR #1912 (stable Stripe and Square refund idempotency keys) has since landed on `main`; the Square refund idempotency key is now the stable @@ -1291,8 +1327,8 @@ pending-result union below is still the real fix; the stale-key concern is resolved. The fix is the staged-checkout pending-result union / callback resolution this -PR was explicitly told not to introduce: surface a pending outcome (carrying -the refund id) separately from a plain false, and have the webhook/admin refund +PR was explicitly told not to introduce: surface a pending outcome (carrying the +refund id) separately from a plain false, and have the webhook/admin refund paths hold/redeliver against that id instead of re-posting. That is the same machinery planned for #1853 (`split/staged-checkout-runtime` — "Finish and recover paid checkouts safely") and overlaps #1905 @@ -1301,45 +1337,48 @@ resolution), so it must be designed with those branches, not duplicated here. Starting points: `squareApi.refundPayment` in `src/shared/square.ts` (where the boolean contract lives), the idempotency key in its `withClient` callback, and the downstream `tryRefund` in `src/features/api/payment-processing/refunds.ts` -plus `refundReferenceAtProvider` in -`src/features/admin/refunds/provider.ts` (both treat `false` as failed and fall -back to `isPaymentRefunded`, which a still-pending refund also fails). +plus `refundReferenceAtProvider` in `src/features/admin/refunds/provider.ts` +(both treat `false` as failed and fall back to `isPaymentRefunded`, which a +still-pending refund also fails). --- ## Validate Square orders/payments responses with Valibot schemas -*Origin: CodeRabbit review of PR #1911. The refund response validation is done +_Origin: CodeRabbit review of PR #1911. The refund response validation is done (`SquareRefundResponseSchema` in `src/shared/square.ts`), and the test file -splits are complete (`refund-payment.test.ts`, `refund-transport.test.ts`, -and the shared `mock-fetch.ts` helper all exist; `retrieve-refund.test.ts` is -240 lines and `rest-transport.test.ts` is 372). What remains is extending the -same boundary-validation pattern to the orders and payments client methods.* +splits are complete (`refund-payment.test.ts`, `refund-transport.test.ts`, and +the shared `mock-fetch.ts` helper all exist; `retrieve-refund.test.ts` is 240 +lines and `rest-transport.test.ts` is 372). What remains is extending the same +boundary-validation pattern to the orders and payments client methods._ The Square REST client still maps order and payment responses with type casts -(`get` for orders and payments). `squareFetch` returns `JSON.parse(response.text)` -cast as ``, so a malformed order or payment object — wrong field types, an -unexpected shape — passes through unvalidated. The refund path now has a Valibot -schema (`SquareRefundSchema` / `SquareRefundResponseSchema`) parsed with -`v.parse` OUTSIDE `withClient`, so a malformed refund response fails loudly. -Doing the same for orders and payments means defining `SquareOrderSchema` and -`SquarePaymentSchema` and parsing in their respective `squareApi` methods, so -a malformed response throws rather than being silently cast. Starting point: -`squareFetch` and the `SquareOrderResponse` / `SquarePaymentResponse` types in -`src/shared/square.ts`; mirror the refund schema shape that already exists. +(`get` for orders and payments). `squareFetch` returns +`JSON.parse(response.text)` cast as ``, so a malformed order or payment +object — wrong field types, an unexpected shape — passes through unvalidated. +The refund path now has a Valibot schema (`SquareRefundSchema` / +`SquareRefundResponseSchema`) parsed with `v.parse` OUTSIDE `withClient`, so a +malformed refund response fails loudly. Doing the same for orders and payments +means defining `SquareOrderSchema` and `SquarePaymentSchema` and parsing in +their respective `squareApi` methods, so a malformed response throws rather than +being silently cast. Starting point: `squareFetch` and the `SquareOrderResponse` +/ `SquarePaymentResponse` types in `src/shared/square.ts`; mirror the refund +schema shape that already exists. --- ## Mutation coverage of `src/features/api/folded-booking.ts` (direct tests) Direct tests at `test/features/api/folded-booking.test.ts` and -`test/features/api/folded-booking/parent-booking.test.ts` kill every non-equivalent mutant on the unchanged `folded-booking.ts`. -Five equivalents (lines 87, 118, 176, 301, 381) are recorded in -`scripts/mutation/equivalent-mutants/` with proofs — no unsuppressed survivors remain. +`test/features/api/folded-booking/parent-booking.test.ts` kill every +non-equivalent mutant on the unchanged `folded-booking.ts`. Five equivalents +(lines 87, 118, 176, 301, 381) are recorded in +`scripts/mutation/equivalent-mutants/` with proofs — no unsuppressed survivors +remain. ## Split `render-selector.test.ts` by what each case actually checks -*Origin: Codex review on PR #1926 (test reorganisation).* +_Origin: Codex review on PR #1926 (test reorganisation)._ `test/integration/server/parents-gate/render-selector.test.ts` holds four cases with three different subjects: one changes a setting and checks the effect, one @@ -1357,7 +1396,7 @@ settings behaviour. ## Let the misplaced-test list see past request helpers -*Origin: Codex reviews on PRs #1926 and #1929 (test reorganisation).* +_Origin: Codex reviews on PRs #1926 and #1929 (test reorganisation)._ The misplaced-test list only considers a test that resolves to exactly one source. Start-up helpers (`describeWithEnv`, the env overlay) used to drag the @@ -1403,23 +1442,23 @@ stands, so the split can be a pure move. ## Two suites now cover the attendees list -*Origin: Codex review of PR #1993 (direct tests for the four testless modules).* +_Origin: Codex review of PR #1993 (direct tests for the four testless modules)._ -`test/features/admin/attendees-list.test.ts` was added because the mutation -gate needs a test at the source's mirrored path. It calls the handlers -directly. But `test/integration/server/attendees-list.test.ts` already drives -the same behaviour over HTTP — authentication, the listing filter, sort order -and paging — and `test/integration/server/attendees-csv.test.ts` covers the -export. So the same rules are now checked twice. +`test/features/admin/attendees-list.test.ts` was added because the mutation gate +needs a test at the source's mirrored path. It calls the handlers directly. But +`test/integration/server/attendees-list.test.ts` already drives the same +behaviour over HTTP — authentication, the listing filter, sort order and paging +— and `test/integration/server/attendees-csv.test.ts` covers the export. So the +same rules are now checked twice. That costs runtime on every suite run, and lets the two sets of fixtures and expectations drift apart. The fix is to consolidate: move the route-level cases -into the mirrored feature suite (which can call the handler directly *and* go +into the mirrored feature suite (which can call the handler directly _and_ go through the router where that is the point), and delete what is left behind. Not done in #1993 because that change touches suites the PR otherwise had no -reason to open, and the mirrored suite had to exist first. Worth doing next -time either file is opened. +reason to open, and the mirrored suite had to exist first. Worth doing next time +either file is opened. Starting point: the three files named above. @@ -1444,15 +1483,14 @@ properly means the workflow (and `.github/actions/backup-site/action.yml`) must learn to skip or handle non-Bunny hosting first. Starting points: the `hostingProvider === "bunny"` filter in -`src/features/instance.ts`, `setSiteSecrets` in -`src/shared/site-assignment.ts`, and the per-site loop in -`.github/workflows/deploy-clients.yml`. +`src/features/instance.ts`, `setSiteSecrets` in `src/shared/site-assignment.ts`, +and the per-site loop in `.github/workflows/deploy-clients.yml`. --- ## Split the hybrid encryption section out of `src/shared/crypto/keys.ts` -*Origin: reviewer suggestion on PR #1945.* +_Origin: reviewer suggestion on PR #1945._ `keys.ts` is 499 lines and holds three separate jobs: KEK derivation, symmetric key wrapping, and hybrid RSA+AES encryption. The hybrid section is the natural @@ -1468,9 +1506,9 @@ split.) The move itself is mechanical, but it is wide: `encryptWithOwnerKey` and `decryptWithOwnerKey` are used across attendee PII, the activity log, email -preferences, and bulk email drafts, so every importer needs repointing. -Remember `src/docs/crypto.ts`, which re-exports whole crypto modules for the -generated API docs — a moved export silently disappears from them otherwise. +preferences, and bulk email drafts, so every importer needs repointing. Remember +`src/docs/crypto.ts`, which re-exports whole crypto modules for the generated +API docs — a moved export silently disappears from them otherwise. Starting point: the "Hybrid Encryption" section of `src/shared/crypto/keys.ts`, and `grep -rn "encryptWithOwnerKey\|decryptWithOwnerKey\|hybridEncrypt" src/`. @@ -1479,7 +1517,7 @@ and `grep -rn "encryptWithOwnerKey\|decryptWithOwnerKey\|hybridEncrypt" src/`. ## Decide what happens to undated bookings when a listing starts being booked by the day -*Origin: found while migrating the multi-day tests to stories (PR for batch 8).* +_Origin: found while migrating the multi-day tests to stories (PR for batch 8)._ A listing booked as one date can be switched to being booked by the day. The people who booked before the switch have no day of their own (`start_at` is @@ -1489,7 +1527,7 @@ listing — see the null-start_at case in The effect is that a full listing stops being full the moment it is switched. A Hall with room for 2, with both places taken, accepts a further booking on any -day after the switch, so it ends up holding 3 people. The listing's *total* +day after the switch, so it ends up holding 3 people. The listing's _total_ check still counts them (`attendeesApi.hasAvailableSpots(id, 1)` with no date returns false), so the two checks disagree. @@ -1511,7 +1549,7 @@ Starting point: `attendeesApi.hasAvailableSpots` and the per-day capacity SQL in ## Tell the story of a refused "stop selling this on its own" -*Origin: reviewer suggestion (Codex) on PR #1952.* +_Origin: reviewer suggestion (Codex) on PR #1952._ The site refuses to stop selling an add-on on its own when doing so would leave another add-on with no way to be bought — `strippedPageOrphanedAddOn` in @@ -1533,7 +1571,7 @@ fixture. ## Test the door's confirmation steps in the browser script -*Origin: reviewer suggestion (Codex) on PR #1959.* +_Origin: reviewer suggestion (Codex) on PR #1959._ `src/ui/client/scanner.js` is the only part of checking people in that nothing tests. It is the script that shows the organiser the question the door asked — @@ -1558,11 +1596,11 @@ for how a client script is driven without a real browser. ## Prove a bundle's blank price really charges the thing's own price -*Origin: reviewer suggestion (Codex) on PR #1968.* +_Origin: reviewer suggestion (Codex) on PR #1968._ -The story `bookings.selling-things-as-one-bundle` proves the *saving* half of +The story `bookings.selling-things-as-one-bundle` proves the _saving_ half of the blank-price rule: leaving a part's price empty on the bundle form stores no -price of its own for that part. It does not prove the *charging* half — that the +price of its own for that part. It does not prove the _charging_ half — that the customer is then asked for that thing's own price rather than nothing. Its rule is worded to say only what it proves. Closing the gap needs a paid @@ -1580,8 +1618,8 @@ priced bundle reaches checkout today. ## Watch for ports being taken between tests -*Origin: the chunk that took `scripts/stripe-mock/install.ts` to a full -mutation score (#1966), and the flaky runs it uncovered.* +_Origin: the chunk that took `scripts/stripe-mock/install.ts` to a full mutation +score (#1966), and the flaky runs it uncovered._ The tests under `test/scripts/stripe-mock/install/` failed about one run in three: five failed runs out of roughly fifteen, a different test each time, and @@ -1605,47 +1643,49 @@ ever receive the same one. It has since been seen more, in `test/scripts/stripe-mock/lifecycle.test.ts` ("stops trying once the mock has been started as many times as asked", on CI for PR #1968, and "gives a mock time to shut itself down before killing it", on CI -for PR #2032 — the latter now hardened: the fixture notes when it wins its -port, and the test retries on a fresh port when that note is missing), with a -second symptom worth knowing about. That test counts how many -times the fake mock was started and expects one start per try asked for. A try -whose freshly picked port already has something listening on it is abandoned -*before* the mock is started, so the count comes up short and the test fails — -even though the starter did try the number of times it was asked to. Handing out -ports so no two tests can receive the same one would fix this too; short of that, -the count is the wrong thing to measure. +for PR #2032 — the latter now hardened: the fixture notes when it wins its port, +and the test retries on a fresh port when that note is missing), with a second +symptom worth knowing about. That test counts how many times the fake mock was +started and expects one start per try asked for. A try whose freshly picked port +already has something listening on it is abandoned _before_ the mock is started, +so the count comes up short and the test fails — even though the starter did try +the number of times it was asked to. Handing out ports so no two tests can +receive the same one would fix this too; short of that, the count is the wrong +thing to measure. ## The Turso upload suite sometimes dies with no diagnostic at all -*Origin: CI on PR #2039, a branch that touches nothing this suite uses. It -then reproduced locally under a full `deno task test:coverage` run, while -passing many consecutive standalone runs — it needs a loaded machine.* +_Origin: CI on PR #2039, a branch that touches nothing this suite uses. It then +reproduced locally under a full `deno task test:coverage` run, while passing +many consecutive standalone runs — it needs a loaded machine._ -`test/scripts/turso-migration-file.test.ts` fails as `fail Turso migration +`test/scripts/turso-migration-file.test.ts` fails as +`fail Turso migration file — at unknown location — No TAP diagnostic was emitted for this -failure.` The first five cases pass and the rest never report, so the whole -describe dies between cases rather than an assertion failing. +failure.` +The first five cases pass and the rest never report, so the whole describe dies +between cases rather than an assertion failing. The suspect is the watch in `sendDatabaseFile` -(`scripts/turso-migration-file.ts`). When a server answers before the whole -body is sent, Deno's node:http polyfill rejects an internal task nobody -awaits (`Failed to fetch: request body stream errored`), and -`watchPolyfillBodyStreamDefect` swallows that duplicate while the upload is -in flight. The watch stands down one `setTimeout(0)` after the upload -settles — its own comment admits this is a guess about when the duplicate -surfaces. On a loaded machine the internal rejection can land *after* that -one timer turn, and an unhandled rejection between cases is exactly "no -diagnostic, unknown location". A fix wants a deterministic stand-down — -e.g. hold the watch until the request's own `close` says its internals are -done — proven by a test that forces the late rejection, not by timing luck. +(`scripts/turso-migration-file.ts`). When a server answers before the whole body +is sent, Deno's node:http polyfill rejects an internal task nobody awaits +(`Failed to fetch: request body stream errored`), and +`watchPolyfillBodyStreamDefect` swallows that duplicate while the upload is in +flight. The watch stands down one `setTimeout(0)` after the upload settles — its +own comment admits this is a guess about when the duplicate surfaces. On a +loaded machine the internal rejection can land _after_ that one timer turn, and +an unhandled rejection between cases is exactly "no diagnostic, unknown +location". A fix wants a deterministic stand-down — e.g. hold the watch until +the request's own `close` says its internals are done — proven by a test that +forces the late rejection, not by timing luck. --- ## A webhook test about dropped answers fails once in a while in CI -*Origin: CI on PR #2037, a branch that changes no `src/` file at all and does +_Origin: CI on PR #2037, a branch that changes no `src/` file at all and does not touch this test or anything it exercises. The same commit passed the whole -suite locally, this test included.* +suite locally, this test included._ `finalizes a paid booking when a text-answer ref has no usable string id, dropping only those answers` @@ -1664,23 +1704,27 @@ synchronously before any async work (`src/shared/logger.ts`), so the error spy is per-case (`beforeEach`/`afterEach` in `test/test-utils/error-spy.ts`), so it cannot be picking up a neighbour's output. That points at the booking or answer-saving assertions rather than the -logging one, but pointing is not proving — do not "fix" this one from the -shape of the test. +logging one, but pointing is not proving — do not "fix" this one from the shape +of the test. --- ## Four feature modules had no test at their mirrored path — now they do -*Origin: `deno task precommit:mutation` on the notes-migration branch, which -could not start. Closed by the direct-test pass that followed.* +_Origin: `deno task precommit:mutation` on the notes-migration branch, which +could not start. Closed by the direct-test pass that followed._ All four now have a direct test at their mirrored path, so the gate no longer refuses to start on a branch that touches them: -- `src/features/admin/attendee-page.ts` → `test/features/admin/attendee-page.test.ts` (100%, two recorded equivalents) -- `src/features/admin/attendees-list.ts` → `test/features/admin/attendees-list.test.ts` (100%) -- `src/features/admin/listing-page-data.ts` → `test/features/admin/listing-page-data/` (100%, one recorded equivalent) -- `src/features/api/payment-processing/store-refund.ts` → `test/features/api/payment-processing/store-refund.test.ts` (100%) +- `src/features/admin/attendee-page.ts` → + `test/features/admin/attendee-page.test.ts` (100%, two recorded equivalents) +- `src/features/admin/attendees-list.ts` → + `test/features/admin/attendees-list.test.ts` (100%) +- `src/features/admin/listing-page-data.ts` → + `test/features/admin/listing-page-data/` (100%, one recorded equivalent) +- `src/features/api/payment-processing/store-refund.ts` → + `test/features/api/payment-processing/store-refund.test.ts` (100%) Every one of them now catches every mutation the gate demands, so a branch touching any of them can pass without first writing the tests that should @@ -1694,31 +1738,31 @@ helpers" above). ## Two people setting a site up at the same moment can both succeed -Raised on #1988 by both automated reviewers, and confirmed against the code. -It is a production bug, not a test gap, and it is deliberately left out of that -pull request because that branch changes no production code and this sits in -the most security-critical path we have. +Raised on #1988 by both automated reviewers, and confirmed against the code. It +is a production bug, not a test gap, and it is deliberately left out of that +pull request because that branch changes no production code and this sits in the +most security-critical path we have. **What happens.** `handleSetupPost` (`src/features/setup.ts`) asks `isSetupComplete()` and then calls `settings.setup.complete`. Nothing holds between the asking and the doing, so two requests that arrive together can both -be told the site is empty. `completeSetup` -(`src/shared/db/settings/setup.ts`) then runs its batch twice. +be told the site is empty. `completeSetup` (`src/shared/db/settings/setup.ts`) +then runs its batch twice. The unique index on `username_index` saves us only when both people pick the -*same* name. Two different names both insert, and the second batch's +_same_ name. Two different names both insert, and the second batch's `settingUpsert` calls overwrite `PUBLIC_KEY` and `WRAPPED_PRIVATE_KEY` with a second keypair. The first owner is left holding a wrapped data key for a data key the site no longer uses — they can sign in and read nothing. **Why it is not simply "add a guard".** The batch cannot decide anything -mid-flight, so making the owner insert conditional still leaves the four -setting upserts landing unconditionally. Whatever fixes it has to make the -whole ceremony refuse to run twice — an interactive transaction that re-reads -`setup_complete` inside the write lock, or a single conditional write that -every other statement hangs off. That is a design decision in the code that -holds everybody's encryption keys, so it wants its own change and its own -review, not a corner of a test PR. +mid-flight, so making the owner insert conditional still leaves the four setting +upserts landing unconditionally. Whatever fixes it has to make the whole +ceremony refuse to run twice — an interactive transaction that re-reads +`setup_complete` inside the write lock, or a single conditional write that every +other statement hangs off. That is a design decision in the code that holds +everybody's encryption keys, so it wants its own change and its own review, not +a corner of a test PR. **Where to start.** `completeSetup` in `src/shared/db/settings/setup.ts` — `withTransaction` from `src/shared/db/client.ts` is the tool, and the header @@ -1727,7 +1771,7 @@ are computed up front). The guard in `handleSetupPost` at `src/features/setup.ts:114` stays useful as the cheap first check. **Proving it.** A story cannot show this today: Cucumber awaits each step, so -the two posts never overlap. #1988 covers the neighbouring case it *can* reach +the two posts never overlap. #1988 covers the neighbouring case it _can_ reach honestly — a person who had the setup page open before somebody else finished, sending their stale form afterwards. A real test for this one needs both posts started together behind a barrier, and it should be written with the fix. @@ -1736,7 +1780,7 @@ started together behind a barrier, and it should be written with the fix. ## An answer filed under a listing nobody booked -*Origin: review of PR #1990 (the booking-check slice), 2026-07-29.* +_Origin: review of PR #1990 (the booking-check slice), 2026-07-29._ Free-text answers travel through checkout filed under the listing they belong to, as `{"12": [{"q": 3, "s": 400}]}`. `ListingKeySchema` in @@ -1752,10 +1796,10 @@ answers. The buyer answered a question and the answer quietly goes nowhere. The schema is the wrong place for the check: it validates one booking's metadata on its own, and the listings that were bought are decided later, once the items -have been priced and loaded. The natural home is next to -`saveSessionAnswers`, which already has both the answer map and the booked -listings — compare the two sets and raise any key that matches no booked -listing, the same way an unreadable booking is raised. +have been priced and loaded. The natural home is next to `saveSessionAnswers`, +which already has both the answer map and the booked listings — compare the two +sets and raise any key that matches no booked listing, the same way an +unreadable booking is raised. Start at `saveSessionAnswers`, and at `test/shared/booking-intent.test.ts`, where the shape rule is covered and the "names a booked listing" rule is not. @@ -1764,18 +1808,18 @@ where the shape rule is covered and the "names a booked listing" rule is not. ## A create whose row can't be read back should not look retryable -*Origin: Codex review on PR #2002, which added the loud failure for a create -whose just-written row can't be read back.* +_Origin: Codex review on PR #2002, which added the loud failure for a create +whose just-written row can't be read back._ `writeEntity` (`src/shared/rest/write-entity.ts`) writes the row, commits, then reads it back on the primary. When a create's read-back finds nothing it now raises an error. That error leaves the API write path in `src/shared/rest/crud-api.ts` and reaches the request handler (`src/features/app/request.ts`), which turns any unhandled error into the shared -503 page. The row itself was committed, so a client that treats the 503 as -"try again" can post the same create twice and end up with two rows. +503 page. The row itself was committed, so a client that treats the 503 as "try +again" can post the same create twice and end up with two rows. -The reviewer's suggestion was to read the row back *before* committing, so a +The reviewer's suggestion was to read the row back _before_ committing, so a failed read-back rolls the insert back and there is nothing to duplicate. That is more than a local change: each resource can supply its own `lookupAfterWrite`, several of which join extra columns, and every one of them @@ -1796,8 +1840,8 @@ answer given for a failure that should no longer happen — not a live fault. ## Record a foreign-currency charge in the money history without pretending it is ours -*Origin: Codex review on PR #2021, which sent a charge taken in the wrong -currency down the existing mismatch-and-refund path.* +_Origin: Codex review on PR #2021, which sent a charge taken in the wrong +currency down the existing mismatch-and-refund path._ The money history holds one currency — the site's. When a charge arrives in a different one, `classify.ts` sends it through the ordinary mismatch flow, and @@ -1810,26 +1854,26 @@ currency it was taken — but the operator's cash history reads wrong, and if th refund fails it names the wrong amount as still held. Two ways out: give the money history a currency of its own so a foreign charge -can be filed honestly, or keep these charges out of it and record them -somewhere that does not claim a site-currency total. +can be filed honestly, or keep these charges out of it and record them somewhere +that does not claim a site-currency total. Why it is not fixed in that PR: either way changes what the money history can -hold — a stored currency per entry, plus every reader and every total that -today assumes one currency. That is a change to the accounting store, well -past a PR about reading provider money safely, and the wrong thing to bolt on -without deciding which of the two shapes we want. +hold — a stored currency per entry, plus every reader and every total that today +assumes one currency. That is a change to the accounting store, well past a PR +about reading provider money safely, and the wrong thing to bolt on without +deciding which of the two shapes we want. ## Record a rejected-and-refunded payment session as finished -*Origin: Codex review on PR #2021, which added the automatic refund for a paid -charge the payment boundary cannot read.* +_Origin: Codex review on PR #2021, which added the automatic refund for a paid +charge the payment boundary cannot read._ When a provider callback meets a `malformed_charge` rejection and refunds it, nothing durable is written down. No reservation, no processed-payment row, no ledger entry says "this session was refused and the money went back". The -webhook simply acknowledges and moves on -(`refundRejectedCharge` in `src/features/api/payment-processing/refunds.ts`, and -its callers in `src/features/api/webhooks.ts` and +webhook simply acknowledges and moves on (`refundRejectedCharge` in +`src/features/api/payment-processing/refunds.ts`, and its callers in +`src/features/api/webhooks.ts` and `src/features/api/payment-processing/classify.ts`). The reviewer's concern is that a later delivery of the same session — a webhook @@ -1837,13 +1881,13 @@ redelivery, or the buyer opening the success page — could read it in a well-formed shape, still see it as paid, find no record of it, and make a real ticket for money that was already returned. -Why it is not being fixed in that PR: every rejection reason is a fixed -property of the provider's own stored record (an amount that is not a whole -number of minor units, a missing or malformed currency, a SumUp amount more -precise than its currency allows). None of those can turn well-formed on a -later read, so the double-book needs a provider changing a completed session's -money — which no provider does. The refund itself is already safe to repeat: -`tryRefund` treats a provider's "already fully refunded" answer as success. +Why it is not being fixed in that PR: every rejection reason is a fixed property +of the provider's own stored record (an amount that is not a whole number of +minor units, a missing or malformed currency, a SumUp amount more precise than +its currency allows). None of those can turn well-formed on a later read, so the +double-book needs a provider changing a completed session's money — which no +provider does. The refund itself is already safe to repeat: `tryRefund` treats a +provider's "already fully refunded" answer as success. If it is taken on: carry the session id in `SessionRejection` (`src/shared/payment/validated-session.ts`), and finish the session through the @@ -1854,8 +1898,8 @@ circuits the way an already-processed payment does. ## Tell a buyer when their money was taken and not (yet) given back -*Origin: Codex review on PR #2021, which added the "your money has been sent -back" page for a charge the payment boundary refused and refunded.* +_Origin: Codex review on PR #2021, which added the "your money has been sent +back" page for a charge the payment boundary refused and refunded._ That page is only shown when the refund actually went through. Three other outcomes still fall back to "Payment session not found", and in each of them the @@ -1868,7 +1912,7 @@ buyer really was charged: the same situation, reached a different way (`refundable` is `paid && isResourceId(...)`, and the `paid` half is discarded today). - A refund the provider refused (`settled: false`, answered 503). Careful here: - a 503 only asks the *webhook* to be delivered again. The redirect and cancel + a 503 only asks the _webhook_ to be delivered again. The redirect and cancel paths have no retry behind them — they re-attempt only if that person happens to reload the page. So this outcome must not be described to the buyer as being in hand until an unresolved refund is actually written down somewhere @@ -1893,73 +1937,73 @@ is nothing hard about it. ## Split the form-control rules into files about one thing each -*Origin: Codex review on PR #2025. Attempted on that branch and backed out — -see below.* +_Origin: Codex review on PR #2025. Attempted on that branch and backed out — see +below._ -`test/specs/support/form-controls.ts` is 478 lines, over the ~400 the repo -asks for, and holds four separate jobs: +`test/specs/support/form-controls.ts` is 478 lines, over the ~400 the repo asks +for, and holds four separate jobs: - reading a page's attributes (`attribute`, `hasFlag`, `usableInputsOfKind`) -- what a page offers (`chooserFor`, `boxFor`, `choicesOffered`, the checkbox - and question readers) -- why a value could not be sent (`whyValueCannotBeSent` and the rules under - it, plus the insisted-control machinery) +- what a page offers (`chooserFor`, `boxFor`, `choicesOffered`, the checkbox and + question readers) +- why a value could not be sent (`whyValueCannotBeSent` and the rules under it, + plus the insisted-control machinery) - the story-facing helpers (`fillInAndSend`, `takeDownFromActions`) -The first three are pure and the last does the sending, so the natural shape -is `form-controls/reading.ts`, `form-controls/rules.ts`, and a thin +The first three are pure and the last does the sending, so the natural shape is +`form-controls/reading.ts`, `form-controls/rules.ts`, and a thin `form-controls.ts` — the same split already done for `test-browser.ts`. The churn is smaller than it looks: `fillInAndSend` has 15 importers and stays put, and every reader that would move has between one and five (`checkboxValueOffered` 5, `tickedCheckboxes` 4, `whyValueCannotBeSent` 3, -`requireCheckboxOffered` 2, `choicesOffered`/`optionsOffered` 1 each). So -about a dozen import lines change. Do not add a re-export layer in -`form-controls.ts` to avoid touching them — that is the alias-export smell the -repo rules out; point each caller at the file that owns what it uses. +`requireCheckboxOffered` 2, `choicesOffered`/`optionsOffered` 1 each). So about +a dozen import lines change. Do not add a re-export layer in `form-controls.ts` +to avoid touching them — that is the alias-export smell the repo rules out; +point each caller at the file that owns what it uses. **Why it was backed out:** attempted by slicing the file on line ranges, which produced an unterminated comment, duplicated imports and several unresolved symbols. Reverted rather than pushed half-done. Whoever picks this up should move whole declarations (or use an editor that understands the syntax) rather -than cutting on line numbers, and lean on `deno task precommit` — the 214 -specs and the coverage gate both exercise this module hard. +than cutting on line numbers, and lean on `deno task precommit` — the 214 specs +and the coverage gate both exercise this module hard. ## A form found by its words alone can be sent with no button to press -*Origin: Codex review on PR #2025. Real, and deliberately left for its own -change — see the sweep below.* +_Origin: Codex review on PR #2025. Real, and deliberately left for its own +change — see the sweep below._ `findFormByButton` picks a form when the button's words appear anywhere in its body, then asks `buttonToPress` for the button. When no button matches but the -words do, `buttonToPress` returns `{}` — "no button with that text at all" — -and the form is submitted anyway, with no button data. +words do, `buttonToPress` returns `{}` — "no button with that text at all" — and +the form is submitted anyway, with no button data. That is on purpose for forms found by their body text, and plenty are. But it -means a form whose button is *removed* still submits if the words survive +means a form whose button is _removed_ still submits if the words survive elsewhere in it. Site-page deletion is exactly that shape: the heading and the button both say "Delete Page", so deleting the button leaves the heading, and the story goes on deleting pages the owner has no control to delete. The fix is not one line. Refusing every no-button case would break every story that legitimately finds its form by body text, so the change is to tell those -two situations apart — probably by having the caller say which it expects, or -by only allowing the body-text match when the form has no buttons at all. -Either way it needs a sweep of all 215 scenarios to see which rely on which. +two situations apart — probably by having the caller say which it expects, or by +only allowing the body-text match when the form has no buttons at all. Either +way it needs a sweep of all 215 scenarios to see which rely on which. ## An arrow is found across the whole page, not on its own row -*Origin: Codex review on PR #2025, raised twice. The second raise carried a -case the first did not, which is why it is here rather than declined.* +_Origin: Codex review on PR #2025, raised twice. The second raise carried a case +the first did not, which is why it is here rather than declined._ `canMove`/`move` in `test/specs/support/reordering.ts` look for a row's -`/id/move-up` address anywhere on the page. If that form is rendered against -the wrong row — present, but beside a different item — the story still submits -it and passes, while the organiser looking at the named row sees no arrow. +`/id/move-up` address anywhere on the page. If that form is rendered against the +wrong row — present, but beside a different item — the story still submits it +and passes, while the organiser looking at the named row sees no arrow. My first answer to this was that a positive scenario would catch an address convention changing, which is true but only covers one defect. A form -*relocated* to another row keeps every address the template tests assert, so +_relocated_ to another row keeps every address the template tests assert, so nothing catches it. Closing it means attributing controls to rows: parse the list into rows and ask @@ -1968,19 +2012,19 @@ what each row offers, rather than searching the page. `openAtState` in giving the shared reordering helper the same scope, for every list that uses it (states, site pages). -### The way *into* a row is found the same way +### The way _into_ a row is found the same way -*Origin: a third Codex raise, on PR #2025, against `findsTheWayInFrom`.* +_Origin: a third Codex raise, on PR #2025, against `findsTheWayInFrom`._ `findsTheWayInFrom` in `test/specs/support/browser.ts` searches `row.browser.links` — every link on the page, not the matched row's. Its three callers all match on something a sibling row could carry: -| Caller | What it matches | Its `openAt` gives | -| --- | --- | --- | -| `statuses.ts` | `href === /statuses/{id}` | the row's markup | -| `site-pages.ts` | `href` matching `/pages/{id}` | the row's markup | -| `api-keys.ts` | link text plus an address pattern | no row at all | +| Caller | What it matches | Its `openAt` gives | +| --------------- | --------------------------------- | ------------------ | +| `statuses.ts` | `href === /statuses/{id}` | the row's markup | +| `site-pages.ts` | `href` matching `/pages/{id}` | the row's markup | +| `api-keys.ts` | link text plus an address pattern | no row at all | Same defect as the arrow above: a link rendered against the wrong row still satisfies the search, so a deletion journey passes while the person looking at @@ -1994,23 +2038,24 @@ caller you came from, which is worse than the page-wide search it replaced. ### An equivalence proof rests on types the anchor cannot see -*Origin: Codex on PR #2037, against `descendTo` in `scripts/mutation/anchor.ts`.* +_Origin: Codex on PR #2037, against `descendTo` in +`scripts/mutation/anchor.ts`._ Nearly every reason in the equivalent-mutant registry is a claim about a type: "`x` is `string | undefined`, so `??` and `||` agree". An anchor fingerprints -the *expression*, so widening `x` to `number | null` leaves the anchor -unchanged and the entry keeps suppressing a mutant whose proof is now false. -The entry only actually hides something when no test distinguishes the two — -ignored status is applied to survivors only, so a killable mutant still reports -as killed — but that is exactly the case the registry is supposed to guard. +the _expression_, so widening `x` to `number | null` leaves the anchor unchanged +and the entry keeps suppressing a mutant whose proof is now false. The entry +only actually hides something when no test distinguishes the two — ignored +status is applied to survivors only, so a killable mutant still reports as +killed — but that is exactly the case the registry is supposed to guard. Fingerprinting the enclosing function's head was tried and reverted. It costs more than it buys: adding or renaming any parameter invalidates every entry in that function's body, and it still misses the majority of proofs, whose types come from a called function's return, an imported shape, or a database row -rather than the signature overhead. 166 of the 535 recorded reasons name a -call, a return, or a row. A noisy gate that people learn to re-record past -makes the registry less trustworthy, not more. +rather than the signature overhead. 166 of the 535 recorded reasons name a call, +a return, or a row. A noisy gate that people learn to re-record past makes the +registry less trustworthy, not more. A real fix has to re-prove entries rather than re-locate them. The most promising shape is to give `mutation:audit-equivalents` a way to attempt a