diff --git a/AGENTS.md b/AGENTS.md index 81155e02d9..7996cf2dbd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,7 @@ as-is and skips the download, so `deno task test`, `deno task test:files`, and - **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. +- **Never hold a seat**: A checkout NEVER reserves capacity. A staged booking is written at **quantity 0** (`stageCheckout`), so it claims no seat while the customer pays; the real quantity — and the authoritative capacity claim — lands only at activation, after the payment confirms. First payment to land wins; a second buyer who pays for the genuinely-last seat is refunded by the terminal refund path (rare, and cheaper than the alternative). We do **not** reserve because holding seats invites botting and ghost-checkout lockouts on exactly the scarce listings where it hurts most. The availability preflight (`checkBatchAvailability` in `createStagedCheckout`) refuses a checkout that already can't fit, but it *holds* nothing. This is policy: never add a "reserve this seat for N minutes" mechanism. - **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`.) diff --git a/PR_SPLIT_PLAN.md b/PR_SPLIT_PLAN.md new file mode 100644 index 0000000000..c2a53be01d --- /dev/null +++ b/PR_SPLIT_PLAN.md @@ -0,0 +1,186 @@ +# Staged-checkout branch split plan + +## Goal + +Rebuild staged checkout as small changes from current `main`. The monolithic +reference branch remains useful for behavior and tests, but it must never be +merged or cherry-picked as a whole. + +The final checkout flow must still obey the core product rule: starting a +checkout never reserves capacity. Staged listing rows have quantity zero. The +real quantity is written only after payment, with the capacity check in the +same atomic database operation. First completed payment to claim the last seat +wins. A later completed payment that cannot fit is refunded. + +## Operating assumptions + +These assumptions are part of the product design, not temporary omissions: + +- Only the latest edge script runs. We do not support overlapping script + versions or an older script continuing to write after a deployment. +- Deployments and host moves happen when no checkout or payment needs to + survive the change. We do not support rolling back application code around + an open payment. +- Backups are restored only during a quiet host migration or another deliberate + recovery while the site is not busy. An open checkout or refund does not need + to survive backup and restore. +- Restoring an unexpected open checkout is allowed to discard its stage and + quantity-zero attendee. Completed bookings and completed payment records + remain ordinary durable data and must still restore normally. + +If any of these assumptions changes, mixed-version payment fences and stable +backup certification must be designed again before relying on that new +operating model. + +## Why checkout stages remain + +`checkout_stages` is still needed during a normal checkout on the current +version. It gives redirects, webhooks, retries, and crash recovery one durable +mapping from a provider payment session to the quantity-zero attendee that the +payment may activate. + +The stage is application workflow state, not a seat hold and not a second +payment idempotency system. `processed_payments` continues to decide whether a +provider session has already reached a terminal result. + +Keep a stage only while work remains: + +- `pending`: the provider may still complete the payment and activate it. +- `refunding`: payment completed, activation failed, and the refund still has + to finish. + +Once booking or refund handling reaches a durable terminal result, delete the +stage. The attendee and `processed_payments` record then carry the lasting +booking or replay result. + +## Current state + +The monolithic branch remains a committed reference. Do not merge it into +`main`. New work starts from current `main` and ports only the behavior named by +the active pull request. + +The independent foundations already merged include: + +- #1821 through #1827: attendee purge, refund, checkout error, Stripe setup, + and role-safe note prerequisites. +- #1829: canonical signed paid-booking rows and date fields. +- #1833: atomic booking writes and completed-payment recovery. +- #1836 and #1837: snapshot/export and backup-storage module splits without a + staged-checkout behavior change. +- #1840: dormant `checkout_stages` storage. It also added revision storage that + is no longer needed under the backup assumptions above. + +PR #1844 must not merge. Its `processed_payments.checkout_stage_attendee_id` +column, reservation lookup, and three payment fence triggers protect mixed +application versions and rollback-era writes. Those are not supported states. +The current runtime can load the stage and use its attendee directly. + +PR #1844 also contains answer-count trigger cleanup found while resolving a +concurrent merge. That independent cleanup is not a reason to merge payment +rollback machinery. Rebuild it separately only if it is still useful on +current `main`. + +## Removed work + +Do not build any of the following for staged checkout: + +- A staged-attendee claim column on `processed_payments`. +- Insert or update triggers that fence payments against checkout stages. +- Compatibility for an old edge script writing after the new runtime deploys. +- `checkout_stage_revisions` or checkout-stage revision triggers. +- Backup start/end revision reads, retry loops, snapshot certificates, or + restore rejection for changing checkout stages. +- Preservation of `pending` or `refunding` stages through backup and restore. +- Admin held-payment locks, merge conflict choices, pending-stage CSV columns, + or pending-stage UI whose only purpose is protecting an in-flight payment + from an operator action. +- First-request reconciliation whose only purpose is repairing events missed by + an older deployed script. + +The consequence is deliberate: if the operating assumptions are broken, an +open checkout may be discarded rather than recovered. + +## Next pull request + +### Simplify dormant checkout-stage storage + +Start from current `main` after closing #1844. + +Primary scope: + +- Remove `checkout_stage_revisions` from the declarative schema. +- Drop the revision table and its three triggers through a normal migration. +- Remove revision-only tests. +- Add `checkout_stages` to the shared attendee dependent-row deletion + mechanism. +- When an attendee is deleted or merged, delete its checkout stage instead of + preserving, moving, or asking the operator to resolve it. +- Leave generic backup/export behavior unchanged. Do not add stage-specific + reads, certificates, retries, filtering, or restore checks. + +Acceptance: + +- No production revision writer or reader remains. +- Deleting or merging an attendee cannot leave a stage pointing at it. +- Existing attendee, orphan, backup, and restore behavior remains green. +- No payment or checkout runtime is enabled. + +## Final runtime pull request + +### Current-version staged checkout + +Ship stage creation, payment activation, refund recovery, and cleanup together. +Do not deploy a stage creator before the current runtime can finish every stage +it creates. + +Primary behavior: + +- Run the ordinary availability preflight without holding capacity. +- Create the provider session and one durable stage tied to quantity-zero + attendee and listing rows before returning the payment URL. +- If writing the stage fails after creating the provider session, expire that + session before returning the error. A crash before the URL is returned may + leave only an unreachable provider session, which the provider may expire. +- On redirect or webhook, reserve the provider session through the existing + `processed_payments` idempotency path and load the attendee directly from + `checkout_stages`. +- Build the real rows through the canonical paid-booking representation from + #1829. +- Activate every listing row, enforce capacity, write ledger effects, store + ticket tokens, and finalize the processed payment atomically. +- If a completed payment cannot activate, durably enter `refunding` before the + provider refund and keep retrying until the provider confirms success. +- After a booked or refunded terminal result is durable, delete the stage. Also + remove the quantity-zero attendee after a terminal refund. +- Expire and delete abandoned pending stages through one bounded cleanup path. +- Keep stage rows out of ordinary attendee, ticket, CSV, and admin projections. + +The runtime does not add a second single-item path. A checkout with one listing +uses the same collection-based staging and activation code as a checkout with +many listings. + +Acceptance: + +- A pending checkout contributes zero booked quantity and holds no seat. +- A failed stage write never returns a usable payment URL. +- Concurrent completed payments cannot exceed listing capacity. +- The winning payment activates the exact staged attendee and rows. +- Redirect and webhook retries replay one processed result without duplicate + attendees, ledger entries, refunds, or ticket tokens. +- A failed activation cannot leave a completed provider payment without a + durable refunding path. +- Pending cleanup never deletes a refund still owed to the customer. +- Terminal stages and their sensitive stage-only tokens are removed. +- No rollback fence, stage revision, backup certification, or pending admin UI + is introduced. + +## Deployment and restore + +Deploy the final runtime during a quiet period. There is no mixed-version handoff +and no requirement to carry an open stage across the deployment. + +Backups may run while the site is active, but they make no consistency promise +for an open checkout or refund. Restore only during a quiet recovery or host +migration, then run the latest script. If an unexpected open stage is present, +the supported recovery is to discard it through the same stage cleanup +mechanism, not to resume or certify the old payment. diff --git a/TODO.md b/TODO.md index b78b4ef221..2f3d1ff732 100644 --- a/TODO.md +++ b/TODO.md @@ -326,26 +326,14 @@ existing ticket?) and likely adding payment-intent uniqueness — out of scope f a test-only file split. Starting point: `src/features/api/payment-processing.ts` (the `/payment/success` finalize path) and `#shared/db/processed-payments.ts`. -## Payment-processing review follow-ups (from PR #1692) +## Payment-processing review follow-up (from PR #1692) -Both items describe behaviour that predates the payment-processing split (the -code was moved verbatim from the old `payment-processing.ts` monolith). They are +This item describes behaviour that predates the payment-processing split (the +code was moved verbatim from the old `payment-processing.ts` monolith). It is recorded here because the split PR was a pure reorganisation — changing this -behaviour there would be out of scope — and CodeRabbit flagged them as worth a -look. - -- **Refund after a committed booking** (`src/features/api/payment-processing/index.ts`, - the `try { honoured = await createAttendeeForSession(...) } catch` in - `processReservedSession`). `createAttendeeForSession` commits the attendee + - bookings atomically, then runs `ensureAllBookings` (a post-commit read). If - that post-write step *threw*, the `catch` would route to `storeRefundedBooking` - — refunding a booking that actually persisted. Today `ensureAllBookings` - returns a structured `{ ok: false }` rather than throwing on the capacity path, - so the window is theoretical, but it isn't guarded structurally. Fix direction: - narrow the `try` to the pre-commit call only, or guarantee the post-commit - cleanup path is non-throwing, so a persisted booking can never be refunded. - Add a regression test that makes the post-commit step throw and asserts no - refund is issued. +behaviour there would have been out of scope — and CodeRabbit flagged it as +worth a look. + - **Per-item DB reads not batched** (`src/features/api/payment-processing/items.ts` `validateAllItems`, and `package-pricing.ts` `loadPackagePricingByGroup`). `validateAllItems` calls `getListingWithCount` once per item in a loop, and @@ -1028,7 +1016,68 @@ Follow-up: give each variable's description its own locale key under a new i18n-coverage gate doesn't flag them), but worth doing when this settings surface is next touched. Keep the copy plain per the Simple-Language rules. ---- +## Deferred from the staged-checkout hardening PR (review of PR #1764) + +*Origin: the adversarial review of the staged-checkout branch. These were +verified as accurate findings but deferred because each trades a small win +against real churn or a redesign of a money path; the hardening PR stayed +scoped to the confirmed bugs.* + +- **Batch `activateStagedBooking`'s interactive transaction.** It holds the + primary write lock across one round-trip per statement (pii update, N booking + updates, M usage inserts, legs, finalize, stage mark) plus in-transaction + crypto (`batchFinalizeStatement` encrypts inside the lock). The batch-with- + guards shape in `create-batch.ts` (`BOOKING_WRITE_GUARD`-style `changes()=0` + probes instead of `rowsAffected` checks) would make the whole activation one + round trip. Substantive rework of a money path — do it with its own focused + mutation run. +- **Thread already-loaded listings into `stageCheckout`.** `stagedBookings` + re-fetches (and re-decrypts) the order's listings that all four + `createStagedCheckout` call sites already hold. Threading them through saves + one SELECT per paid checkout start but adds a parameter every future checkout + entry must supply correctly, and deletes the "listing vanished before + staging" freshness throw — weigh both before doing it. +- **Batch the two-execute spots.** `store-refund.ts`'s staged path + (`updateAttendeePII` then `markCheckoutStage`) and `recordOrderActivity`'s + email+phone upserts each fire sequential single executes. Both are + replay-healed (verified), so this is round-trip thrift, not correctness; it + needs the helpers restructured to return statements instead of executing. +- **One shared "session returned unpaid" resolver.** The cancel path discards + on `paymentStatus !== "paid"` while classify discards only on `"failed"` — + deliberate today ("unpaid" can still complete on the success path), but the + next provider integration should not have to rediscover that asymmetry. +- **Reuse #1775's `createSystemNoteOnce` for stranded-refund note dedup** once + that PR merges — the store-refund replay path can stack an identical system + note on a redelivery after a mid-flight crash (rare, replay-healed). +- **`defineTable`-ize `checkout_stages`** (hand-written row type + mapping in + `checkout-stages.ts`) when the table next grows a column. +- **Rebuild refund placeholders from the signed lines on `stage_mismatch`.** + A refused staged order keeps its rows as stored; with the admin guard those + rows can no longer be operator-edited while pending, so the realistic + mismatch sources are derived-key changes (listing type flips, parent-edge + changes) where the rows still name the customer's exact listings and + quantities. If a real divergence source ever appears, re-write the + placeholder rows from the signed intent before refunding, so the operator + record always mirrors what was paid for. (Raised by Codex on PR #1802.) +- **A paid-but-stuck staged checkout can be pruned at 7 days (ACCEPTED EDGE — + deliberate).** If a paid staged checkout's processing keeps failing past the + provider's ~3-day webhook-retry window — a refund returning `false`, a ledger + write failing, or a worker crash — the stage stays `pending` and is pruned at + `PRUNE_CHECKOUT_STAGES_RETENTION_DAYS` (7), deleting OUR record of a payment + still captured at the provider. Left as-is by the owner's call: the money is + never lost (recoverable at the provider), and each failing delivery fires + `logError` → ntfy + the admin activity log + Sentry, so the operator is + alerted repeatedly across the ~3-day retry window and reconciles (manual + refund / fix) well before the prune. The trade accepted: alerts are + front-loaded in the retry window with a ~4-day quiet tail before the prune, + and the alert names the failure (an error code) rather than an impending + prune — but reacting to the failure IS the fix. If it ever needs a hard fix: + add a durable "payment captured" mark to `checkout_stages` (stamped on the + first paid delivery) and skip marked stages in `prunePendingCheckoutStages`, + or record the captured cash as a held `payment` leg on deferral so the record + is kept and operator-actionable (the held-cash model, already guarded). The + prune's existing `NOT EXISTS processed_payments` guard is unchanged. + (Raised by Codex on PR #1802; decision by owner.) --- diff --git a/plan.md b/plan.md new file mode 100644 index 0000000000..844ff69af5 --- /dev/null +++ b/plan.md @@ -0,0 +1,144 @@ +# Plan: staged-checkout hardening (PR #1802) + +This branch (`claude/branch-review-comparison-pcwbvd`, PR #1802) targets `main` +and contains the whole staged-checkout system: the original P1–P9 hardening +(see git history / the PR description) plus the review items and every Codex +finding worked through below. Everything ships with regression tests that fail +before the fix (repo rule) and a green `deno task precommit`. + +--- + +## Shipped + +- **P1–P9** — the original hardening of the staged flow (terminal paths, + pending-stage guards, expiry/retention, polish). +- **Item 1 — a gone stage is an impossible state, not a recovery** (37492fb). + `findStageProblem` (zero rows) and `getCheckoutStageOrNull` (dangling pending + stage) throw instead of booking fresh around a missed cascade. Earned by + blocking admin delete of a mid-payment record, blocking listing deletion + while it has a pending checkout, and cascading `checkout_stages` in the + orphan purge. +- **Item 6 — validate before the provider** (7a60e5c). `createStagedCheckout` + checks real-quantity availability and `active = 1` BEFORE creating the + provider session; an unbookable order is refused with + `public.checkout_unavailable` and never reaches the provider. The dead + balance-attendee branch was removed (only new bookings run through here). +- **Item 3 — money model DECISION: keep all legs at activation** (0f95fb1). + The "owed legs at staging" design was dropped by the operator's call: + an event group is immutable once posted (no growing it with a later payment + leg), per-row `price_paid` is sale-scoped to one stamped event group, and — + decisively — a pending stage with NO legs is excluded from every ledger sum + for free, so the totals are identical with none of the rework. Activation is + already atomic. The no-seat-holding policy is documented in AGENTS.md. +- **Item 2 (core) — the activation `booked` flip is a compare-and-set** + (d73c4f0): only this attendee's still-pending stage may flip, exactly one + row, or the whole activation rolls back. +- **Codex: delete/stage race** (5818fbb). `deleteListing` no longer cascades a + pending-staged attendee's rows, so a delete slipping past the preflight guard + leaves the paid order whole. +- **Codex: record money before exposing it** (21ed057). Both terminal + staged-refund paths post the held-payment leg BEFORE stamping the provider + payment reference; a failed placeholder post on a staged order throws (stage + stays pending, redelivery retries) instead of going terminal unrecorded. +- **Codex: held-conflict-cash guards** (99a13da, 6f49011). A `stage_active` + conflict leaves a `payment` leg with no sale ("held cash"). + `attendeeIdsHoldingUnreturnedCash` (primary-pinned, batch) now blocks + no-quantity edits, deletes, and merges until the cash is refunded. +- **Codex: heal path stamps the payment reference** (26ccc4e). The + orphan-ledger heal rebuilds the stored payment details a crash lost, so the + kept record's payment panel and refund path always resolve. +- **Codex: paid-stage prune — ACCEPTED EDGE, no code change** (fa8b15d, by the + owner's call). A paid-but-stuck stage (3+ days of failing + refunds/processing) can be pruned at 7 days; the money stays captured at the + provider and the operator is alerted on every failing delivery, so they + reconcile long before the prune. Documented with the future hard-fix paths + in TODO.md. +- **Codex: deleted-listing + held-cash + refund-retry round** (ecb5f81, + db71893, 0ffd541). Seven more findings from the delete/mid-payment race and + the held-cash model, all with fail-before regression tests: + - The owner-only Ledger tab and the standalone `/admin/ledger/:type/:ref/add` + write both hide/refuse while a checkout is pending, so a manual leg can't + combine with activation's own legs into a surprise balance. + - The Edit tab renders a locked "Deleted listing" row (never throws) for a + kept record whose listing was deleted mid-payment, and the Actions tab + hides for it (its routes 404 when the home listing is gone). + - Deleting a listing is refused while any attendee on it holds unreturned + conflict cash (the delete would cascade the booking line the refund needs). + - The held-cash no-quantity guard only blocks a save that removes the active + home line the in-app refund needs, not every no-quantity edit. + - A staged order whose provider refund fails stays retryable (no ledger + legs, stage pending) so the next delivery re-attempts it, instead of going + terminal and stranding the money. + +Every Codex review thread on the PR is answered. + +--- + +## Review items #8–#10 — shipped + +### #8 admin lifecycle — shipped (880ac8e, 4c8aeff) + +- The attendee page hides the Edit, Logistics, and Actions tabs (and the + send-email button) while a checkout is pending; a hidden tab's URL 404s and + a banner alert explains the locked state. A raced edit or logistics save + redirects to the always-visible overview with the refusal as a flash. +- Every attendee row carries a `pending_checkout` flag (projected per row in + the one shared attendee SELECT), so tables say "Payment in progress" + instead of "No quantity", and the CSV exports gain a conditional "Checkout + pending" column. +- A note's owner-only ledger link renders as plain text for non-owner admins + (`withoutLinksTo`), across the banner, the list summaries, and the + delete-note page. +- A booking row that outlives its listing shows as a plain "Deleted listing" + placeholder in the bookings table instead of disappearing — no dead link. + +### #9 provider lifecycle — resolved + +- **Remote expiry on local discard: in place, and the order is deliberate.** + The cancel path discards locally FIRST (the discard's "no payment claimed + this session" guard is what makes closing the remote session safe), then + best-effort `expireCheckoutSession` (Stripe implements it). Every gap — a + failed expire, a provider with no expiry API, a pruned stage paid weeks + later (Square links never lapse) — lands on the designed no-stage + fresh-booking path. Reordering would lose the claim guard for no safety + gain, so this stays as is. +- **SumUp EXPIRED: handled.** EXPIRED maps to the terminal `failed` status, + which the redirect/validate path answers with discard-the-staged-details + + the friendly cancel page (same as a declined card). Locked with a direct + provider-mapping test. +- **Stripe webhook endpoints: reconciled on setup.** Setup now lists the + account's endpoints and removes every one on OUR webhook url (plus the + recorded id, which may point at an old url) before recreating — a stray + left by a lost-id setup (e.g. a database restore) would otherwise fail + verification on every delivery forever. +- **Queryable stage relation: shipped with #8** (`pending_checkout` per-row + projection, `attendeeIdsWithPendingStage`, `listingHasPendingCheckout`, + `hasPendingCheckout`). + +### #10 repo cleanup — shipped + +- `DEPENDENT_ROW_TARGETS` is no longer exported. The three parallel purge + implementations (single-attendee delete, orphaned-attendee purge, and the + pending-checkout discard) now all build from one mechanism in `delete.ts`: + `attendeePurgeStatements` (internal) run through `runAttendeePurge` — a new + dependent table is cleaned by every purge path automatically, and the + orphan purge gained the `service_costs` parity it previously special-cased. +- No file this branch touched exceeds the grandfathered limits, and every + helper added here has production callers (no alias/test-only exports). +- The PR title/description are rewritten in plain language as the final step. + +### Not picked up (deliberately) + +- **#5** batch `activateStagedBooking`'s interactive transaction — recorded in + TODO.md ("Batch activateStagedBooking's interactive transaction"): a + substantive money-path rework wanting its own focused mutation run. +- **#7** side-effect-free validation — partially superseded by item 6 (the + preflight refusal is already a returned value); the remaining + refund-inside-validateAllItems reshaping can ride with #9's provider work. + +## Verification + +- Per slice: targeted `deno task test:files` on the touched suites. +- Per commit: full `deno task precommit` (typecheck incl. tests, strict lint, + full suite, 0% duplication, 100% coverage). +- `deno task precommit:mutation` before merge. diff --git a/review.md b/review.md new file mode 100644 index 0000000000..a51f4d15f6 --- /dev/null +++ b/review.md @@ -0,0 +1,281 @@ +# Deep-dive branch review + +Reviewed `2786a994` against fetched `origin/main` at `8d87b16f` (merge base +`34d162cd`). The latest five main commits merge cleanly and do not invalidate +the findings below. + +## P1 - Fix before merge + +### 1. Stripe treats an unfinished refund as completed + +**Code:** `src/shared/stripe-provider.ts:60-62`, `src/shared/stripe.ts:368-373`, +`src/features/api/payment-processing/store-refund.ts:294-336` + +Stripe can return a refund with `pending`, `requires_action`, `failed`, or +`canceled` status. The adapter returns `true` for every non-null refund object, +so the staged flow records `refund_cash`, marks the stage failed, and stops all +retries even when no money has reached the customer. A pending refund that later +fails therefore leaves the customer charged while the application and ledger +say it was returned. + +The provider tests cover only a returned object and a thrown request. Add status +cases and prove that only a completed refund can resolve a stage or post +`refund_cash`. + +### 2. A false-negative refund can later activate as a live ticket + +**Code:** `src/features/api/payment-processing/store-refund.ts:268-292`, +`src/features/api/payment-processing/index.ts:403-419`, +`src/features/api/payment-processing/create.ts:366-404` + +If the provider accepts a refund but the response and immediate status lookup +both fail, `tryRefund` returns false. The code leaves the stage pending, writes +no ledger legs, and releases the payment reservation. On redelivery it runs the +whole booking decision again. If the original refusal has disappeared, such as +capacity becoming available or registration reopening, the refunded payment is +activated into a live ticket. + +Existing retry tests keep the refusal in place. Add a regression where the first +refund settles remotely but reports failure, then make the order bookable before +redelivery and prove it cannot activate. + +### 3. A partial refund-ledger write is sealed as a complete outcome + +**Code:** `src/shared/refund-ledger.ts:309-335`, +`src/features/api/payment-processing/store-refund.ts:294-307`, +`src/features/api/payment-processing/index.ts:163-185` + +`recordPlaceholderRefund` commits the received `payment` and returned +`refund_cash` through two separate `postTransfers` calls. If the second call +fails, the first remains committed and the stage stays pending. On redelivery, +the payment leg makes the ledger preflight classify the session as `orphaned`. +That branch resolves the stage without retrying the missing `refund_cash` leg. +The ledger then permanently says the business still holds money that was +already returned. + +Tests cover a failure before the payment leg and a complete two-leg replay, but +not failure between the two posts. The cash round-trip needs one atomic post or +a replay state that can distinguish and finish a partial round-trip. + +### 4. A normal large cart exceeds the activation transaction budget + +**Code:** `src/shared/db/attendees/activate.ts:112-155`, +`src/shared/accounting/store.ts:107-116`, +`src/shared/checkout-complete.ts:67-81` + +Activation executes one round trip per booking, modifier use, and ledger leg, +plus the PII, ledger snapshot, event-group, finalize, and stage writes. A paid +cart with 12 listings and no modifiers already reaches 31 statements: 12 +booking updates, 13 money legs, and 6 fixed statements. The repository limit is +30. Development and tests throw and roll back at statement 31; production only +warns, leaving the same transaction exposed to the libsql timeout the guard is +designed to prevent. + +Because the staged attendee already owns the ticket token, recovery rethrows +rather than refunding. Every retry repeats the deterministic oversized +transaction, leaving captured money with no ticket or refund. The largest +activation test has two bookings. Add a realistic large-cart test and move this +input-sized write to one guarded batch. + +### 5. One retained deleted-listing row breaks the global CSV export + +**Code:** `src/shared/db/listings/delete.ts:19-23`, +`src/features/admin/attendees-list.ts:195-208`, +`src/features/admin/calendar-csv.ts:64-68` + +The branch deliberately preserves a pending stage's booking row when listing +deletion wins the preflight race. After the payment resolves, the unfiltered +attendee query still returns that row, but the listing collection contains only +live listings. `toCalendarAttendees` passes an undefined lookup to +`listingDetails`, which throws. One raced deletion therefore makes every global +`/admin/attendees/csv` request return 500 until the data is repaired. + +Add a server regression that exports a resolved attendee whose listing was +deleted and returns a valid placeholder row rather than throwing. + +### 6. Resolved deleted-listing records cannot be deleted + +**Code:** `src/features/admin/attendee-page.ts:74-79`, +`src/features/admin/attendees-route-helpers.ts:82-88`, +`src/shared/db/orphan-attendees.ts:31-37` + +Once the stage resolves, the detail page hides Actions because the home listing +is gone. Direct delete routes also require that listing and return 404. The +preserved `listing_attendees` row means orphan pruning does not consider the +attendee an orphan either. The result is retained personal data with no normal +operator or scheduled deletion path. + +Current tests assert that Actions is hidden but never require an alternative +deletion path. Add a resolved deleted-listing case that can be deleted together +with all dependent rows. + +## P2 - Should fix + +### 7. A stale payment claim prevents stage pruning forever + +**Code:** `src/shared/db/checkout-stages.ts:288-326`, +`src/shared/db/prune.ts:79-119`, `src/features/admin/listing-page-data.ts:203-216` + +Stage pruning excludes a session whenever any `processed_payments` row exists, +while payment pruning deliberately keeps unresolved reservations. The only +production call that deletes stale reservations is loading an admin listing +overview; it is not a scheduled task. A worker crash can therefore leave the +stage, attendee PII, and payment claim indefinitely, while the pending state +continues blocking edits, merges, and deletion. + +No test combines an expired stage with a stale unresolved payment row and runs +scheduled pruning. The scheduler needs to clear stale claims before pruning or +handle the pair atomically. + +### 8. Existing Stripe endpoints never gain the expiry event + +**Code:** `src/shared/stripe.ts:235-241`, +`src/features/admin/settings-stripe.ts:22-31`, +`src/features/admin/settings-helpers.ts:385-388` + +The branch adds `checkout.session.expired` only when creating a new endpoint, +but endpoint setup runs only when the owner submits a new secret. Existing +installations keep their old completed-only subscription after upgrade. Their +abandoned stages retain PII and block attendee/listing operations until the +seven-day pruner runs. + +Add an upgrade test for a stored Stripe key and old endpoint without resubmitting +the key, and reconcile required events during upgrade or startup. + +### 9. The attendee browser hides retained deleted-listing bookings + +**Code:** `src/shared/attendee-table-rows.ts:40-53` + +`groupAttendeeRows` resolves bookings only against live listings. It silently +drops a deleted line from a mixed order and omits an attendee whose only line is +the preserved deleted one. This makes the exceptional record hardest to find at +the time it needs operator attention. + +The pure test currently expects unknown listing IDs to be dropped. Add server +coverage for sole and mixed deleted-listing rows and render the same plain +placeholder used on the attendee detail page. + +### 10. Omitting the locked deleted-listing field deletes the row + +**Code:** `src/features/admin/attendee-form-model.ts:490-505,665-681`, +`src/shared/db/attendees/atomic-update.ts:306-338` + +The lock validator checks only submitted lines. A crafted edit that omits the +hidden deleted-listing line never presents it to validation; if another line +remains, the atomic editor interprets the omitted stored key as a deletion and +succeeds. This contradicts the UI promise that the row can only be kept as-is. + +Add a request test that omits the deleted line while retaining another booking +and assert rejection with all stored rows unchanged. + +### 11. Direct QR availability failures are returned as HTTP 500 + +**Code:** `src/shared/db/checkout-stages.ts:89-102`, +`src/features/public/ticket-payment.ts:161-168`, +`src/features/public/qr-book.ts:125-130` + +Staged checkout now returns an expected availability refusal, and +`runCheckoutFlow` passes it to `onError` as status 400. The QR callback ignores +the message and status and always creates a 500 response, so a sold-out scan is +reported as a server failure/invalid QR rather than an unavailable booking. + +Add a valid direct-checkout QR whose capacity is exhausted before checkout and +assert the user-facing refusal with a non-500 status. + +### 12. Resolved stages retain a weaker copy of every ticket token + +**Code:** `src/shared/db/checkout-stages.ts:112-119,264-286`, +`src/shared/db/attendees/activate.ts:145-149` + +Booked and failed transitions update only `state`; replay needs that state but +not `ticket_tokens`. The stage copy therefore remains environment-key +decryptable for the attendee's lifetime, while the canonical attendee copy is +protected inside the owner-key-encrypted PII blob. It is a second long-lived +bearer credential that can open the ticket and its attachments. + +Keep the resolved replay guard but scrub its token on every terminal transition. +Tests currently assert state retention without checking credential removal. + +### 13. Backups can split the new stage invariant across snapshots + +**Code:** `src/shared/db/backup.ts:193-209`, +`src/shared/db/checkout-stages.ts:202-213`, +`src/shared/db/attendees/activation-refusal.ts:43-61` + +Backups read tables independently and four at a time. A checkout commit between +the attendee, booking, and stage snapshots can produce a restore with a stage +but no attendee/lines, making every paid callback throw as an impossible state. +The opposite split restores a quantity-zero attendee without its stage, so the +callback creates a second attendee through the fresh path. + +Independent backup snapshots predate this branch, but the new hard relation +makes the restored payment path unrecoverable. Add a concurrent stage/backup/ +restore test and take a consistent snapshot or reconcile incomplete stage +triples on restore. + +### 14. A code-only rollback leaves a staged attendee permanently stuck + +**Code:** `src/shared/db/checkout-stages.ts:124-157`, +`src/features/api/payment-processing/index.ts:86-103,145-184` + +If this release stages attendee A and the script is rolled back before payment, +the old code ignores the stage and creates live attendee B for the callback. +When this release returns, processed-payment or booked-ledger replay returns B +before inspecting A's stage. A remains pending, cannot be edited or deleted, +blocks listing deletion, and cannot be pruned because the finalized payment row +uses the same session ID. + +The restore workflow can deploy an older commit to the same database, so this is +a persisted-data compatibility case rather than hypothetical API compatibility. +Add a pre-stage processor recovery case or make rollout/rollback fail closed for +pending staged sessions. + +## P3 - Cleanup + +### 15. Reference-style Markdown still renders forbidden ledger links + +**Code:** `src/shared/markdown.ts:83-92`, +`src/ui/templates/admin/attendee-notes.tsx:62` + +`withoutLinksTo` strips only inline `[text](target)` syntax. A note using +`[ledger][ref]` plus `[ref]: /admin/ledger/...` survives and renders a live +owner-only link for lower roles. Authorization still protects the target, but +the UI emits a forbidden link that cannot be followed. + +Parse link tokens rather than matching one Markdown spelling, and cover +reference-style links. + +### 16. Held-cash records still offer a delete action that always fails + +**Code:** `src/features/admin/attendee-page.ts:85-110`, +`src/features/admin/attendees.ts:93-105` + +The Actions tab always includes Delete when the listing exists, but the POST +always refuses while the attendee holds unreturned conflict cash. Hide or +disable Delete under the same held-cash condition so the page does not promise +an action the operator cannot complete. + +### 17. Resolved stages make the staging table and prune scan unbounded + +**Code:** `src/shared/db/checkout-stages.ts:288-326`, +`src/shared/db/migrations/schema/tables-attendees.ts:164-180` + +Every paid checkout leaves a booked or failed stage until its attendee is +deleted. The scheduled prune filters by `state` and `created_at`, but the table +has only its session primary key and an attendee index. Over time each prune and +the listing-delete pending-stage subquery scans the lifetime history of paid +checkouts. + +Define resolved-row retention and add an index beginning with `state, +created_at`; tests currently confirm old failed rows survive but do not require a +bounded table or indexed prune plan. + +## Verification + +- Fetched the latest remote refs; the review branch itself was already current. +- `git merge-tree --write-tree HEAD origin/main` completed without conflicts. +- Focused payment/admin verification runs completed with 357 passing tests. +- Lint, typecheck, copy checks, the edge build, and the full coverage suite pass. +- The jscpd precommit step could not start its dynamically linked npm binary on + NixOS, including inside `nix develop`; it did not report an actual clone. +- Existing tests do not exercise the exact failure scenarios listed above. diff --git a/scripts/mutation/equivalent-mutants.txt b/scripts/mutation/equivalent-mutants.txt index cc6bf7c355..ad988d1eee 100644 --- a/scripts/mutation/equivalent-mutants.txt +++ b/scripts/mutation/equivalent-mutants.txt @@ -831,13 +831,12 @@ src/features/admin/attendees-merge.ts:188:40 && → || # keptIsPinned = Boole src/features/admin/attendees-merge.ts:344:54 target → "" # parsePiiDecisions fallback string: every consumer of decision.pii (pickPiiField, the kept/address ternary on :186, mergedPiiName on :319, applyPiiDecisions on attendee-merge.ts:501) only tests `=== "source"`, so any non-"source" value (including "" and "target") is treated identically as "keep target" src/features/admin/attendees-merge.ts:374:15 skip_source → "" # toBookingChoice comparison `raw === "skip_source"` → `raw === ""`: parse "skip_source" as the "keep_target" fallback. Unobservable — both fall to applyBookingDecisions' same `else` branch (no DB write, bookingsSkipped++) and discardedSaleAmount returns sourceSaleAmount for both, so the merge outcome is identical for every input src/features/admin/attendees-merge.ts:408:10 return undefined → return undefined # toMoneyChoice's fallback is already `return undefined`; the mutation is a no-op (it produces identical code), so no test can ever observe a difference -# contact-tokens.ts — `?? → ||` and `return null → return undefined` sites whose +# contact-tokens.ts — `?? → ||` sites whose # operand can never be a falsy-but-non-null value, or whose result is only ever # consumed by a nullish check. Each is provably equivalent from the types. -src/shared/db/contact-tokens.ts:92:30 - → + # splitTokenBlob's `separatorAt === -1`: every app-written line has a tab at the marker-length position (64), and a malformed separator-less line's first-chars marker can never equal a real BlindIndex (an hmac hex), and its ciphertext never decrypts, so taking the else-branch for a tab-less line (marker = raw.slice(0, -1)) is unobservable through ensureBookingToken/removeBookingToken/getRecentBookingTokens -src/shared/db/contact-tokens.ts:177:35 ?? → || # loadTokenBlob's `row?.attendee_tokens_blob ?? null`: attendee_tokens_blob is string|undefined; the only falsy-non-null is "" and tokenLinesFrom("") === tokenLinesFrom(null) === [], so ?? and || agree -src/shared/db/contact-tokens.ts:218:22 return null → return undefined # removeBookingToken's no-match return; removedSource is only ever consumed by `removedSource ?? sync.source`, and null and undefined agree under ?? -src/shared/db/contact-tokens.ts:289:18 ?? → || # `removedSource ?? sync.source`: removedSource is BookingSource|null, and both BookingSource values ("admin","public") are non-empty truthy strings, so it is never falsy-but-non-null; ?? and || agree +src/shared/db/contact-tokens.ts:97:30 - → + # splitTokenBlob's `separatorAt === -1`: every app-written line has a tab at the marker-length position (64), and a malformed separator-less line's first-chars marker can never equal a real BlindIndex (an hmac hex), and its ciphertext never decrypts, so taking the else-branch for a tab-less line (marker = raw.slice(0, -1)) is unobservable through ensureBookingToken/removeBookingToken/getRecentBookingTokens +src/shared/db/contact-tokens.ts:174:35 ?? → || # loadTokenBlob's `row?.attendee_tokens_blob ?? null`: attendee_tokens_blob is string|undefined; the only falsy-non-null is "" and tokenLinesFrom("") === tokenLinesFrom(null) === [], so ?? and || agree +src/shared/db/contact-tokens.ts:286:18 ?? → || # `removedSource ?? sync.source`: removedSource is BookingSource|null, and both BookingSource values ("admin","public") are non-empty truthy strings, so it is never falsy-but-non-null; ?? and || agree # attendees/tokens.ts — `?? → ||` sites where the operand is a Map#get() whose # value is always a truthy object or array (never a falsy-but-non-null value), diff --git a/src/docs/database.ts b/src/docs/database.ts index 8c746233f2..c9b9f9dc51 100644 --- a/src/docs/database.ts +++ b/src/docs/database.ts @@ -50,12 +50,8 @@ export * from "#shared/db/attendees/capacity/groups.ts"; export * from "#shared/db/attendees/capacity/range.ts"; export * from "#shared/db/attendees/capacity/remaining.ts"; export type { ListingCapacityRow } from "#shared/db/attendees/capacity/types.ts"; -export { - type BookingBatchPlan, - buildAttendeeInsert, - ensureAllBookings, - reverseOrderActivity, -} from "#shared/db/attendees/create.ts"; +export { buildAttendeeInsert } from "#shared/db/attendees/create.ts"; +export type { BookingBatchPlan } from "#shared/db/attendees/create-batch.ts"; export * from "#shared/db/attendees/delete.ts"; export * from "#shared/db/attendees/pii.ts"; export * from "#shared/db/attendees/queries.ts"; diff --git a/src/features/admin/api.ts b/src/features/admin/api.ts index e90b7046a6..f6d9145a06 100644 --- a/src/features/admin/api.ts +++ b/src/features/admin/api.ts @@ -36,8 +36,8 @@ import { } from "#shared/db/listings/records.ts"; import type { ListingInput } from "#shared/db/listings/table.ts"; import { - deleteOrphanedAddOnError, generateUniqueListingSlug, + listingDeleteError, listingInputToEdge, parseUpdatedListingSlug, performListingDelete, @@ -376,11 +376,11 @@ const handleDeleteListing: RouteHandlerFn = (request, { listingId }) => "Listing name", ); if (error) return apiErrorResponse(error); - // Same orphaned-add-on guard the HTML delete uses: reject - // a delete that would leave a child-scoped add-on reachable only through a - // suppressed child, with the same 400 + error as the deactivate API. - const orphanError = await deleteOrphanedAddOnError(listing.id); - if (orphanError) return apiErrorResponse(orphanError); + // Same guard the HTML delete uses: reject a delete that would leave a + // child-scoped add-on reachable only through a suppressed child, or strand a + // booking still mid-payment, with the same 400 + error. + const guardError = await listingDeleteError(listing.id); + if (guardError) return apiErrorResponse(guardError); await performListingDelete(listing); return jsonResponse({ status: "ok" }); }); diff --git a/src/features/admin/attendee-action-state.ts b/src/features/admin/attendee-action-state.ts new file mode 100644 index 0000000000..064c8354c2 --- /dev/null +++ b/src/features/admin/attendee-action-state.ts @@ -0,0 +1,28 @@ +import { attendeeHoldsUnreturnedCash } from "#shared/db/attendees/queries.ts"; +import { hasPendingCheckout } from "#shared/db/checkout-stages.ts"; + +/** Write-action gates shared by the attendee page and its action routes. */ +export type AttendeeActionState = { + canDelete: boolean; + holdsUnreturnedCash: boolean; + pendingCheckout: boolean; +}; + +/** Load the live states that block attendee deletion. The POST route checks + * them again immediately before deleting, so a race still fails closed. */ +export const loadAttendeeActionState = async ( + attendeeId: number, + knownPendingCheckout?: boolean, +): Promise => { + const [pendingCheckout, holdsUnreturnedCash] = await Promise.all([ + knownPendingCheckout === undefined + ? hasPendingCheckout(attendeeId) + : Promise.resolve(knownPendingCheckout), + attendeeHoldsUnreturnedCash(attendeeId), + ]); + return { + canDelete: !pendingCheckout && !holdsUnreturnedCash, + holdsUnreturnedCash, + pendingCheckout, + }; +}; diff --git a/src/features/admin/attendee-delete.ts b/src/features/admin/attendee-delete.ts new file mode 100644 index 0000000000..ed11334ac7 --- /dev/null +++ b/src/features/admin/attendee-delete.ts @@ -0,0 +1,103 @@ +import { t } from "#i18n"; +import { loadAttendeeActionState } from "#routes/admin/attendee-action-state.ts"; +import { + type AttendeeRecord, + attendeeFormAction, + attendeeRecordActionPage, + verifiedAttendeeRecordAction, +} from "#routes/admin/attendees-route-helpers.ts"; +import { redirect } from "#routes/response.ts"; +import { logActivity } from "#shared/db/activityLog.ts"; +import { deleteAttendee } from "#shared/db/attendees/delete.ts"; +import { hasAnyPaymentReference } from "#shared/db/payment-references.ts"; +import { isIncompletePayment } from "#shared/incomplete-payment.ts"; +import { isPaidListing } from "#shared/types.ts"; +import { adminDeleteAttendeePage } from "#templates/admin/attendees.tsx"; + +/** The delete confirm page is available only while its POST can succeed. */ +export const handleAdminAttendeeDeleteGet = attendeeRecordActionPage( + adminDeleteAttendeePage, + async ({ attendee }) => + (await loadAttendeeActionState(attendee.id)).canDelete, +); + +/** Delete an attendee, log the activity, and redirect. */ +const deleteAttendeeAndRedirect = async ( + attendeeId: number, + listingId: number | null, + redirectTo: string, + activityMessage: string, + flashMessage: string, + opts?: Parameters[3], + releaseBookings = true, +): Promise => { + const actionState = await loadAttendeeActionState(attendeeId); + // A pending staged checkout claims the exact rows when the payment lands, so + // deleting the record mid-payment would strand the paid order. + if (actionState.pendingCheckout) { + return redirect( + redirectTo, + t("attendee_form.error_pending_checkout"), + false, + opts, + ); + } + // Held provider cash needs the attendee's refund path. Deleting first would + // leave orphaned ledger cash that the operator could no longer return. + if (actionState.holdsUnreturnedCash) { + return redirect( + redirectTo, + t("attendee_form.error_held_cash"), + false, + opts, + ); + } + await deleteAttendee(attendeeId, { releaseBookings }); + await logActivity(activityMessage, listingId, attendeeId); + return redirect(redirectTo, flashMessage, true, opts); +}; + +/** Delete a retained record. Its home listing may already be gone. */ +export const handleAttendeeDelete = verifiedAttendeeRecordAction( + "delete", + "deletion", + (data: AttendeeRecord, form) => + deleteAttendeeAndRedirect( + data.attendee.id, + data.listing?.id ?? null, + "/admin/attendees", + data.listing + ? `Attendee deleted from '${data.listing.name}'` + : `Attendee '${data.attendee.name}' deleted`, + t("success.attendee_deleted"), + { form }, + form.getFlag("release_bookings"), + ), +); + +/** Delete a genuinely incomplete payment from a live listing roster. */ +export const handleDeleteIncomplete = attendeeFormAction( + async (data, _session, _form, listingId, attendeeId) => { + if ( + !isIncompletePayment( + data.attendee, + isPaidListing(data.listing), + await hasAnyPaymentReference(data.attendee), + ) + ) { + return redirect( + `/admin/listing/${listingId}/attendees`, + t("error.attendee_no_incomplete_payment"), + false, + ); + } + + return deleteAttendeeAndRedirect( + attendeeId, + listingId, + `/admin/listing/${listingId}/attendees`, + `Incomplete attendee deleted from '${data.listing.name}'`, + t("success.incomplete_removed"), + ); + }, +); diff --git a/src/features/admin/attendee-form-model.ts b/src/features/admin/attendee-form-model.ts index c4d7fb955b..248e4ffd81 100644 --- a/src/features/admin/attendee-form-model.ts +++ b/src/features/admin/attendee-form-model.ts @@ -128,6 +128,11 @@ export type AttendeeBooking = { listingId: number; listingName: string; listingActive: boolean; + /** True when the booked listing no longer exists (deleted after this row was + * written — e.g. a delete racing a mid-payment checkout, whose rows are kept). + * The table then shows a plain "Deleted listing" placeholder, never a link + * that would 404. */ + listingDeleted: boolean; quantity: number; startAt: string | null; endAt: string | null; @@ -191,10 +196,14 @@ type SharedDates = { export const isBookedLine = (line: AttendeeFormLine): boolean => line.quantity !== null && line.quantity >= 1 && line.listing !== null; -/** True when the "no quantity" box is ticked for a resolvable listing: a - * deliberate quantity-0 sentinel line to keep, not a real booking. */ +/** True when the "no quantity" box is ticked for a line worth keeping: a + * deliberate quantity-0 sentinel, not a real booking. The listing must + * resolve OR the line must carry a stored row — a stored row can outlive its + * listing (a delete racing a mid-payment checkout keeps the rows), and the + * editor locks that row as a permanent no-quantity line so a save never + * silently drops it. */ export const isNoQuantityLine = (line: AttendeeFormLine): boolean => - line.noQuantity && line.listing !== null; + line.noQuantity && (line.listing !== null || line.existingBooking !== null); /** True when a line carries a captured payment (price_paid > 0 on its existing * booking). Such a line can't be marked no-quantity until the charge is @@ -231,22 +240,24 @@ export const bookingDurationDays = ( /** * Project the form's listing lines into read-only booking summaries: one per * line that carries a saved booking (the attendee's current registrations), - * dropping not-yet-booked rows. A booked line always resolves its listing; the - * `listing` guard only keeps a hand-crafted POST — one pairing a saved booking - * key with an unknown listing id — from throwing by dropping that bogus line. + * dropping not-yet-booked rows. A saved row whose listing no longer exists — + * a delete racing a mid-payment checkout keeps the rows — still shows, as a + * plain "Deleted listing" placeholder, so the operator always sees what the + * customer booked. */ export const attendeeBookingsFromLines = ( lines: AttendeeFormLine[], ): AttendeeBooking[] => mapNotNullish((line: AttendeeFormLine): AttendeeBooking | null => { const { existingBooking: booking, listing } = line; - if (!booking || !listing) return null; + if (!booking) return null; return { checkedIn: Boolean(booking.checked_in), endAt: booking.end_at, - listingActive: listing.active, + listingActive: listing?.active ?? false, + listingDeleted: listing === null, listingId: line.listingId, - listingName: listing.name, + listingName: listing?.name ?? t("attendee_form.deleted_listing"), parentListingId: booking.parent_listing_id, quantity: booking.quantity, refunded: Boolean(booking.refunded), @@ -476,6 +487,41 @@ const validatePaidNoQuantity = (parsed: ParsedAttendeeForm): string | null => ? PAID_NO_QUANTITY_MESSAGE() : null; +/** Form-wide guard: a row whose listing was deleted can only be kept as-is. + * The editor renders it locked (a hidden no-quantity tick, no controls), so + * this only fires on a hand-crafted submission that changes, duplicates, or + * omits the row. */ +const validateDeletedListingLocked = ( + parsed: ParsedAttendeeForm, + lockedBookings: ReadonlyMap, +): string | null => { + const changesBooking = ( + line: AttendeeFormLine, + booking: ListingAttendeeRow, + ): boolean => + line.listing !== null || + !line.noQuantity || + line.listingId !== booking.listing_id || + line.quantity !== booking.quantity; + const submittedByKey = Map.groupBy(parsed.lines, (line) => line.key); + const changedSubmittedLine = parsed.lines.some( + (line) => + line.existingBooking !== null && + line.listing === null && + changesBooking(line, line.existingBooking), + ); + const changedLockedBooking = [...lockedBookings].some(([key, booking]) => { + const submitted = submittedByKey.get(key) ?? []; + return ( + submitted.length !== 1 || + submitted.some((line) => changesBooking(line, booking)) + ); + }); + return changedSubmittedLine || changedLockedBooking + ? t("attendee_form.deleted_listing_locked") + : null; +}; + /** * Validate the attendee block, the shared date, and each booked line. A daily * booking requires a valid shared start date; everything date- or @@ -483,6 +529,7 @@ const validatePaidNoQuantity = (parsed: ParsedAttendeeForm): string | null => */ export const validateParsedForm = ( parsed: ParsedAttendeeForm, + lockedBookings: ReadonlyMap = new Map(), ): ValidationResult => { const attendeeError = validateAttendeeBlock(parsed); const hasDailyBooking = parsed.lines.some(isBookedDaily); @@ -490,7 +537,9 @@ export const validateParsedForm = ( hasDailyBooking && !isIsoDate(parsed.startDate) ? t("attendee_form.date_required") : null; - const formError = validatePaidNoQuantity(parsed); + const formError = + validatePaidNoQuantity(parsed) ?? + validateDeletedListingLocked(parsed, lockedBookings); const lineErrors = new Map(); for (let i = 0; i < parsed.lines.length; i++) { @@ -516,12 +565,30 @@ export const validateParsedForm = ( // Mutation adapters — convert parsed form into the DB-layer input shapes // --------------------------------------------------------------------------- +/** A line's booking range: the start date (null = no date) and its length. */ +type LineDateRange = { date: string | null; durationDays: number }; + /** Booking date/duration for a line: the shared range for daily listings, none - * for standard listings. */ -const lineDate = (line: AttendeeFormLine, parsed: ParsedAttendeeForm) => - line.listing!.listing_type === "daily" + * for standard listings. A deleted listing's locked row keeps its stored range + * unchanged — the shared date fields only apply to listings that still exist, + * and only a line with a stored row is retained when its listing is gone + * ({@link isNoQuantityLine}), so the row is always there to read. */ +const lineDate = ( + line: AttendeeFormLine, + parsed: ParsedAttendeeForm, +): LineDateRange => { + if (line.listing === null) { + const booking = line.existingBooking!; + return { + // A stored row without a range stays range-less. + date: booking.start_at?.slice(0, 10) ?? null, + durationDays: bookingDurationDays(booking) ?? 1, + }; + } + return line.listing.listing_type === "daily" ? { date: parsed.startDate, durationDays: parsed.dayCount } : { date: null, durationDays: 1 }; +}; /** The booking fields every persisted line carries — its date range, its own * booking path, and its quantity. Shared by the create and edit adapters so @@ -529,9 +596,7 @@ const lineDate = (line: AttendeeFormLine, parsed: ParsedAttendeeForm) => const lineBookingFields = ( line: AttendeeFormLine, parsed: ParsedAttendeeForm, -): { - date: string | null; - durationDays: number; +): LineDateRange & { listingId: number; packageGroupId: number; quantity: number; diff --git a/src/features/admin/attendee-form-routes.ts b/src/features/admin/attendee-form-routes.ts index a24e6bcffa..50225fcfcb 100644 --- a/src/features/admin/attendee-form-routes.ts +++ b/src/features/admin/attendee-form-routes.ts @@ -60,19 +60,19 @@ import { getSearchParam } from "#routes/url.ts"; import { manualAddLedgerPoster } from "#shared/checkout-complete.ts"; import { logActivity } from "#shared/db/activityLog.ts"; import { attendeeStatuses } from "#shared/db/attendee-statuses.ts"; -import type { - CreateAttendeeResult, - ListingAttendeeRow, -} from "#shared/db/attendee-types.ts"; +import type { ListingAttendeeRow } from "#shared/db/attendee-types.ts"; import { applyAttendeeAtomicEdit, createAttendeeAtomic, } from "#shared/db/attendees/api.ts"; -import { ensureAllBookings } from "#shared/db/attendees/create.ts"; import { buildPiiBlob, encryptPiiBlob } from "#shared/db/attendees/pii.ts"; -import { hasPaidLine } from "#shared/db/attendees/queries.ts"; +import { + attendeeHoldsUnreturnedCash, + hasPaidLine, +} from "#shared/db/attendees/queries.ts"; import { updateAttendeeStatus } from "#shared/db/attendees/update.ts"; import { hasAssignedBuiltSite } from "#shared/db/built-sites.ts"; +import { hasPendingCheckout } from "#shared/db/checkout-stages.ts"; import { syncAttendeeContactTokens } from "#shared/db/contact-tokens.ts"; import { getAllListings } from "#shared/db/listings/records.ts"; import { @@ -216,6 +216,20 @@ const handleSubmitInner = async ( selectedTextAnswers, } = edit; + // A staged checkout's rows are claimed by the payment when it lands; editing + // them mid-payment would strand the paid order. Fail closed until the payment + // finishes. The Edit tab is hidden while pending (so it can't re-render the + // refusal in place, and would 404 as the failure target) — a submission that + // raced the checkout redirects to the always-visible overview instead, where + // the banner explains the locked state. + if (attendee !== null && (await hasPendingCheckout(attendee.id))) { + return redirect( + attendeePage.path(attendee.id, ""), + t("attendee_form.error_pending_checkout"), + false, + ); + } + const listingsById = byId(await getAllListings()); // Coerce a missing/blank status back to the public default (the form offers // no "no status" choice) — the same resolver the template pre-selects with. @@ -237,7 +251,12 @@ const handleSubmitInner = async ( selectedTextAnswers, }; - const result = validateParsedForm(parsed); + const lockedDeletedBookings = new Map( + [...existingByKey].filter( + ([, booking]) => !listingsById.has(booking.listing_id), + ), + ); + const result = validateParsedForm(parsed, lockedDeletedBookings); // Re-render the submitted form in place with the given errors merged onto // the render options — the validation and save failure paths share this. const showErrors = async ( @@ -344,7 +363,7 @@ const applyLogisticsPlan = ( ? setLogisticsAssignments(attendeeId, plan.split, plan.perListing) : Promise.resolve(); -/** Run the atomic create flow. All-or-nothing via `ensureAllBookings`. */ +/** Run the all-or-nothing atomic create flow. */ const applyCreate = async ( parsed: ParsedAttendeeForm, logisticsPlan: LogisticsPlan, @@ -368,18 +387,10 @@ const applyCreate = async ( }, manualAddLedgerPoster(toLedgerOrder(parsed)), ); - const check = await ensureAllBookings( - createResult, - input.bookings.length, - "admin", - ); - if (!check.ok) { + if (!createResult.success) { return { ok: false, saveError: t("attendee_form.error_capacity") }; } - const { attendees } = createResult as Extract< - CreateAttendeeResult, - { success: true } - >; + const { attendees } = createResult; const firstListingId = input.bookings[0]!.listingId; const newId = attendees[0]!.id; await applyLogisticsPlan(newId, logisticsPlan); @@ -421,9 +432,27 @@ const applyEdit = async ( saveError: t("attendee_form.error_built_site_no_qty"), }; } - // Block marking a paid line no-quantity, even when a stale form key hid the - // existing booking from the per-line model guard. - if (await anyNoQuantityLineMatches(attendeeId, parsed.lines, hasPaidLine)) { + // A save can't strip the in-app refund path while money is still ours to + // return — refund first. Two ways it would, both refused with the same message: + // - marking a SALE-backed line no-quantity (hasPaidLine, even when a stale + // form key hid the existing booking from the per-line model guard); or + // - removing the active home line while the attendee holds a stage_active + // conflict's un-refunded cash. That cash has no sale leg, so hasPaidLine + // misses it, and the refund route needs an active home line + // (canRefundAttendee → hasActiveBookingLine). Only a save that REMOVES that + // line (marks it no-quantity, zeroes it, or omits it) strands the cash; a + // save that keeps an active home line leaves the refund path intact, so the + // operator can still fix the quantities the conflict note asks about. + const keepsHomeRefundLine = parsed.lines.some( + (line) => + line.listingId === attendee.listing_id && + line.quantity !== null && + line.quantity >= 1, + ); + if ( + (await anyNoQuantityLineMatches(attendeeId, parsed.lines, hasPaidLine)) || + (!keepsHomeRefundLine && (await attendeeHoldsUnreturnedCash(attendeeId))) + ) { return { ok: false, saveError: t("attendee_form.error_paid_no_qty") }; } diff --git a/src/features/admin/attendee-logistics-routes.ts b/src/features/admin/attendee-logistics-routes.ts index 0418a6c8ad..cb3b70b9e3 100644 --- a/src/features/admin/attendee-logistics-routes.ts +++ b/src/features/admin/attendee-logistics-routes.ts @@ -35,6 +35,7 @@ import type { TypedRouteHandler } from "#routes/router.ts"; import { logActivity } from "#shared/db/activityLog.ts"; /* jscpd:ignore-end */ import { updateAttendeePII } from "#shared/db/attendees/update.ts"; +import { hasPendingCheckout } from "#shared/db/checkout-stages.ts"; import { setLogisticsAssignments } from "#shared/db/logistics.ts"; import { LOGISTICS_DEMO_FIELDS, @@ -113,6 +114,20 @@ const handleLogisticsSubmit = async ( ); if (!entity) return notFoundResponse(); + // A staged checkout's rows are claimed by the payment when it lands, and + // activation rewrites the stored details from the signed order — a Logistics + // save mid-payment would be silently lost. Fail closed until the payment + // finishes, exactly like the Edit tab: redirect to the always-visible + // overview (the Logistics tab is hidden while pending, so its URL 404s), + // where the banner explains the locked state. + if (await hasPendingCheckout(attendeeId)) { + return redirect( + attendeePage.path(attendeeId, ""), + t("attendee_form.error_pending_checkout"), + false, + ); + } + const values: LogisticsFormValues = { address: form.getString("address"), lat: form.getString(LAT_FIELD), diff --git a/src/features/admin/attendee-page-data.ts b/src/features/admin/attendee-page-data.ts index 8313bfa33f..2e00d63bf5 100644 --- a/src/features/admin/attendee-page-data.ts +++ b/src/features/admin/attendee-page-data.ts @@ -27,6 +27,7 @@ import { import { getAttendeeOrderSummary } from "#shared/db/attendees/balance.ts"; import { checkLinesCapacity } from "#shared/db/attendees/capacity/checks.ts"; import { hasActiveBookingLine } from "#shared/db/attendees/queries.ts"; +import { hasPendingCheckout } from "#shared/db/checkout-stages.ts"; import { getContactRecordOrRepair, hashEmail, @@ -40,7 +41,10 @@ import { packageMemberMaps, } from "#shared/db/groups.ts"; import { getChildrenForParents } from "#shared/db/listing-parents.ts"; -import { getAllListings } from "#shared/db/listings/records.ts"; +import { + getAllListings, + getListingWithCount, +} from "#shared/db/listings/records.ts"; import { hasRefundPaymentReference } from "#shared/db/payment-references.ts"; import type { QuestionWithAnswers, @@ -64,8 +68,17 @@ import type { * loads once and every tab shares. */ export type LoadedAttendee = { attendee: Attendee; + /** Loaded only for the Actions tab; null fails closed elsewhere. */ + canDelete: boolean | null; canRefund: boolean; existing: ExistingLine[]; + /** True when the attendee's home listing still exists. Listing-based actions + * hide when it is gone; record deletion remains available. */ + homeListingExists: boolean; + /** True while this record's checkout is staged and the customer may still be + * paying. The write tabs and actions hide behind it — every mutation is also + * blocked server-side, so the page must not offer controls that only fail. */ + pendingCheckout: boolean; }; const canRefundAttendee = async (attendee: Attendee): Promise => { @@ -81,11 +94,21 @@ export const loadAttendeeForEdit: ( attendeeId: number, ) => Promise = withDecryptedAttendee( async (attendee) => { - const [canRefund, existing] = await Promise.all([ - canRefundAttendee(attendee), - loadExistingLines(attendee.id), - ]); - return { attendee, canRefund, existing }; + const [canRefund, existing, pendingCheckout, homeListing] = + await Promise.all([ + canRefundAttendee(attendee), + loadExistingLines(attendee.id), + hasPendingCheckout(attendee.id), + getListingWithCount(attendee.listing_id), + ]); + return { + attendee, + canDelete: null, + canRefund, + existing, + homeListingExists: homeListing !== null, + pendingCheckout, + }; }, ); @@ -167,9 +190,11 @@ const lineForExistingRow = ( error: null, existingBooking: existing.booking, key: existing.key, - // A stored row's listing always renders: booked inactive listings are in the - // render set, and deleting a listing deletes its rows. - listing: listingsById.get(existing.booking.listing_id)!, + // Booked inactive listings are in the render set, so a stored row's listing + // normally resolves. It is null for a DELETED listing: a delete racing a + // mid-payment checkout keeps the staged rows, and the read-only bookings + // table shows those as a "Deleted listing" placeholder. + listing: listingsById.get(existing.booking.listing_id) ?? null, listingId: existing.booking.listing_id, // A stored quantity-0 line renders with the "no quantity" box ticked. noQuantity: existing.booking.quantity === 0, diff --git a/src/features/admin/attendee-page.ts b/src/features/admin/attendee-page.ts index 13c5b4cfd5..19d4d9b075 100644 --- a/src/features/admin/attendee-page.ts +++ b/src/features/admin/attendee-page.ts @@ -16,6 +16,7 @@ */ import { t } from "#i18n"; +import { loadAttendeeActionState } from "#routes/admin/attendee-action-state.ts"; import { attendeeBookingsFromLines } from "#routes/admin/attendee-form-model.ts"; import { loadAttendeeLedgerPanel } from "#routes/admin/attendee-ledger-panel.ts"; import { loadLogisticsPanel } from "#routes/admin/attendee-logistics-tab.ts"; @@ -67,6 +68,11 @@ import { PaymentDetails } from "#templates/admin/attendees.tsx"; const actionBase = ({ attendee }: LoadedAttendee): string => `/admin/attendees/${attendee.id}`; +/** The visibility gate for everything that changes a record: shown only while + * no checkout is pending. Mirrors the server-side pending guards exactly. */ +const notMidPayment = ({ pendingCheckout }: LoadedAttendee): boolean => + !pendingCheckout; + /** Thread the current tab back through a sub-action's confirm page. */ const withReturn = (href: string, ctx: PageCtx): string => `${href}?return_url=${encodeURIComponent(ctx.returnUrl)}`; @@ -84,18 +90,21 @@ const ATTENDEE_ACTIONS: readonly ActionDef[] = [ withReturn(`${actionBase(entity)}/resend-notification`, ctx), icon: "rotate-ccw", labelKey: "attendee_form.action_resend", + visible: ({ homeListingExists }) => homeListingExists, }, { href: ({ attendee }) => `/admin/sms?listing=${attendee.listing_id}&attendee=${attendee.id}`, icon: "arrow-right", labelKey: "attendee_form.action_send_text", + visible: ({ homeListingExists }) => homeListingExists, }, { danger: true, href: (entity) => `${actionBase(entity)}/delete`, icon: "trash-2", labelKey: "attendee_form.action_delete", + visible: ({ canDelete }) => canDelete === true, }, ]; @@ -129,12 +138,13 @@ const overviewTab: TabDef = { sections: [ { kind: "summary", - rows: ({ attendee, existing }) => + rows: ({ attendee, existing, pendingCheckout }) => Promise.resolve( attendeeSummaryRows({ allowedDomain: getEffectiveDomain(), attendee, hasRealLine: existing.some((line) => line.booking.quantity > 0), + pendingCheckout, phonePrefix: settings.phonePrefix, }), ), @@ -178,7 +188,7 @@ const overviewTab: TabDef = { }, { kind: "custom", - load: async ({ attendee }, ctx) => { + load: async ({ attendee, pendingCheckout }, ctx) => { const [contactRecords, previousBookings] = await Promise.all([ loadContactRecords(attendee), loadPreviousBookings(attendee), @@ -187,6 +197,7 @@ const overviewTab: TabDef = { attendee, contactRecords, isOwner: isOwnerRole(ctx.session.adminLevel), + pendingCheckout, previousBookings, }); }, @@ -197,14 +208,15 @@ const overviewTab: TabDef = { /** The tabbed attendee page. */ export const attendeePage: EntityPage = defineEntityPage({ - banner: async ({ attendee }, ctx) => + banner: async ({ attendee, pendingCheckout }, ctx) => attendeeBanner({ attendee, - isOwner: ctx.session.adminLevel === "owner", + isOwner: isOwnerRole(ctx.session.adminLevel), notes: await getNotesForAttendee( attendee.id, await requireRequestPrivateKey(), ), + pendingCheckout, statuses: await attendeeStatuses.getAll(), }), basePath: (id) => `/admin/attendees/${id}`, @@ -219,8 +231,17 @@ export const attendeePage: EntityPage = defineEntityPage({ ), tabs: [ overviewTab, - writeFormTab("edit", "entity.tab.edit", loadEditPanel), - writeFormTab("logistics", "entity.tab.logistics", loadLogisticsPanel), + // The write tabs and the actions tab hide while a checkout is pending: the + // payment claims the exact staged rows when it lands, so every mutation is + // blocked server-side — the page must not offer tabs that only fail. The + // banner explains the state; a hidden tab's URL 404s (visible IS the gate). + writeFormTab("edit", "entity.tab.edit", loadEditPanel, notMidPayment), + writeFormTab( + "logistics", + "entity.tab.logistics", + loadLogisticsPanel, + notMidPayment, + ), { labelKey: "entity.tab.ledger", sections: [ @@ -237,8 +258,13 @@ export const attendeePage: EntityPage = defineEntityPage({ ], slug: "ledger", // The ledger exposes money movements and the customer pay link, so it is - // owner-only — matching the standalone /admin/ledger* routes. - visible: (_entity, session) => session.adminLevel === "owner", + // owner-only — matching the standalone /admin/ledger* routes. It also + // hides while a checkout is pending: it embeds manual charge/payment + // forms, and a manual leg posted mid-payment would combine with + // activation's own legs into a surprise balance (a pending stage has + // zero legs, so the tab has nothing to show anyway). + visible: (entity, session) => + session.adminLevel === "owner" && notMidPayment(entity), }, { labelKey: "entity.tab.activity", @@ -257,6 +283,15 @@ export const attendeePage: EntityPage = defineEntityPage({ { actions: ATTENDEE_ACTIONS, kind: "actions", + prepare: async (entity) => ({ + ...entity, + canDelete: ( + await loadAttendeeActionState( + entity.attendee.id, + entity.pendingCheckout, + ) + ).canDelete, + }), titleKey: "entity.tab.actions", }, { @@ -266,6 +301,7 @@ export const attendeePage: EntityPage = defineEntityPage({ }, ], slug: "actions", + visible: notMidPayment, }, ], titleOf: ({ attendee }) => diff --git a/src/features/admin/attendees-csv.ts b/src/features/admin/attendees-csv.ts index ebd1b7c0ef..a26967e472 100644 --- a/src/features/admin/attendees-csv.ts +++ b/src/features/admin/attendees-csv.ts @@ -81,6 +81,22 @@ export const standardAttendeeColumns = (domain: string): Column[] => [ }, ]; +/** A "Checkout pending" column, emitted only when some exported row is + * mid-payment — an ordinary export carries no extra column. Without it a + * pending staged row reads as a plain zero-quantity booking, hiding that the + * customer may still be paying. Shared by the attendee and calendar exports. */ +export const pendingCheckoutColumns = ( + attendees: readonly Attendee[], +): Column[] => + attendees.some((a) => a.pending_checkout) + ? [ + { + header: t("csv.col.checkout_pending"), + value: (a) => (a.pending_checkout ? t("csv.yes") : ""), + }, + ] + : []; + /** Optional Listing Date / Listing Location columns, shared by the attendee and * calendar exports. Each column is emitted only when its `show` flag is set; its * `value` reads the cell from the row (a per-row listing for the calendar, a @@ -180,13 +196,13 @@ export const generateAttendeesCsv = ( questionData?: AttendeeQuestionData, tz: string = DEFAULT_TIMEZONE, ): string => - CSV.generate( - attendees, - attendeeColumns({ + CSV.generate(attendees, [ + ...attendeeColumns({ domain: getEffectiveDomain(), includeDate, listingInfo, questionData, tz, }), - ); + ...pendingCheckoutColumns(attendees), + ]); diff --git a/src/features/admin/attendees-merge.ts b/src/features/admin/attendees-merge.ts index 63cd859bd0..59ecf88ca2 100644 --- a/src/features/admin/attendees-merge.ts +++ b/src/features/admin/attendees-merge.ts @@ -4,6 +4,7 @@ /* jscpd:ignore-start */ import { filter, map, pipe, unique } from "#fp"; +import { t } from "#i18n"; import { createEntityRouteHandlers } from "#routes/admin/entity-handlers.ts"; import type { AttendeeRouteParams } from "#routes/entity.ts"; import { errorRedirect, redirect } from "#routes/response.ts"; @@ -14,12 +15,16 @@ import { decryptAttendees, } from "#shared/db/attendees/pii.ts"; import { + attendeeIdsHoldingUnreturnedCash, getAttendeeRaw, LISTING_ATTENDEE_ROW_COLS, } from "#shared/db/attendees/queries.ts"; import { getAttendeesByTokens } from "#shared/db/attendees/tokens.ts"; import { updateAttendeePII } from "#shared/db/attendees/update.ts"; + +import { attendeeIdsWithPendingStage } from "#shared/db/checkout-stages.ts"; import { queryAll } from "#shared/db/client.ts"; + import { syncAttendeeContactTokens } from "#shared/db/contact-tokens.ts"; import { getQuestionsWithListingIds } from "#shared/db/questions/queries.ts"; import type { FormParams } from "#shared/form-data.ts"; @@ -265,6 +270,24 @@ const buildMergeFlashParts = mergeMessageParts( ], ); +/** Why a merge of these two records is blocked right now, or null when it's + * allowed. Two money states forbid it, both of which the merge would + * mis-reconcile onto a mixed account: either record is mid-payment (a pending + * stage claims its exact rows), or either holds unreturned conflict cash (a + * stage_active `payment` leg with no sale) the operator must refund first. */ +const mergeMoneyBlock = async ( + targetId: number, + sourceId: number, +): Promise => { + if ((await attendeeIdsWithPendingStage([targetId, sourceId])).size > 0) { + return t("admin.merge.pending_checkout"); + } + if ((await attendeeIdsHoldingUnreturnedCash([targetId, sourceId])).size > 0) { + return t("admin.merge.held_cash"); + } + return null; +}; + /** Validate merge POST preconditions, returning an error Response or the source */ const validateMergePostInput = async ( attendeeId: number, @@ -303,6 +326,14 @@ const validateMergePostInput = async ( }; } + // A record that is mid-payment (or holding unreturned conflict cash) must stay + // exactly as staged: merging it (in either direction) moves or rewrites the + // rows the payment will claim, or the held cash the operator must refund. + const moneyBlock = await mergeMoneyBlock(attendeeId, source.id); + if (moneyBlock) { + return { ok: false, response: errorRedirect(actionsTab, moneyBlock) }; + } + return { ok: true, source, sourceToken }; }; @@ -472,6 +503,12 @@ export const loadMergePanel = async ( "Cannot merge an attendee with themselves", ); } + // Show the block here too, so the operator never fills in a decision form + // the POST would refuse. + const moneyBlock = await mergeMoneyBlock(target.id, source.id); + if (moneyBlock) { + return AttendeeMergePanel(target, null, token, moneyBlock); + } const diff = await buildMergeDiffFor(target, source, target.id); return AttendeeMergePanel(target, source, token, undefined, diff); }; diff --git a/src/features/admin/attendees-route-helpers.ts b/src/features/admin/attendees-route-helpers.ts index f714f982ef..ae1b689e4e 100644 --- a/src/features/admin/attendees-route-helpers.ts +++ b/src/features/admin/attendees-route-helpers.ts @@ -11,7 +11,7 @@ import { import type { AuthSession } from "#routes/auth.ts"; import { applyFlash } from "#routes/csrf.ts"; import type { IdFormHandler } from "#routes/entity.ts"; -import { htmlResponse } from "#routes/response.ts"; +import { htmlResponse, notFoundResponse } from "#routes/response.ts"; import { getSearchParam } from "#routes/url.ts"; import { createAuthedHandler } from "#shared/app-forms.ts"; import { decryptAttendeeOrNull } from "#shared/db/attendees/pii.ts"; @@ -35,6 +35,12 @@ export type AttendeeWithListing = { listing: ListingWithCount; }; +/** A retained attendee record whose home listing may have been deleted. */ +export type AttendeeRecord = { + attendee: Attendee; + listing: ListingWithCount | null; +}; + /** No payment provider configured error (shared with attendee-refunds) */ export const NO_PROVIDER_ERROR = "No payment provider configured."; @@ -89,17 +95,30 @@ export const loadAttendeeWithHomeListing: ( }, ); +/** Load a record that still has a booking row, even when that row's listing is + * gone. A true orphan has the left-join sentinel listing id 0 and stays outside + * this action path. */ +const loadAttendeeRecord: ( + attendeeId: number, +) => Promise = withDecryptedAttendee(async (attendee) => + attendee.listing_id > 0 + ? { + attendee, + listing: await getListingWithCount(attendee.listing_id), + } + : null, +); + /** Route params for listing-scoped routes */ export type ListingRouteParams = { id: number }; /** Route params for listing-scoped attendee routes */ type AttendeeRouteParams = { listingId: number; attendeeId: number }; -/** GET/POST handler pair for the attendee-scoped action routes. */ -const attendeeActionHandlers = createEntityRouteHandlers( - loadAttendeeWithHomeListing, - ({ attendeeId }: { attendeeId: number }) => attendeeId, -); +type AttendeeActionRoute = ( + request: Request, + params: { attendeeId: number }, +) => Promise; /** The canonical URL of an attendee-scoped action (confirm page + POST). */ export const attendeeActionUrl = (attendeeId: number, action: string): string => @@ -118,53 +137,104 @@ export const attendeeActionUrlWithReturn = ( }`; /** An attendee-action confirm page renderer's shape. */ -type AttendeeActionRenderer = ( - data: AttendeeWithListing, +type AttendeeActionRenderer = ( + data: T, session: AdminSession, returnUrl?: string, error?: string, ) => string; +/** Build the confirm and verified POST handlers over one attendee loader. */ +const defineAttendeeActionRoutes = ( + load: (attendeeId: number) => Promise, +) => { + const handlers = createEntityRouteHandlers( + load, + ({ attendeeId }: { attendeeId: number }) => attendeeId, + ); + return { + page: ( + render: AttendeeActionRenderer, + guard?: (data: T) => Promise, + available?: (data: T) => Promise, + ): AttendeeActionRoute => + handlers.get(async (request, session, data) => { + if (available && !(await available(data))) return notFoundResponse(); + const returnUrl = getReturnUrl(request); + const blocked = guard ? await guard(data) : null; + if (blocked !== null) { + return htmlResponse(render(data, session, returnUrl, blocked), 400); + } + const flash = applyFlash(request); + return htmlResponse(render(data, session, returnUrl, flash.error)); + }), + verified: ( + action: string, + actionLabel: string | undefined, + handler: (data: T, form: FormParams) => Response | Promise, + ): AttendeeActionRoute => + handlers.post((_session, form, data) => { + const error = verifyOrRedirect( + form, + data.attendee.name, + attendeeActionUrlWithReturn( + data.attendee.id, + action, + form.getString("return_url"), + ), + "Attendee name", + actionLabel, + ); + if (error) return error; + return handler(data, form); + }), + }; +}; + +const listingAttendeeActions = defineAttendeeActionRoutes( + loadAttendeeWithHomeListing, +); +const attendeeRecordActions = defineAttendeeActionRoutes(loadAttendeeRecord); + /** GET handler for an attendee-action confirm page: auth, load the attendee + * home listing, render with the flashed error and threaded return_url. An * optional guard can block the action up front — its message renders in the * page at HTTP 400 (the refund page's no-payment/already-refunded states). */ export const attendeeActionPage = ( - render: AttendeeActionRenderer, + render: AttendeeActionRenderer, guard?: (data: AttendeeWithListing) => Promise, -) => - attendeeActionHandlers.get(async (request, session, data) => { - const returnUrl = getReturnUrl(request); - const blocked = guard ? await guard(data) : null; - if (blocked !== null) { - return htmlResponse(render(data, session, returnUrl, blocked), 400); - } - const flash = applyFlash(request); - return htmlResponse(render(data, session, returnUrl, flash.error)); - }); +): AttendeeActionRoute => listingAttendeeActions.page(render, guard); + +/** Confirm page for deleting a retained attendee record. Unlike listing-based + * actions, this works when the home listing is gone. */ +export const attendeeRecordActionPage = ( + render: AttendeeActionRenderer, + available: (data: AttendeeRecord) => Promise, +): AttendeeActionRoute => + attendeeRecordActions.page(render, undefined, available); /** POST handler for an attendee-scoped action that first verifies the typed * attendee name, bouncing back to the action's own confirm page on mismatch. */ export const verifiedAttendeeAction = ( action: string, actionLabel: string | undefined, - handler: ResponseHandler<[data: AttendeeWithListing, form: FormParams]>, -) => - attendeeActionHandlers.post((_session, form, data) => { - const error = verifyOrRedirect( - form, - data.attendee.name, - attendeeActionUrlWithReturn( - data.attendee.id, - action, - form.getString("return_url"), - ), - "Attendee name", - actionLabel, - ); - if (error) return error; - return handler(data, form); - }); + handler: ( + data: AttendeeWithListing, + form: FormParams, + ) => Response | Promise, +): AttendeeActionRoute => + listingAttendeeActions.verified(action, actionLabel, handler); + +/** Verified POST for record deletion, with no live-listing requirement. */ +export const verifiedAttendeeRecordAction = ( + action: string, + actionLabel: string | undefined, + handler: ( + data: AttendeeRecord, + form: FormParams, + ) => Response | Promise, +): AttendeeActionRoute => + attendeeRecordActions.verified(action, actionLabel, handler); /** Route params for a POST scoped to one attendee by its id alone. */ type AttendeeIdRouteParams = { attendeeId: number }; diff --git a/src/features/admin/attendees.ts b/src/features/admin/attendees.ts index fa9fd28ab3..f9c1638bd5 100644 --- a/src/features/admin/attendees.ts +++ b/src/features/admin/attendees.ts @@ -1,16 +1,15 @@ -/* jscpd:ignore-start */ -import { handlersFor } from "#routes/admin/handlers.ts"; /** * Admin attendee management routes */ +/* jscpd:ignore-start */ import { t } from "#i18n"; +import { handlersFor } from "#routes/admin/handlers.ts"; import { redirect } from "#routes/response.ts"; import type { TypedRouteHandler } from "#routes/router.ts"; import { createAuthedFormRoute } from "#shared/app-forms.ts"; import { logActivity } from "#shared/db/activityLog.ts"; import { createAttendeeAtomic } from "#shared/db/attendees/api.ts"; -import { deleteAttendee } from "#shared/db/attendees/delete.ts"; import { decryptAttendeeOrNull } from "#shared/db/attendees/pii.ts"; import { getAttendeePackageRowsRaw, @@ -18,30 +17,26 @@ import { } from "#shared/db/attendees/queries.ts"; import { updateCheckedIn } from "#shared/db/attendees/update.ts"; import { getListingWithCount } from "#shared/db/listings/records.ts"; -import { hasAnyPaymentReference } from "#shared/db/payment-references.ts"; import { ATTENDEE_DEMO_FIELDS, applyDemoOverrides, } from "#shared/demo/overrides.ts"; import type { FormParams } from "#shared/form-data.ts"; import { validateForm } from "#shared/forms.tsx"; -import { isIncompletePayment } from "#shared/incomplete-payment.ts"; import { ErrorCode, logError } from "#shared/logger.ts"; import { requireRequestPrivateKey } from "#shared/session-private-key.ts"; -import { - availableDayCounts, - isPaidListing, - type ListingWithCount, -} from "#shared/types.ts"; +import { availableDayCounts, type ListingWithCount } from "#shared/types.ts"; import { logAndNotifyRegistration } from "#shared/webhook.ts"; -import { - adminDeleteAttendeePage, - adminResendNotificationPage, -} from "#templates/admin/attendees.tsx"; +import { adminResendNotificationPage } from "#templates/admin/attendees.tsx"; import { type AddAttendeeFormValues, getAddAttendeeFields, } from "#templates/fields/add-attendee.ts"; +import { + handleAdminAttendeeDeleteGet, + handleAttendeeDelete, + handleDeleteIncomplete, +} from "./attendee-delete.ts"; import { handleAttendeeEditPost, handleAttendeeNewGet, @@ -63,79 +58,6 @@ import { } from "./attendees-route-helpers.ts"; /* jscpd:ignore-end */ - -/** Handle GET /admin/attendees/:attendeeId/delete */ -const handleAdminAttendeeDeleteGet = attendeeActionPage( - adminDeleteAttendeePage, -); - -/** Delete an attendee, log the activity, and redirect. */ -const deleteAttendeeAndRedirect = async ( - attendeeId: number, - listingId: number, - redirectTo: string, - activityMessage: string, - flashMessage: string, - opts?: Parameters[3], - releaseBookings = true, -): Promise => { - await deleteAttendee(attendeeId, { releaseBookings }); - await logActivity(activityMessage, listingId, attendeeId); - return redirect(redirectTo, flashMessage, true, opts); -}; - -/** Handle POST /admin/attendees/:attendeeId/delete. The deleted attendee's - * pages are gone, so the fallback landing is the attendees roster (a - * submitted return_url still wins via the redirect's form option). */ -const handleAttendeeDelete = verifiedAttendeeAction( - "delete", - "deletion", - (data, form) => - deleteAttendeeAndRedirect( - data.attendee.id, - data.listing.id, - "/admin/attendees", - `Attendee deleted from '${data.listing.name}'`, - t("success.attendee_deleted"), - { form }, - form.getFlag("release_bookings"), - ), -); - -/** - * Handle POST /admin/listing/:listingId/attendee/:attendeeId/delete-incomplete - * Deletes an attendee with an incomplete payment without requiring name confirmation. - * Verifies the attendee is actually incomplete before deleting. - */ -const handleDeleteIncomplete = attendeeFormAction( - async (data, _session, _form, listingId, attendeeId) => { - // The failed-payments delete form lives on the Attendees tab, so both - // outcomes return there — keeping the operator on the table they are - // clearing rather than bouncing them to Overview. - if ( - !isIncompletePayment( - data.attendee, - isPaidListing(data.listing), - await hasAnyPaymentReference(data.attendee), - ) - ) { - return redirect( - `/admin/listing/${listingId}/attendees`, - t("error.attendee_no_incomplete_payment"), - false, - ); - } - - return deleteAttendeeAndRedirect( - attendeeId, - listingId, - `/admin/listing/${listingId}/attendees`, - `Incomplete attendee deleted from '${data.listing.name}'`, - t("success.incomplete_removed"), - ); - }, -); - /** Return a redirect response when the attendee has no active booking line, or null otherwise. */ const redirectIfNoActiveBookingLine = async ( attendeeId: number, diff --git a/src/features/admin/backup.ts b/src/features/admin/backup.ts index 6d46e3121a..1412376a8b 100644 --- a/src/features/admin/backup.ts +++ b/src/features/admin/backup.ts @@ -20,18 +20,20 @@ import { clearSessionCookie } from "#shared/cookies.ts"; import { getEncryptionKeyString } from "#shared/crypto/encryption.ts"; import { formatDatetimeLabel } from "#shared/dates.ts"; import { - backupDir, - backupTimestamp, countZipStatements, createAndUploadBackup, - isBackupLeaf, - isBackupPath, - isRemoteDatabase, PostResetError, - parseBackupTime, readManifest, restoreFromZip, } from "#shared/db/backup.ts"; +import { + backupDir, + backupTimestamp, + isBackupLeaf, + isBackupPath, + isRemoteDatabase, + parseBackupTime, +} from "#shared/db/backup-storage.ts"; import { SCHEMA_HASH } from "#shared/db/migrations.ts"; import { formatBytes, MAX_BACKUPS } from "#shared/limits.ts"; import { flushPendingWork } from "#shared/pending-work.ts"; diff --git a/src/features/admin/built-sites.ts b/src/features/admin/built-sites.ts index 46a57a1fba..33f307109f 100644 --- a/src/features/admin/built-sites.ts +++ b/src/features/admin/built-sites.ts @@ -20,7 +20,7 @@ import type { RouteHandlerFn, RouteParams } from "#routes/router.ts"; /* jscpd:ignore-end */ import { siteHostingAccess } from "#shared/builder.ts"; import { logActivity } from "#shared/db/activityLog.ts"; -import { dbName, hasRecentBackup } from "#shared/db/backup.ts"; +import { dbName, hasRecentBackup } from "#shared/db/backup-storage.ts"; import type { BuiltSite, BuiltSiteFormInput } from "#shared/db/built-sites.ts"; import { builtSites, diff --git a/src/features/admin/calendar-csv.ts b/src/features/admin/calendar-csv.ts index c2d8971529..3584a28a1a 100644 --- a/src/features/admin/calendar-csv.ts +++ b/src/features/admin/calendar-csv.ts @@ -12,6 +12,7 @@ import { t } from "#i18n"; import { csvDateRange, listingInfoColumns, + pendingCheckoutColumns, standardAttendeeColumns, } from "#routes/admin/attendees-csv.ts"; import { getEffectiveDomain } from "#shared/config.ts"; @@ -51,8 +52,8 @@ export type CalendarLogisticsCsv = { /** * Join attendees with their listings into calendar CSV rows, taking the listing - * name and date/location from the attendee's `listing_id`. Callers guarantee - * every attendee's listing is present in `listings`. + * name and date/location from the attendee's `listing_id`. A retained booking + * whose listing was deleted gets the same plain placeholder as the admin page. */ export const toCalendarAttendees = < L extends { id: number; name: string; date: string; location: string }, @@ -62,8 +63,17 @@ export const toCalendarAttendees = < ): CalendarAttendee[] => { const listingById = byId(listings); return attendees.map((a) => { - const listing = listingById.get(a.listing_id)!; - return { ...a, ...listingDetails(listing) }; + const listing = listingById.get(a.listing_id); + return { + ...a, + ...(listing + ? listingDetails(listing) + : { + listingDate: "", + listingLocation: "", + listingName: t("attendee_form.deleted_listing"), + }), + }; }); }; @@ -151,6 +161,7 @@ const calendarColumns = ({ value: (a) => csvDateRange(a.date, a.end_date), }, ...standardAttendeeColumns(domain), + ...pendingCheckoutColumns(attendees), ...(showLogistics ? logisticsColumns(logistics!) : []), ]; }; diff --git a/src/features/admin/ledger/entries.ts b/src/features/admin/ledger/entries.ts index df0c4c0506..7eb10d7307 100644 --- a/src/features/admin/ledger/entries.ts +++ b/src/features/admin/ledger/entries.ts @@ -16,7 +16,7 @@ import { applyFlash } from "#routes/csrf.ts"; import type { IdParam } from "#routes/entity.ts"; import { errorRedirect, notFoundResponse, redirect } from "#routes/response.ts"; import type { TypedRouteHandler } from "#routes/router.ts"; -import { isRowAccountType } from "#shared/accounting/accounts.ts"; +import { ATTENDEE, isRowAccountType } from "#shared/accounting/accounts.ts"; import { deleteManualLedgerEntry, getTransferById, @@ -27,6 +27,7 @@ import { } from "#shared/accounting/manual-entries.ts"; import { formatCurrency, toMajorUnits } from "#shared/currency.ts"; import { logActivity } from "#shared/db/activityLog.ts"; +import { hasPendingCheckout } from "#shared/db/checkout-stages.ts"; import { settings } from "#shared/db/settings.ts"; import type { FormParams } from "#shared/form-data.ts"; import type { AccountRef, Transfer } from "#shared/ledger/types.ts"; @@ -204,6 +205,20 @@ export const handleLedgerEntryAddPost: TypedRouteHandler< accountStatementPath(loaded.account), ); const redirectUrl = addEntryPath(loaded.account, returnUrl); + // A staged checkout's activation posts its own sale/payment legs when the + // payment lands, so a manual entry added mid-payment would combine with + // them into a surprise balance. This is the write route behind both the + // attendee page's Ledger tab (hidden while pending) and the standalone + // statement page, so the freeze is enforced here, where the money moves. + if ( + loaded.account.type === ATTENDEE && + (await hasPendingCheckout(Number(loaded.account.id))) + ) { + return errorRedirect( + redirectUrl, + t("attendee_form.error_pending_checkout"), + ); + } const parsed = defineLedgerEntryAddForm(loaded.options).validate(form); if (!parsed.valid) return errorRedirect(redirectUrl, parsed.error); await postManualLedgerEntry({ diff --git a/src/features/admin/listings-lifecycle.ts b/src/features/admin/listings-lifecycle.ts index 594b34fbb5..a36a939bb4 100644 --- a/src/features/admin/listings-lifecycle.ts +++ b/src/features/admin/listings-lifecycle.ts @@ -20,7 +20,7 @@ import { } from "#shared/db/listings/records.ts"; import { deactivationOrphanedAddOnError, - deleteOrphanedAddOnError, + listingDeleteError, performListingDelete, } from "#shared/listings-actions.ts"; import type { AdminSession, ListingWithCount } from "#shared/types.ts"; @@ -97,7 +97,7 @@ export const listingReactivate = listingToggleHandlers({ * the error (200), the POST blocks before deleting. */ export const listingDelete = createConfirmedHandlers({ ...listingConfirmBase, - guardError: (_listing, id) => deleteOrphanedAddOnError(id), + guardError: (_listing, id) => listingDeleteError(id), onConfirm: async (listing) => { await performListingDelete(listing); }, @@ -116,9 +116,9 @@ export const handleAdminListingDelete: TypedRouteHandler< ? listingDelete.post(request, id) : withAuth(request, AUTH_FORM, () => withEntityFromParam(id, getListingWithCount, async (listing) => { - // Same orphaned-add-on guard as the confirmed path: block a - // delete that would leave a child-scoped add-on unreachable. - const error = await deleteOrphanedAddOnError(listing.id); + // Same guard as the confirmed path: block a delete that would leave a + // child-scoped add-on unreachable, or strand a mid-payment booking. + const error = await listingDeleteError(listing.id); if (error) return redirect(`/admin/listing/${id}`, error, false); await performListingDelete(listing); return redirect("/admin", t("success.listing_deleted"), true); diff --git a/src/features/admin/settings-stripe.ts b/src/features/admin/settings-stripe.ts index c1c8d60752..82cc68412e 100644 --- a/src/features/admin/settings-stripe.ts +++ b/src/features/admin/settings-stripe.ts @@ -14,6 +14,7 @@ import { setupWebhookEndpoint, testStripeConnection, } from "#shared/stripe.ts"; +import { STRIPE_WEBHOOK_EVENTS_VERSION } from "#shared/stripe-webhook-events.ts"; export const stripeRoutes = defineProviderCredentialsRoute({ // Provision the Stripe webhook before the key is persisted, so a setup @@ -34,6 +35,9 @@ export const stripeRoutes = defineProviderCredentialsRoute({ // keep delivering with the old config instead of losing the only signed // endpoint mid-replacement. await settings.update.stripe.webhookConfig(result); + await settings.update.stripe.webhookEventsVersion( + STRIPE_WEBHOOK_EVENTS_VERSION, + ); // Now that the new credentials are saved, delete old endpoints. Pass the // previous endpoint ID explicitly — after a domain change it points at // the old URL, so the same-URL listing won't find it. Cleanup is diff --git a/src/features/admin/update.ts b/src/features/admin/update.ts index e65623cc06..7ff68229a2 100644 --- a/src/features/admin/update.ts +++ b/src/features/admin/update.ts @@ -8,7 +8,7 @@ import { OWNER_FORM, ownerPage, withAuth } from "#routes/auth.ts"; import { errorRedirect, redirect } from "#routes/response.ts"; import { BUILD_COMMIT, BUILD_TIMESTAMP } from "#shared/build-info.ts"; import { isBunnyCdnEnabled } from "#shared/config.ts"; -import { hasRecentBackup } from "#shared/db/backup.ts"; +import { hasRecentBackup } from "#shared/db/backup-storage.ts"; import { settings } from "#shared/db/settings.ts"; import { getFlash } from "#shared/flash-context.ts"; import { deployAndReport } from "#shared/site-update.ts"; diff --git a/src/features/api/folded-booking.ts b/src/features/api/folded-booking.ts index 8cc1056f15..7dd9d65f5b 100644 --- a/src/features/api/folded-booking.ts +++ b/src/features/api/folded-booking.ts @@ -38,6 +38,7 @@ import type { BookingTree } from "#shared/booking/tree.ts"; import { owedOrderForLedger } from "#shared/checkout-ledger.ts"; import { priceCheckout } from "#shared/checkout-pricing.ts"; import { isPaymentsEnabled } from "#shared/config.ts"; +import { createStagedCheckout } from "#shared/db/checkout-stages.ts"; import type { FormParams } from "#shared/form-data.ts"; import { mergeListingFields } from "#shared/listing-fields.ts"; import { @@ -278,7 +279,7 @@ const completeFoldedBooking = async ( if (!available) return soldOutResponse(); const provider = (await getActivePaymentProvider())!; const baseUrl = getBaseUrl(request); - const result = await provider.createCheckoutSession(intent, baseUrl); + const result = await createStagedCheckout(provider, intent, baseUrl); if (!result) return checkoutFailedResponse(); return "error" in result ? checkoutFailedResponse(result.error) diff --git a/src/features/api/payment-processing/classify.ts b/src/features/api/payment-processing/classify.ts index b80b1a8099..cbf752ccfb 100644 --- a/src/features/api/payment-processing/classify.ts +++ b/src/features/api/payment-processing/classify.ts @@ -12,6 +12,7 @@ import type { SignedVerdict, } from "#routes/api/webhook-types.ts"; import { paymentErrorResponse } from "#routes/payment-response.ts"; +import { discardPendingCheckoutSessions } from "#shared/db/checkout-stage-cleanup.ts"; import { ErrorCode, logError } from "#shared/logger.ts"; import { verifyPrice } from "#shared/payment-signature.ts"; import { @@ -135,6 +136,7 @@ export const validatePaidSession = async ( // URL for every outcome, so a card decline lands here. Show the friendly // cancel/try-again page, not a "contact support" error. if (session.paymentStatus === "failed") { + await discardPendingCheckoutSessions([sessionId]); return { ok: false, response: await cancelPageResponse(session, logRedirectError), diff --git a/src/features/api/payment-processing/committed-entries.ts b/src/features/api/payment-processing/committed-entries.ts new file mode 100644 index 0000000000..3c28d427a1 --- /dev/null +++ b/src/features/api/payment-processing/committed-entries.ts @@ -0,0 +1,87 @@ +import { + type CreatedEntry, + pairEntriesByListing, +} from "#routes/api/payment-processing/create.ts"; +import type { ValidatedItem } from "#routes/api/payment-processing/package-pricing.ts"; +import type { + BookingIntent, + ValidatedSession, +} from "#routes/api/webhook-types.ts"; +import type { BlindIndex } from "#shared/crypto/sealed.ts"; +import { + ATTENDEE_ECHO_DEFAULTS, + contactFields, +} from "#shared/db/attendees/pii.ts"; +import { + pricePaidFromLedger, + remainingBalanceFromLedger, +} from "#shared/db/attendees/select.ts"; +import { queryAllPrimary } from "#shared/db/client.ts"; + +type CommittedBookingRow = { + created: string; + date: string | null; + end_date: string | null; + kind: string; + listing_id: number; + package_group_id: number; + price_paid: number; + quantity: number; + remaining_balance: number; + status_id: number | null; + ticket_token_index: BlindIndex; +}; + +/** Build completion entries from committed booking rows and the signed contact + * intent. This is shared by staged activation and lost-result recovery. */ +export const committedEntries = async ( + attendeeId: number, + ticketToken: string, + session: ValidatedSession["session"], + intent: BookingIntent, + validatedItems: ValidatedItem[], +): Promise => { + const rows = await queryAllPrimary( + `SELECT attendee.created, + SUBSTR(listingAttendee.start_at, 1, 10) AS date, + SUBSTR(listingAttendee.end_at, 1, 10) AS end_date, + attendee.kind, + listingAttendee.listing_id, + listingAttendee.package_group_id, + ${pricePaidFromLedger( + "listingAttendee.attendee_id", + "listingAttendee.listing_id", + "listingAttendee.ledger_event_group", + "listingAttendee.id", + )}, + listingAttendee.quantity, + ${remainingBalanceFromLedger("attendee.id")}, + attendee.status_id, + attendee.ticket_token_index + FROM attendees AS attendee + JOIN listing_attendees AS listingAttendee + ON listingAttendee.attendee_id = attendee.id + WHERE attendee.id = ? + ORDER BY listingAttendee.id`, + [attendeeId], + ); + const attendees: CreatedEntry["attendee"][] = rows.map((row) => ({ + ...contactFields(intent), + ...ATTENDEE_ECHO_DEFAULTS, + created: row.created, + date: row.date, + end_date: row.end_date, + id: attendeeId, + kind: row.kind, + listing_id: row.listing_id, + package_group_id: row.package_group_id, + payment_id: session.paymentReference, + price_paid: String(row.price_paid), + quantity: row.quantity, + remaining_balance: row.remaining_balance, + status_id: row.status_id, + ticket_token: ticketToken, + ticket_token_index: row.ticket_token_index, + })); + return pairEntriesByListing(attendees, validatedItems); +}; diff --git a/src/features/api/payment-processing/completion.ts b/src/features/api/payment-processing/completion.ts new file mode 100644 index 0000000000..c2cb9e0a01 --- /dev/null +++ b/src/features/api/payment-processing/completion.ts @@ -0,0 +1,44 @@ +import { + type CreatedEntry, + logPromoCodeModifiers, + saveSessionAnswers, + sessionSuccess, +} from "#routes/api/payment-processing/create.ts"; +import type { + BookingIntent, + PaymentResult, +} from "#routes/api/webhook-types.ts"; +import type { ModifierApplication } from "#shared/checkout-pricing.ts"; +import type { ModifierSpec } from "#shared/payments.ts"; +import { logAndNotifyRegistration } from "#shared/webhook.ts"; + +/** Finish the work that follows an atomically committed paid booking. Shared by + * the normal create result and recovery after the database committed but the + * client lost that result. */ +export const completePaidBooking = async ( + createdEntries: CreatedEntry[], + intent: BookingIntent, + codeSpecs: ModifierSpec[], + modifierApplications: ModifierApplication[], + ticketTokens: string[], +): Promise => { + await saveSessionAnswers(createdEntries, intent); + const firstEntry = createdEntries[0]!; + + if (codeSpecs.length > 0) { + await logPromoCodeModifiers( + codeSpecs, + modifierApplications, + firstEntry.listing, + firstEntry.attendee.id, + ); + } + + await logAndNotifyRegistration(createdEntries, intent.siteTokenIndex); + + return sessionSuccess( + firstEntry.attendee.id, + firstEntry.listing.id, + ticketTokens, + ); +}; diff --git a/src/features/api/payment-processing/create.ts b/src/features/api/payment-processing/create.ts index 6f790a962c..63b65c8ffd 100644 --- a/src/features/api/payment-processing/create.ts +++ b/src/features/api/payment-processing/create.ts @@ -6,6 +6,7 @@ * quantity-0 placeholder instead of dropping a paid customer. */ +import { committedEntries } from "#routes/api/payment-processing/committed-entries.ts"; import { businessTime } from "#routes/api/payment-processing/metadata.ts"; import type { ValidatedItem } from "#routes/api/payment-processing/package-pricing.ts"; import { @@ -16,8 +17,7 @@ import type { BookingIntent, PaymentResult, } from "#routes/api/webhook-types.ts"; -import { lineGroupId } from "#shared/booking/signed-metadata.ts"; -import { orderBookings } from "#shared/booking-lines.ts"; +import { orderBookingsFor, signedPaidLine } from "#shared/booking-lines.ts"; import { capacityErrorFormatter } from "#shared/capacity-error.ts"; import { bookingBatchPlan } from "#shared/checkout-complete.ts"; import type { @@ -27,11 +27,14 @@ import type { import { formatCurrency } from "#shared/currency.ts"; import { logActivity } from "#shared/db/activityLog.ts"; import { getPublicStatusId } from "#shared/db/attendee-statuses.ts"; +import { activateStagedBooking } from "#shared/db/attendees/activate.ts"; +import type { ActivationFailure } from "#shared/db/attendees/activation-refusal.ts"; import { type createAttendeeAtomic, createBookingAtomic, } from "#shared/db/attendees/api.ts"; -import { ensureAllBookings } from "#shared/db/attendees/create.ts"; +import type { CheckoutStage } from "#shared/db/checkout-stages.ts"; +import { recordOrderActivity } from "#shared/db/contact-tokens.ts"; import { decryptSessionTokens, type ProcessedPayment, @@ -42,7 +45,6 @@ import { } from "#shared/db/questions/attendee-answers/save.ts"; import { ErrorCode, logError } from "#shared/logger.ts"; import type { - BookingItem, CheckoutIntent, ModifierSpec, TextAnswerRef, @@ -50,15 +52,6 @@ import type { } from "#shared/payments.ts"; import type { ListingWithCount } from "#shared/types.ts"; -/** The listing id + package path shared by every booking row we build from a - * signed line — a fresh booking, a quantity-0 placeholder, or a dateless ghost. - * A line's package path keeps a listing booked through two paths in two distinct - * slots. */ -export const bookingSlot = (item: BookingItem) => ({ - listingId: item.e, - packageGroupId: lineGroupId(item) ?? 0, -}); - /** The one success shape every resolved payment session returns: the created or * already-existing attendee (only its id is carried — see PaymentSuccess), the * listing id the redirect resolves lazily, and any ticket tokens (a fresh @@ -124,6 +117,26 @@ const formatPostPaymentError = capacityErrorFormatter({ `Sorry, ${name} sold out while you were completing payment.`, }); +/** The reason plus internal log line for each way a staged claim can fail. The + * two stage problems name the session (the operator's reconciliation key); the + * two stock problems reuse the shared sold-out wording. An exhaustive record, + * so a new activation failure cannot silently fall through to the wrong story. + * A gone stage is never a reason here — it is an impossible state that throws + * (findStageProblem), because a pending stage is never deleted mid-payment. */ +const STAGED_FAILURE_DETAILS: Record< + ActivationFailure, + (sessionId: string, listingName: string) => string +> = { + capacity_exceeded: (_sessionId, listingName) => + formatPostPaymentError("capacity_exceeded", listingName), + sold_out: (_sessionId, listingName) => + formatPostPaymentError("sold_out", listingName), + stage_active: (sessionId) => + `Staged rows for session ${sessionId} were already given a quantity outside payment`, + stage_mismatch: (sessionId) => + `Staged booking lines for session ${sessionId} no longer match the signed order`, +}; + type CreatedAttendee = Extract< Awaited>, { success: true } @@ -145,10 +158,23 @@ export type HonourResult = | { ok: true; entries: CreatedEntry[] } | { ok: false; - reason: "sold_out" | "capacity_exceeded" | "encryption_error"; + reason: + | "sold_out" + | "capacity_exceeded" + | "encryption_error" + | "stage_active" + | "stage_mismatch"; detail: string; }; +/** Build the failure arm of a {@link HonourResult}: a classified reason plus the + * operator-facing detail. Shared by every honour path that gives up after a + * post-payment capacity, stock, or staging problem. */ +const honourFailure = ( + reason: Extract["reason"], + detail: string, +): HonourResult => ({ detail, ok: false, reason }); + /** * Keep only the text-answer refs that still carry a resolved string id (`s`). * @@ -251,6 +277,8 @@ export const createAttendeeForSession = async ( validatedItems: ValidatedItem[], pricingIntent: CheckoutIntent, pricedOrder: PricedOrder, + ticketToken: string, + stage: CheckoutStage | null, ): Promise => { // Per-LINE paid amounts: a listing booked through two paths is two lines // with their own prices, and each becomes its own booking row. The priced @@ -263,78 +291,115 @@ export const createAttendeeForSession = async ( // to an optional `number`, so `pricePaid` is omitted when absent (the DB // layer then coerces it to `0` at insert, matching the prior `?? 0`). const paidByIntentItem = paidByItem(pricedOrder); - const bookings = orderBookings({ - allocations: intent.allocations, - date: intent.date, - dayCount: intent.dayCount, - lines: validatedItems.map(({ item, listing }, index) => { - const pricePaid = paidByIntentItem.get(pricingIntent.items[index]!); - return { - ...bookingSlot(item), + const bookings = orderBookingsFor( + intent, + validatedItems.map(({ item, listing }, index) => + signedPaidLine( + item, listing, - ...(pricePaid !== undefined ? { pricePaid } : {}), - quantity: item.q, - }; - }), - }); + paidByIntentItem.get(pricingIntent.items[index]!), + ), + ), + ); const fullTotal = pricedOrder.fullSubtotal; const depositTotal = orderLineTotal(pricedOrder); const remainingBalance = intent.reservationAmount === undefined ? 0 : fullTotal - depositTotal; // Consume modifier stock, post the ledger legs, and finalize the session in - // ONE libsql batch with the attendee + booking INSERTs, so the booking, its + // one atomic write with the attendee + booking rows, so the booking, its // stock, its sale/payment legs, and attendee_id are all-or-nothing in a single - // round-trip — never an interactive write transaction held open against the - // primary (which timed out under edge→primary latency). The usage amounts come + // write transaction. The usage amounts come // from the same pricing pass that calculated the checkout total, so scoped // bases, quantities, and clamped discounts match. A modifier that sold out // during payment stops the booking landing (→ "sold-out"). The event is keyed // on the payment session and dated from the provider's checkout time. - const plan = await bookingBatchPlan( - pricedOrder.modifierApplications, - { - eventId: session.id, - occurredAt: businessTime(session), - pricedOrder, - }, - { paymentReference: session.paymentReference, sessionId: session.id }, - ); + const ledger = { + eventId: session.id, + occurredAt: businessTime(session), + pricedOrder, + }; + const finalize = { + paymentReference: session.paymentReference, + sessionId: session.id, + }; + const attendeeInput = { + ...(await attendeeBaseFields(session, intent)), + bookings, + remainingBalance, + ticketToken, + }; - const result = await createBookingAtomic( - { - ...(await attendeeBaseFields(session, intent)), - bookings, - remainingBalance, - }, - plan, - ); - if (result === "sold-out") { - return { - detail: "a chosen add-on or extra sold out during payment", - ok: false, - reason: "sold_out", - }; - } + /** Book a brand-new attendee from the signed order — the no-stage path, and + * the fallback when a staged record was deleted mid-payment. Builds its own + * plan: a fresh insert must never reference a staged attendee id. */ + const createFresh = async (): Promise => { + const plan = await bookingBatchPlan( + null, + pricedOrder.modifierApplications, + ledger, + finalize, + ); + const result = await createBookingAtomic(attendeeInput, plan); + if (result === "sold-out") { + return honourFailure( + "sold_out", + "a chosen add-on or extra sold out during payment", + ); + } - // All-or-nothing: a capacity failure rolled the transaction back (no legs). - const bookingCheck = await ensureAllBookings( - result, - bookings.length, - "public", - ); - if (!bookingCheck.ok) { + // A capacity failure rolled the whole atomic transaction back, including legs. + if (!result.success) { + return honourFailure( + result.reason, + formatPostPaymentError(result.reason, validatedItems[0]!.listing.name), + ); + } + + const entries = pairEntriesByListing(result.attendees, validatedItems); + return { entries, ok: true }; + }; + + if (stage) { + const plan = await bookingBatchPlan( + stage.attendeeId, + pricedOrder.modifierApplications, + ledger, + finalize, + ); + const activated = await activateStagedBooking( + session.id, + stage.attendeeId, + ticketToken, + attendeeInput, + { ...plan, finalize }, + ); + if (!activated.success) { + return honourFailure( + activated.reason, + STAGED_FAILURE_DETAILS[activated.reason]( + session.id, + validatedItems[0]!.listing.name, + ), + ); + } + await recordOrderActivity( + intent.email, + intent.phone, + "public", + ticketToken, + ); return { - detail: formatPostPaymentError( - bookingCheck.reason, - validatedItems[0]!.listing.name, + entries: await committedEntries( + stage.attendeeId, + ticketToken, + session, + intent, + validatedItems, ), - ok: false, - reason: bookingCheck.reason, + ok: true, }; } - const created = result as Extract; - const entries = pairEntriesByListing(created.attendees, validatedItems); - return { entries, ok: true }; + return createFresh(); }; diff --git a/src/features/api/payment-processing/index.ts b/src/features/api/payment-processing/index.ts index cbc8c18423..063ce6d969 100644 --- a/src/features/api/payment-processing/index.ts +++ b/src/features/api/payment-processing/index.ts @@ -5,7 +5,7 @@ * * unreserved → reserved → (finalized success | terminal failure) * - * via the steps: validate → reserve → process → record-outcome. + * via validate → reserve → process → record-outcome. * * 1. validate — `validatePaidSession` (classify.ts) confirms with the provider * that the session is paid and `classifySession` proves (via a signed price @@ -14,30 +14,20 @@ * 2. reserve — `processPaymentSession` claims the idempotency lock * (`reserveSession`); a conflict replays the already-recorded outcome * (`handleReservationConflict`) instead of re-processing. - * 3. process — `processReservedSession`, holding the lock, turns the signed - * session into either a real ticket (`createAttendeeForSession`) / a settled - * balance (`settleBalanceSession`), or — for ANY reason it can't be honoured - * (charge mismatch, a price edited mid-checkout, a sold-out extra, a full - * event, a since-deleted listing, or an unexpected error after the charge) — - * a quantity-0 placeholder that is refunded (`storeRefundedBooking`), so a - * paid customer is never dropped. + * 3. process — `processReservedSession` creates a ticket, settles a balance, + * or stores and refunds a quantity-0 placeholder when the paid booking cannot + * be honoured before its atomic write commits. * 4. record-outcome — `processPaymentSession` records a handled failure as the * session's terminal outcome (`markSessionFailed`) so a later redirect/webhook * replays the same result, or releases the reservation when a real refund * failed so the next provider redelivery re-attempts it. * - * The HTTP plumbing (redirect + webhook handlers, routing) lives in - * `webhooks.ts` and calls into this module. The steps above are split across - * sibling files (metadata, classify, cancel, refunds, items, package-pricing, - * pricing, create, store-refund); this file is the orchestration that wires - * them into the two-phase locked lifecycle. */ +import { completePaidBooking } from "#routes/api/payment-processing/completion.ts"; import { alreadyProcessedResult, createAttendeeForSession, - logPromoCodeModifiers, - saveSessionAnswers, sessionSuccess, } from "#routes/api/payment-processing/create.ts"; import { validateAllItems } from "#routes/api/payment-processing/items.ts"; @@ -45,6 +35,7 @@ import { checkoutIntentForSession, paidPricingRefund, } from "#routes/api/payment-processing/pricing.ts"; +import { recoverOrRefundUnexpectedCreate } from "#routes/api/payment-processing/recovery.ts"; import { chargeMismatchSpec, deletedListingSpec, @@ -53,9 +44,12 @@ import { } from "#routes/api/payment-processing/refunds.ts"; import { datelessGhostBookings, + failStagedValidation, placeholderBookings, settleBalanceSession, specForFailure, + stagedConflict, + stampStagedPaymentId, storeRefundedBooking, } from "#routes/api/payment-processing/store-refund.ts"; import type { @@ -66,7 +60,14 @@ import type { } from "#routes/api/webhook-types.ts"; import { eventGroupHasLegs } from "#shared/accounting/queries.ts"; import { type PricedOrder, priceCheckout } from "#shared/checkout-pricing.ts"; +import { generateTicketToken } from "#shared/crypto/utils.ts"; import { balanceEventGroup } from "#shared/db/attendees/balance.ts"; +import { isOpenCheckoutStage } from "#shared/db/checkout-stage-state.ts"; +import { + getCheckoutStageOrNull, + resolveOpenStageFromOwner, + resolvePendingStage, +} from "#shared/db/checkout-stages.ts"; import { buyerVisits, specsFromRefs } from "#shared/db/modifier-resolve.ts"; import { finalizeSessionIfUnresolved, @@ -75,40 +76,36 @@ import { parseSessionFailure, releaseReservation, reserveSession, - setSessionTicketTokens, } from "#shared/db/processed-payments.ts"; -import { logDebug } from "#shared/logger.ts"; +import { createSystemNote } from "#shared/db/system-notes.ts"; +import type { ValidatedPaymentSession } from "#shared/payments.ts"; import { bookingLedgerDisposition } from "#shared/session-ledger.ts"; -import { logAndNotifyRegistration } from "#shared/webhook.ts"; - -type SessionProcessorOptions = { storeTokens?: boolean }; -/** The shared shape of the two-phase session processors: reserve/process a paid - * session by id, given its validated data, and resolve to a {@link - * PaymentResult}. */ type SessionProcessor = ( sessionId: string, data: ValidatedSession, - options?: SessionProcessorOptions, ) => Promise; -/** Handle the "already reserved" branch of reserveSession */ const handleReservationConflict = async ( intent: BookingIntent, existing: ProcessedPayment, ): Promise => { if (existing.attendee_id !== null) { + await resolveOpenStageFromOwner( + existing.payment_session_id, + existing.attendee_id, + ); return alreadyProcessedResult(intent.items[0]!.e, { ...existing, attendee_id: existing.attendee_id, }); } - // A recorded terminal failure replays the same handled outcome (refund - // already issued, sold out, price changed) without re-validating or - // re-refunding. failure_data is encrypted, so this read is async. + // Replay an encrypted terminal outcome without revalidating or refunding. const failure = await parseSessionFailure(existing.failure_data); - if (failure) return { ...failure, success: false }; - // Otherwise reserved but not finalized — another request is mid-flight. + if (failure) { + await resolvePendingStage(existing.payment_session_id); + return { ...failure, success: false }; + } return { error: "Payment is being processed. Please wait a moment and refresh.", status: 409, @@ -116,34 +113,36 @@ const handleReservationConflict = async ( }; }; -/** - * Replay a payment session the ledger already records as resolved to - * `attendeeId`: heal the fresh reservation at that attendee — token-safely, so a - * racing delivery's finalized tokens survive (see {@link - * finalizeSessionIfUnresolved}) — and return success. NEVER refunds: the money is - * already in the ledger against this attendee. Tokens come back empty, so the - * redirect renders directly from the attendee. Shared by the booking-replay and - * balance-replay preflights. - */ -const replaySuccess = async ( - sessionId: string, - attendeeId: number, - listingId: number, - paymentReference = "", -): Promise => { +/** Heal a fresh reservation from the ledger's attendee, preserving any tokens + * finalized by a racing delivery. The recorded money is never refunded. */ +type ReplaySuccessInput = { + attendeeId: number; + listingId: number; + paymentReference: string; + sessionId: string; +}; + +const replaySuccess = async ({ + attendeeId, + listingId, + paymentReference, + sessionId, +}: ReplaySuccessInput): Promise => { + // Resolve a rollback-era stage before pointing the recreated reservation at + // the ledger owner. The finalization trigger deliberately refuses a different + // attendee while the stage is open. + await resolveOpenStageFromOwner(sessionId, attendeeId); await finalizeSessionIfUnresolved(sessionId, attendeeId, paymentReference); - logDebug("Payment", `Replayed already-ledgered session ${sessionId}`); return sessionSuccess(attendeeId, listingId); }; -/** - * Acknowledge a session the ledger already accounts for but whose booking is - * gone — an operator deleted the attendee (its sale/payment legs remain) or it - * was a refunded quantity-0 placeholder. The money is already recorded, so we - * neither refund again nor recreate the booking: return a terminal handled - * outcome (200 — the webhook acks it, the redirect shows it as processed) and - * leave the orphaned ledger rows for the operator to reconcile. - */ +/** The note left when the ledger answer heals a stage a crash left open: + * the money is recorded, but the interrupted run never wrote its own note. */ +const healedStageNote = (paymentReference: string): string => + `This booking's payment was already handled, but an interruption left the record looking mid-payment. The money's story is in the ledger. Payment reference: ${paymentReference}.`; + +/** Acknowledge recorded money whose booking is gone without refunding again or + * recreating it; its orphaned ledger rows remain for operator reconciliation. */ const alreadyHandledSession = ( sessionId: string, listingId: number, @@ -154,39 +153,55 @@ const alreadyHandledSession = ( success: false, }); -/** - * The booking-session ledger preflight: the durable ledger — not the prunable - * processed_payments row — is the source of truth for "already honoured", so - * before validating, pricing, or refunding, resolve what it already records. - * Returns the replay outcome for a session it has seen (a live booking replays as - * success; an orphaned one is acknowledged), or null for a session it has never - * recorded (process it fresh). The single guard that stops a late replay — after - * the idempotency row is pruned or lost to a stale-reservation cleanup — from - * refunding a live ticket via the deleted-listing, price-change, inactive-listing, - * or capacity refund paths below. - */ -const replaySessionFromLedger = async ( - sessionId: string, +/** Resolve a booking against the durable ledger before any validation, pricing, + * or refund path. This protects live tickets after their idempotency row is lost. */ +/** Returns null when the ledger has not recorded this session yet. */ +const replaySessionFromLedgerOrNull = async ( + session: ValidatedPaymentSession, + intent: BookingIntent, listingId: number, ): Promise => { + const sessionId = session.id; + const paymentReference = session.paymentReference; const disposition = await bookingLedgerDisposition(sessionId); switch (disposition.status) { case "unrecorded": return null; case "booked": - return replaySuccess(sessionId, disposition.attendeeId, listingId); - case "orphaned": + return replaySuccess({ + attendeeId: disposition.attendeeId, + listingId, + paymentReference, + sessionId, + }); + case "orphaned": { + // The money is already accounted for. A stage still pending here is a + // crash leftover — the keep-and-refund paths resolve their stage LAST, + // so a crash after the ledger post can lose that flip. The outcome is + // known, so resolve it (an open stage blocks edits and merges and + // holds the staged details unprunable) and leave the note the + // interrupted run never wrote. + const healedStage = await getCheckoutStageOrNull(sessionId); + if (healedStage && isOpenCheckoutStage(healedStage.state)) { + // Stamp the payment reference the interrupted run never wrote — the + // money legs post BEFORE stampStagedPaymentId, so a crash between them + // leaves an empty payment_id, hiding the payment panel and refund path + // for a charge the note tells the operator to reconcile. Written before + // the note (which goes before the resolve: if the resolve is lost, the + // retry re-runs this heal, and a duplicate note beats a missing one). + await stampStagedPaymentId(healedStage, session, intent); + await createSystemNote( + healedStage.attendeeId, + healedStageNote(paymentReference), + ); + await resolvePendingStage(sessionId); + } return alreadyHandledSession(sessionId, listingId); + } } }; -/** - * The balance-settlement counterpart of {@link replaySessionFromLedger}: replay a - * balance session whose payment leg the ledger already records (its idempotency - * row was pruned or lost), or null to settle it fresh. The attendee is known from - * the proof-bound intent, so — unlike the booking path — there is no orphaned - * case to resolve. - */ +/** Replay a balance payment already recorded by the ledger, or settle it fresh. */ const replayBalanceFromLedger = async ( sessionId: string, attendeeId: number, @@ -194,55 +209,90 @@ const replayBalanceFromLedger = async ( paymentReference: string, ): Promise => (await eventGroupHasLegs(await balanceEventGroup(sessionId))) - ? replaySuccess(sessionId, attendeeId, listingId, paymentReference) + ? replaySuccess({ attendeeId, listingId, paymentReference, sessionId }) : null; -/** - * Process a session we have just reserved (holding the lock). A signed session - * either becomes a real ticket or — for ANY reason we can't honour it (charge - * mismatch, a price edited mid-checkout, a sold-out extra, a full event, a - * since-deleted listing, or an unexpected error after the charge) — is kept as a - * quantity-0 placeholder and refunded, so a paid customer is never dropped. Every - * failure returned here is a handled terminal outcome; processPaymentSession - * records it so a later redirect/webhook replays the same result instead of - * re-running refunds or stalling behind the idempotency lock. - */ -const processReservedSession: SessionProcessor = async ( - sessionId, - data, - options, -) => { +/** Balance payment: settle the existing attendee rather than create one. A + * mismatch can't be "stored" (the attendee already exists), so it refunds-and- + * fails as before, idempotently inside the reservation. */ +const processBalanceSession = async ( + sessionId: string, + balanceAttendeeId: number, + data: ValidatedSession, + signedListingId: number, +): Promise => { + // Preflight: a balance session whose payment leg is already in the ledger is + // a replay (its idempotency row was pruned or lost). Replay success rather + // than re-settling — settleAttendeeBalance would find nothing owed and refund + // a balance that's already paid. + const replay = await replayBalanceFromLedger( + sessionId, + balanceAttendeeId, + signedListingId, + data.session.paymentReference, + ); + if (replay) return replay; + if (data.verdict.verdict === "mismatch") { + return refuseMismatch(data.session, data.verdict.agreed, signedListingId); + } + return settleBalanceSession(sessionId, data.session, data.intent); +}; + +/** Process a reserved session into a ticket or handled terminal outcome. Errors + * after an atomic booking commit propagate rather than refunding a live ticket. */ +const processReservedSession = async ( + sessionId: string, + data: ValidatedSession, +): Promise => { const { session, intent, verdict } = data; const signedListingId = intent.items[0]!.e; - // Balance payment: settle the existing attendee rather than create one. A - // mismatch can't be "stored" (the attendee already exists), so it refunds-and- - // fails as before, idempotently inside the reservation. if (intent.balanceAttendeeId) { - // Preflight: a balance session whose payment leg is already in the ledger is - // a replay (its idempotency row was pruned or lost). Replay success rather - // than re-settling — settleAttendeeBalance would find nothing owed and refund - // a balance that's already paid. - const replay = await replayBalanceFromLedger( + return processBalanceSession( sessionId, intent.balanceAttendeeId, + data, signedListingId, - session.paymentReference, ); - if (replay) return replay; - if (verdict.verdict === "mismatch") { - return refuseMismatch(session, verdict.agreed, signedListingId); - } - return settleBalanceSession(sessionId, session, intent); } - // Preflight: the durable ledger is the source of truth for "already honoured". // Replay a session the ledger already records BEFORE any validation, pricing, // or refund path runs below — so a late delivery (after the prunable idempotency // row is gone) never refunds a live ticket via the deleted-listing, price-change, // inactive-listing, or capacity paths, nor double-books it. - const replay = await replaySessionFromLedger(sessionId, signedListingId); - if (replay) return replay; + const replay = await replaySessionFromLedgerOrNull( + session, + intent, + signedListingId, + ); + if (replay !== null) return replay; + + // Fetched only past the replay preflight: a replayed delivery never needs the + // stage, so reading it earlier would waste a primary round-trip per replay. + const stage = await getCheckoutStageOrNull(sessionId); + + // Once any delivery starts refunding a staged order, every later delivery + // stays on that rail even if the original refusal disappears. Re-validating + // into activation here could issue a live ticket for money already returned. + if (stage?.state === "refunding") { + return storeRefundedBooking( + session, + intent, + datelessGhostBookings(intent.items), + refundSpec("refund_retry")( + `Continuing the refund already started for session ${sessionId}`, + ), + ); + } + + // The ledger preflight above is the designed replay rail. Reaching here with + // a stage that is already resolved means both the idempotency row and the + // ledger trail for this session are gone (or were never written — e.g. a + // swallowed placeholder-refund post). Fail closed: re-processing could book + // rows for money that was already refunded months ago. + if (stage && !isOpenCheckoutStage(stage.state)) { + return alreadyHandledSession(sessionId, signedListingId); + } // Phase 2: Validate listings. const validated = await validateAllItems(session, intent); @@ -263,7 +313,12 @@ const processReservedSession: SessionProcessor = async ( deletedListingSpec(session), ); } - return validated; + // A known-listing refusal (closed or deactivated mid-payment) refunded + // inside validateAllItems; a staged session still needs its stage marked + // failed, the money round-trip recorded, and its record noted, or the + // pending stage holds the PII unprunable for as long as the payment row + // lives. + return failStagedValidation(session, intent, signedListingId, validated); } const validatedItems = validated.items; @@ -298,6 +353,19 @@ const processReservedSession: SessionProcessor = async ( // booking at quantity 0 and refunds rather than dropping a paid customer: a // structured sold-out/capacity/encryption result, OR an unexpected throw after // the charge (which would otherwise crash-loop the webhook over paid money). + const preparedTicketToken = stage?.ticketToken ?? generateTicketToken(); + const codeSpecs = modifierSpecs.filter((spec) => spec.trigger === "code"); + const complete = ( + entries: Parameters[0], + ticketTokens: string[], + ) => + completePaidBooking( + entries, + intent, + codeSpecs, + pricedOrder.modifierApplications, + ticketTokens, + ); let honoured: Awaited>; try { honoured = await createAttendeeForSession( @@ -306,58 +374,53 @@ const processReservedSession: SessionProcessor = async ( validatedItems, pricingIntent, pricedOrder, + preparedTicketToken, + stage, ); } catch (error) { - return storeRefundedBooking( - session, + // The atomic create may have committed before result handling or the client + // threw. Recheck its reservation on the primary before moving money. + return recoverOrRefundUnexpectedCreate({ + complete, + error, intent, placeholders, - refundSpec("unexpected_error")( - `Unexpected error completing session ${session.id}: ${String(error)}`, - ), - ); + session, + ticketToken: preparedTicketToken, + validatedItems, + }); } if (!honoured.ok) { + const { reason } = honoured; + if (reason === "stage_active") { + // Only the staged activation path can report stage_active, so the stage + // this session was created with is always present here. + return stagedConflict( + session, + intent, + stage!, + signedListingId, + honoured.detail, + ); + } return storeRefundedBooking( session, intent, placeholders, - specForFailure(honoured), + specForFailure({ ...honoured, reason }), ); } // Success: a real ticket, finalized atomically in the creation transaction. const createdEntries = honoured.entries; - await saveSessionAnswers(createdEntries, intent); const firstAttendee = createdEntries[0]!; const ticketToken = firstAttendee.attendee.ticket_token; - - // Persist the ticket token for webhook replay when the caller needs it. - if (options?.storeTokens !== false) { - await setSessionTicketTokens(sessionId, [ticketToken]); - } - - const codeSpecs = modifierSpecs.filter((s) => s.trigger === "code"); - if (codeSpecs.length > 0) { - await logPromoCodeModifiers( - codeSpecs, - pricedOrder.modifierApplications, - firstAttendee.listing, - firstAttendee.attendee.id, - ); - } - - await logAndNotifyRegistration(createdEntries, intent.siteTokenIndex); - - return sessionSuccess(firstAttendee.attendee.id, firstAttendee.listing.id, [ - ticketToken, - ]); + return complete(createdEntries, [ticketToken]); }; export const processPaymentSession: SessionProcessor = async ( sessionId, data, - options, ) => { // Phase 1: Reserve the session (claim the lock) const reservation = await reserveSession(sessionId); @@ -365,7 +428,7 @@ export const processPaymentSession: SessionProcessor = async ( return handleReservationConflict(data.intent, reservation.existing); } - const result = await processReservedSession(sessionId, data, options); + const result = await processReservedSession(sessionId, data); // A refund of a real payment that FAILED must stay retryable, and the very // next provider redelivery should re-attempt it. Releasing the reservation @@ -373,12 +436,12 @@ export const processPaymentSession: SessionProcessor = async ( // that happen: a held reservation would make the redelivery collide with the // lock and return 409 until the row goes stale (~5 min), gating refund // recovery on a local timer instead of provider redelivery. Releasing lets - // the next delivery re-claim and re-refund immediately. This CANNOT - // double-pay: every provider refunds the full charge amount and rejects a - // refund that exceeds the already-refunded balance, and tryRefund treats an - // already-refunded payment as success — so a redelivery after a refund that - // actually went through (but reported failure) records success, not a second - // payout. + // the next delivery re-claim and re-refund immediately. A staged order has + // already entered its durable `refunding` state, so re-claiming can only + // finish that refund, never reconsider activation after the refusal changes. + // This cannot double-pay: every provider refunds the full charge amount and + // rejects a refund that exceeds the already-refunded balance, and tryRefund + // treats an already-refunded payment as success. if (!result.success && result.refunded === false) { await releaseReservation(sessionId); return result; @@ -395,6 +458,10 @@ export const processPaymentSession: SessionProcessor = async ( refunded: result.refunded, status: result.status, }); + // A handled failure is terminal. Keep its replay guard, but release any + // staged attendee and scrub the copied bearer token even if the specific + // refusal path was interrupted before doing its own final stage update. + await resolvePendingStage(sessionId); } return result; diff --git a/src/features/api/payment-processing/recovery-decision.ts b/src/features/api/payment-processing/recovery-decision.ts new file mode 100644 index 0000000000..048a4d8a5e --- /dev/null +++ b/src/features/api/payment-processing/recovery-decision.ts @@ -0,0 +1,29 @@ +export type RecoveryFacts = { + finalizedAttendeeId: number | null; + tokenAttendeeId: number | null; + unresolved: boolean; +}; + +export type RecoveryDecision = + | { attendeeId: number; kind: "recover" } + | { kind: "refund" } + | { kind: "rethrow" }; + +/** Decide from committed primary state only. An attendee beside an unresolved + * reservation is impossible after atomic create cleanup, so it fails loudly. + * For a STAGED session the token attendee is the staged row itself, so this + * always rethrows — which is correct: the deterministic staged problems + * (changed lines, already-active rows) return structured results before + * anything can throw, so only transient or system-down errors reach here, and + * those must retry on the provider's next delivery, never refund. */ +export const decideUnexpectedCreate = ( + facts: RecoveryFacts, +): RecoveryDecision => { + if (facts.finalizedAttendeeId !== null) { + return { attendeeId: facts.finalizedAttendeeId, kind: "recover" }; + } + if (!facts.unresolved || facts.tokenAttendeeId !== null) { + return { kind: "rethrow" }; + } + return { kind: "refund" }; +}; diff --git a/src/features/api/payment-processing/recovery.ts b/src/features/api/payment-processing/recovery.ts new file mode 100644 index 0000000000..49a95721a2 --- /dev/null +++ b/src/features/api/payment-processing/recovery.ts @@ -0,0 +1,112 @@ +import { committedEntries } from "#routes/api/payment-processing/committed-entries.ts"; +import type { CreatedEntry } from "#routes/api/payment-processing/create.ts"; +import type { ValidatedItem } from "#routes/api/payment-processing/package-pricing.ts"; +import { decideUnexpectedCreate } from "#routes/api/payment-processing/recovery-decision.ts"; +import { refundSpec } from "#routes/api/payment-processing/refunds.ts"; +import { + type placeholderBookings, + storeRefundedBooking, +} from "#routes/api/payment-processing/store-refund.ts"; +import type { + BookingIntent, + PaymentResult, + ValidatedSession, +} from "#routes/api/webhook-types.ts"; +import { computeTicketTokenIndex } from "#shared/crypto/hashing.ts"; +import type { BlindIndex } from "#shared/crypto/sealed.ts"; +import { queryBatchPrimary, resultRows } from "#shared/db/client.ts"; +import { recordOrderActivity } from "#shared/db/contact-tokens.ts"; +import { UNRESOLVED_RESERVATION } from "#shared/db/processed-payments.ts"; + +type UnexpectedCreateRecovery = { + complete: ( + entries: CreatedEntry[], + ticketTokens: string[], + ) => Promise; + error: unknown; + intent: BookingIntent; + placeholders: ReturnType; + session: ValidatedSession["session"]; + ticketToken: string; + validatedItems: ValidatedItem[]; +}; + +/** Restore the contact history normally written after the booking batch returns. + * A committed batch whose result was lost never reached that completion step. */ +const recordRecoveredOrderActivity = ( + intent: BookingIntent, + ticketToken: string, +): Promise => + recordOrderActivity(intent.email, intent.phone, "public", ticketToken); + +const loadRecoveryFacts = async ( + sessionId: string, + ticketTokenIndex: BlindIndex, +) => { + const [finalizedResult, unresolvedResult, attendeeResult] = + await queryBatchPrimary([ + { + args: [sessionId, ticketTokenIndex], + sql: `SELECT processedPayment.attendee_id + FROM processed_payments AS processedPayment + JOIN attendees AS attendee + ON attendee.id = processedPayment.attendee_id + WHERE processedPayment.payment_session_id = ? + AND attendee.ticket_token_index = ?`, + }, + { + args: [sessionId], + sql: `SELECT 1 AS present FROM processed_payments + WHERE payment_session_id = ? AND ${UNRESOLVED_RESERVATION}`, + }, + { + args: [ticketTokenIndex], + sql: "SELECT id FROM attendees WHERE ticket_token_index = ?", + }, + ]); + const finalized = resultRows<{ attendee_id: number }>(finalizedResult!)[0]; + const attendee = resultRows<{ id: number }>(attendeeResult!)[0]; + return { + finalizedAttendeeId: finalized === undefined ? null : finalized.attendee_id, + tokenAttendeeId: attendee === undefined ? null : attendee.id, + unresolved: resultRows<{ present: number }>(unresolvedResult!).length === 1, + }; +}; + +/** Recover an atomically finalized ticket after result handling throws. Refund + * only when the primary reservation proves the booking never committed. */ +export const recoverOrRefundUnexpectedCreate = async ({ + complete, + error, + intent, + placeholders, + session, + ticketToken, + validatedItems, +}: UnexpectedCreateRecovery): Promise => { + const ticketTokenIndex = await computeTicketTokenIndex(ticketToken); + const decision = decideUnexpectedCreate( + await loadRecoveryFacts(session.id, ticketTokenIndex), + ); + if (decision.kind === "recover") { + const entries = await committedEntries( + decision.attendeeId, + ticketToken, + session, + intent, + validatedItems, + ); + await recordRecoveredOrderActivity(intent, ticketToken); + return complete(entries, [ticketToken]); + } + if (decision.kind === "rethrow") throw error; + + return storeRefundedBooking( + session, + intent, + placeholders, + refundSpec("unexpected_error")( + `Unexpected error completing session ${session.id}: ${String(error)}`, + ), + ); +}; diff --git a/src/features/api/payment-processing/refunds.ts b/src/features/api/payment-processing/refunds.ts index 1bd2aeef52..23d0540432 100644 --- a/src/features/api/payment-processing/refunds.ts +++ b/src/features/api/payment-processing/refunds.ts @@ -201,13 +201,23 @@ const REFUND_REASONS = { notify: ErrorCode.WEBHOOK_PRICE_SIGNATURE, reason: "the amount charged did not match the agreed total", }, + listing_closed: { + reason: "the listing stopped taking bookings while they were paying", + }, listing_removed: { notify: ErrorCode.PAYMENT_SESSION, reason: "the listing was removed while they were paying", }, + order_changed: { + notify: ErrorCode.PAYMENT_SESSION, + reason: "the booking changed between checkout and payment", + }, price_changed: { reason: "the listing price changed while they were paying", }, + refund_retry: { + reason: "an earlier automatic refund had already started", + }, sold_out: { reason: "an add-on or extra they chose sold out while they were paying", }, diff --git a/src/features/api/payment-processing/store-refund.ts b/src/features/api/payment-processing/store-refund.ts index e990239ac6..e952fca240 100644 --- a/src/features/api/payment-processing/store-refund.ts +++ b/src/features/api/payment-processing/store-refund.ts @@ -9,7 +9,6 @@ import { attendeeBaseFields, - bookingSlot, type HonourResult, sessionSuccess, } from "#routes/api/payment-processing/create.ts"; @@ -29,16 +28,29 @@ import type { PaymentResult, } from "#routes/api/webhook-types.ts"; import { bookingDateFields } from "#shared/booking-date-fields.ts"; +import { bookingSlot } from "#shared/booking-lines.ts"; import { logActivity } from "#shared/db/activityLog.ts"; +import type { CreateAttendeeSuccess } from "#shared/db/attendee-types.ts"; import { createAttendeeAtomic } from "#shared/db/attendees/api.ts"; import { settleAttendeeBalance } from "#shared/db/attendees/balance.ts"; +import { contactFields } from "#shared/db/attendees/pii.ts"; +import { updateAttendeePII } from "#shared/db/attendees/update.ts"; +import { + beginCheckoutStageRefund, + type CheckoutStage, + getCheckoutStageOrNull, + markCheckoutStage, +} from "#shared/db/checkout-stages.ts"; import { balanceFinalizeStatement } from "#shared/db/payment-finalize.ts"; import { createSystemNote } from "#shared/db/system-notes.ts"; import { ErrorCode, logError } from "#shared/logger.ts"; import { sendNtfyError } from "#shared/ntfy.ts"; import type { BookingItem, ValidatedPaymentSession } from "#shared/payments.ts"; import { addPendingWork } from "#shared/pending-work.ts"; -import { recordPlaceholderRefund } from "#shared/refund-ledger.ts"; +import { + type PlaceholderRefundFacts, + recordPlaceholderRefund, +} from "#shared/refund-ledger.ts"; /** User-facing message when the outstanding balance changed mid-payment. */ const BALANCE_CHANGED_MESSAGE = @@ -80,6 +92,80 @@ type PlaceholderBookings = Parameters< typeof createAttendeeAtomic >[0]["bookings"]; +/** The money facts of a kept-but-unhonoured booking, read off its session: + * what the provider charged, whose record holds it, and the session id that + * keys the ledger event group. Shared by every keep-and-record path. */ +const placeholderFacts = ( + session: ValidatedPaymentSession, + attendeeId: number, + listingId: number, +): PlaceholderRefundFacts => ({ + amount: session.amountTotal, + attendeeId, + eventId: session.id, + listingId, + occurredAt: businessTime(session), +}); + +/** Write the provider's payment reference into a kept staged record's stored + * details (rewriting the same contact fields staging wrote from this same + * signed intent), so the record's payment panel can find the charge. Every + * terminal outcome that keeps a staged record for a charged session stamps + * this — without it the operator has no in-app view of the money. Also used by + * the ledger-heal path, which repairs a crash that lost the stamp after the + * money legs were already posted. */ +export const stampStagedPaymentId = ( + stage: CheckoutStage, + session: ValidatedPaymentSession, + intent: BookingIntent, +): Promise => + updateAttendeePII(stage.attendeeId, { + ...contactFields(intent), + lat: "", + lng: "", + payment_id: session.paymentReference, + ticket_token: stage.ticketToken, + }); + +/** The facts identifying a charge held against a staged record: the paid + * session, its signed intent, the staged attendee, and the listing the money is + * for. These four always travel together into the money-posting helper. */ +type HeldStagedCharge = { + session: ValidatedPaymentSession; + intent: BookingIntent; + stage: CheckoutStage; + listingId: number; +}; + +/** Stamp the held charge's payment reference onto the staged record, then post + * its money to the ledger — the `payment` leg, plus `refund_cash` when the money + * went back. Shared by both paid-but-not-ticketed terminal paths (a mid-payment + * close and a mid-payment edit conflict) so neither can post one without the + * other. A failed ledger write is a system fault, not part of the outcome: it + * throws so the caller leaves the stage open and the provider's next delivery + * re-runs the whole path (a settled refund reads back as refunded, so a retry + * never moves money twice). */ +const recordHeldStagedMoney = async ( + { session, intent, stage, listingId }: HeldStagedCharge, + memo: string, + refunded: boolean, +): Promise => { + const { posted } = await recordPlaceholderRefund( + placeholderFacts(session, stage.attendeeId, listingId), + memo, + refunded, + ); + if (!posted) { + throw new Error( + `Could not record session ${session.id}'s money in the ledger`, + ); + } + // Stamp the payment reference only AFTER the money is in the ledger: a failed + // post throws above, leaving the still-open stage with no reference for the + // Actions tab to expose a mid-flight refund against. + await stampStagedPaymentId(stage, session, intent); +}; + /** * Settle a reserved attendee's balance instead of creating a new attendee. * @@ -148,10 +234,14 @@ export const settleBalanceSession = async ( * carrying the reason and the provider's payment reference. The customer is told * their details were saved and the payment refunded; no ticket is issued. * - * We never report `refunded: false`. The booking now exists, so a retry must NOT - * re-create it — an un-refunded payment is recorded as a terminal, operator- - * resolved outcome (the note's manual-refund instruction stands) rather than - * released for re-processing. + * A refund that FAILED splits by whether the order was staged. A NO-STAGE + * placeholder was just created here, so a retry would mint a duplicate — its + * un-refunded payment is a terminal, operator-resolved outcome (the note's + * manual-refund instruction stands), reported with `refunded` absent. A STAGED + * order reuses its existing quantity-0 record, so a retry is safe and better: + * it stays retryable (`refunded: false`, no ledger legs, stage left refunding) so + * the provider's next delivery re-attempts the refund — the same design as + * {@link failStagedValidation}. */ export const storeRefundedBooking = async ( session: ValidatedPaymentSession, @@ -160,29 +250,70 @@ export const storeRefundedBooking = async ( spec: RefundSpec, ): Promise => { if (spec.notify) addPendingWork(sendNtfyError(spec.notify)); + // Resolved here, not taken as a parameter, so no failure path can forget the + // session was staged and mint a duplicate placeholder beside the staged one. + const stage = await getCheckoutStageOrNull(session.id); const listingId = bookings[0]!.listingId; // A quantity-0 overbook insert has no capacity gate and consumes no modifier // stock, so it always writes the row — trust it. (If the PII can't encrypt the // whole system is broken; we don't defend against that.) - const stored = await createAttendeeAtomic({ - ...(await attendeeBaseFields(session, intent)), - allowOverbook: true, - bookings, - }); - const attendeeId = (stored as Extract) - .attendees[0]!.id; + const attendeeId = stage + ? stage.attendeeId + : ( + (await createAttendeeAtomic({ + ...(await attendeeBaseFields(session, intent)), + allowOverbook: true, + bookings, + })) as CreateAttendeeSuccess + ).attendees[0]!.id; + if (stage?.state === "pending") { + await beginCheckoutStageRefund(session.id); + } const refunded = await tryRefund(session.paymentReference, listingId); - await recordPlaceholderRefund( - { - amount: session.amountTotal, - attendeeId, - eventId: session.id, + // A STAGED order whose provider refund FAILED must stay retryable, not go + // terminal: leave the stage refunding with NO ledger legs so the next delivery + // re-runs the whole path and re-attempts tryRefund (a settled refund reads + // back as success, so a retry never refunds twice). Posting the placeholder + // payment leg now would make the ledger preflight (bookingLedgerDisposition) + // answer "already handled" — the leg exists but the quantity-0 record owns no + // live booking, so it reads as orphaned money — and never re-refund. Reporting + // refunded:false releases the reservation and returns 503 to the provider so it + // redelivers. The no-stage path below can't retry (its placeholder is already + // created and a retry would mint a duplicate), so it records the miss + // terminally instead. This mirrors failStagedValidation's retryable design. + if (stage && !refunded) { + logError({ + code: ErrorCode.PAYMENT_REFUND, + detail: `Staged refund not settled for ${attendeeId} (${spec.code}); leaving stage refunding to retry: ${spec.detail}`, listingId, - occurredAt: businessTime(session), - }, + }); + return { + detail: spec.detail, + error: BOOKING_SAVED_MESSAGE, + refunded: false, + status: 200, + success: false, + }; + } + const { posted } = await recordPlaceholderRefund( + placeholderFacts(session, attendeeId, listingId), spec.code, refunded, ); + // A failed ledger post on a STAGED order must retry, not resolve: throw so the + // stage stays refunding and the provider's next delivery re-posts (tryRefund + // reads the settled refund back as success, so it never refunds twice). The + // no-stage path can't retry without minting a duplicate placeholder, so it + // proceeds — postWithoutThrowing has already logged the miss. + if (!posted && stage) { + throw new Error( + `Could not record session ${session.id}'s placeholder money in the ledger`, + ); + } + // Stamp the payment reference only AFTER the money is recorded, so a failed + // post never leaves a refundable reference on a still-open stage — the + // Actions tab would otherwise offer to refund a charge the ledger hasn't seen. + if (stage) await stampStagedPaymentId(stage, session, intent); if (refunded) { await logActivity( `Automatic refund (${spec.code}); booking kept at quantity 0`, @@ -200,6 +331,13 @@ export const storeRefundedBooking = async ( attendeeId, refundedNoteText(attendeeId, spec, refunded, session.paymentReference), ); + // The stage resolves LAST — after the refund attempt and the ledger/note + // writes — so a throw anywhere above leaves it refunding and the redelivery + // re-runs the whole path (tryRefund reads an already-settled refund as + // success, so a retry never refunds twice). A crash between the ledger post + // and this line is healed by the next delivery's orphaned-ledger answer, + // which resolves the leftover open stage (resolvePendingStage). + if (stage) await markCheckoutStage(session.id); // Status 200: a fully-handled terminal outcome (booking kept, money returned or // flagged). The webhook acks it (never the 409 transient-lock retry nor a 503 // refund retry — the booking exists, so a retry can't re-create it), and the @@ -215,20 +353,147 @@ export const storeRefundedBooking = async ( }; }; +/** Every honour failure that is safe to refund. "stage_active" is deliberately + * excluded: its rows may be a live booking, so the money must wait for the + * operator ({@link stagedConflict}) — the type makes routing it here an error. */ +export type RefundableFailure = Extract & { + reason: Exclude< + Extract["reason"], + "stage_active" + >; +}; + /** The refund reason code for each way a booking we tried can fail: a sold-out - * extra reads differently from a full event, and the broken-system - * encryption_error we don't special-case is treated as "the event filled up". */ -const FAILURE_REFUND_CODES: Record< - Extract["reason"], - RefundCode -> = { + * extra reads differently from a full event, the broken-system + * encryption_error we don't special-case is treated as "the event filled up", + * and changed staged lines (still all quantity 0, so nothing is live) read as + * "the booking changed". */ +const FAILURE_REFUND_CODES: Record = { capacity_exceeded: "capacity_full", encryption_error: "capacity_full", sold_out: "sold_out", + stage_mismatch: "order_changed", }; /** The placeholder refund reason for a booking we tried but couldn't honour. */ -export const specForFailure = ( - failure: Extract, -): RefundSpec => +export const specForFailure = (failure: RefundableFailure): RefundSpec => refundSpec(FAILURE_REFUND_CODES[failure.reason])(failure.detail); + +/** + * After a known-listing validation failure refunds a session (closed or + * deactivated mid-payment — the refund happened in validateAllItems), resolve + * the stage: mark it failed and leave the usual refunded-booking note on the + * staged attendee, which IS the operator's record of the order. Without this + * the stage stayed "pending", which both hid the story from the operator and + * held the staged PII shielded from the prune for as long as the payment row + * lived. A session with no stage passes through unchanged. + */ +export const failStagedValidation = async ( + session: ValidatedPaymentSession, + intent: BookingIntent, + listingId: number, + result: PaymentFailureResult, +): Promise => { + const stage = await getCheckoutStageOrNull(session.id); + if (!stage) return result; + // The provider call already happened during listing validation. Persist the + // refund-only rail before any local outcome handling, so a failed ledger post + // or false provider result can never leave this stage eligible to activate. + await beginCheckoutStageRefund(session.id); + // A refund that FAILED must stay retryable: the reservation is released and + // the provider's next delivery re-attempts it. Resolving the stage here + // would make that retry hit the resolved-stage guard and answer "already + // processed" without ever refunding — so the stage only resolves once the + // money is actually back. + if (result.refunded !== true) { + return result; + } + // The staged attendee is the operator's record of the order, so its ledger + // must show the charge and the refund that actually happened — the same + // payment + refund_cash round-trip every other kept-and-refunded booking + // records. + await recordHeldStagedMoney( + { intent, listingId, session, stage }, + "listing_closed", + true, + ); + await createSystemNote( + stage.attendeeId, + refundedNoteText( + stage.attendeeId, + refundSpec("listing_closed")(result.detail ?? result.error), + true, + session.paymentReference, + ), + ); + // Resolved last: a throw above leaves the stage refunding, so the redelivery + // re-runs this path (the settled refund reads back as refunded). A crash + // between the ledger post and this line is healed by the next delivery's + // orphaned-ledger answer (resolvePendingStage). + await markCheckoutStage(session.id); + return result; +}; + +/** What the customer reads when their paid order needs the organiser's eyes: + * we neither issue a ticket nor promise a refund, because the operator decides. */ +const NEEDS_ORGANISER_MESSAGE = + "Your payment was received, but this booking needs to be confirmed by the organiser. Please contact them."; + +/** The operator-facing note for a staged order whose rows were already given a + * real quantity outside payment. PII-free; carries the payment reference so the + * charge can be found in the provider dashboard. */ +const stagedConflictNote = (paymentReference: string): string => + `This booking was changed while its payment was still being processed, so the payment could not complete automatically. The payment was NOT refunded. Please check the booking's quantities, then either confirm the booking with the customer or refund the payment manually. Payment reference: ${paymentReference} (code: stage_active).`; + +/** + * A paid session whose staged rows were already given a real quantity outside + * the payment flow (an operator edit mid-checkout). The rows may be a live + * booking, so this must NOT refund (money back beside a live ticket is the + * exact failure the staged flow exists to prevent) and must NOT re-claim the + * rows (double-counting capacity and money). Instead the money waits for the + * operator: a loud classified error, a note on the booking's own record, and a + * terminal handled outcome so every replay answers the same. + */ +export const stagedConflict = async ( + session: ValidatedPaymentSession, + intent: BookingIntent, + stage: CheckoutStage, + listingId: number, + detail: string, +): Promise => { + logError({ + code: ErrorCode.PAYMENT_SESSION, + detail, + listingId, + }); + await createSystemNote( + stage.attendeeId, + stagedConflictNote(session.paymentReference), + ); + // The charge we hold is a money fact the operator reconciles against: stamp + // the payment reference (so the record's payment panel and in-app refund + // work) and post the received `payment` leg — no sale and no refund, so the + // ledger says exactly "we hold this customer's money". Posted before the + // stage resolves: a crash in between replays off the ledger preflight as + // "already handled" (the conflicted rows never carry this session's event + // group, so the leg reads as orphaned money) — never a double post, and the + // preflight resolves the leftover pending stage (resolvePendingStage). + await recordHeldStagedMoney( + { intent, listingId, session, stage }, + "stage_active", + false, + ); + // The outcome is recorded, so the stage resolves: the operator must now be + // ABLE to edit the record to act on the note (a pending stage blocks edits + // and merges), and a very late redelivery answers "already processed". + await markCheckoutStage(session.id); + // Status 200: a recorded, operator-owned terminal outcome. The webhook acks + // it (retrying cannot resolve a human decision), and `refunded` stays absent + // so the customer message carries no refund promise either way. + return { + detail, + error: NEEDS_ORGANISER_MESSAGE, + status: 200, + success: false, + }; +}; diff --git a/src/features/api/webhooks.ts b/src/features/api/webhooks.ts index b822848069..ea12f5c8a3 100644 --- a/src/features/api/webhooks.ts +++ b/src/features/api/webhooks.ts @@ -14,7 +14,7 @@ * - Two-phase locking prevents duplicate attendee creation from race conditions */ -import { unique } from "#fp"; +import { asString, unique } from "#fp"; import { cancelPageResponse } from "#routes/api/payment-processing/cancel.ts"; import { classifySessionIntent, @@ -43,6 +43,7 @@ import { } from "#routes/tickets/token-utils.ts"; import { getSearchParam } from "#routes/url.ts"; /* jscpd:ignore-end */ +import { discardPendingCheckoutSessions } from "#shared/db/checkout-stage-cleanup.ts"; import { getHiddenPackageMemberIds } from "#shared/db/groups.ts"; import { getListingWithCount } from "#shared/db/listings/records.ts"; import { clearSessionTokens } from "#shared/db/processed-payments.ts"; @@ -123,15 +124,7 @@ const processSessionAndRedirect = async ( // verified intent still holds it, rather than redirecting to the token path. const explicitThankYou = validation.data.intent.thankYouUrl ?? ""; - // Token persistence diverges by render path. The redirect path skips persisting - // (the tokens go in the URL, so storing them would leave them in the DB forever - // when the redirect wins the race). The direct-render path (explicit thank-you - // URL) does NOT put the tokens in a URL, so it MUST persist them — otherwise a - // reload hits the already-processed branch with no stored token and the buyer - // loses the ticket link. - const result = await processPaymentSession(sessionId, validation.data, { - storeTokens: explicitThankYou !== "", - }); + const result = await processPaymentSession(sessionId, validation.data); if (!result.success) { // Log once at the redirect boundary @@ -154,16 +147,15 @@ const processSessionAndRedirect = async ( ); } - // Redirect path: the tokens go in the URL, so clear any a racing webhook stored - // (consumed now via the redirect URL), then redirect. + // Redirect path: once the URL is ready, clear the persisted copy in one write. + // Direct-render and webhook paths retain it for a later redirect or reload. // encodeURIComponent preserves + as %2B so URLSearchParams.get() decodes it back correctly if (result.ticketTokens.length > 0) { + const location = `/payment/success?tokens=${encodeURIComponent( + result.ticketTokens.join("+"), + )}`; await clearSessionTokens(sessionId); - return redirectResponse( - `/payment/success?tokens=${encodeURIComponent( - result.ticketTokens.join("+"), - )}`, - ); + return redirectResponse(location); } // Already-processed session (no tokens available) - render directly. An @@ -237,7 +229,7 @@ const handlePaymentSuccess = (request: Request): Promise => { /** * Handle GET /payment/cancel (redirect after cancelled payment) * - * No attendee cleanup needed - attendee is only created after successful payment. + * Remove the quantity-zero staged attendee after an unpaid provider return. */ /** Log a payment session error with cancel context prefix */ const logCancelError = paymentSessionErrorLogger("cancel"); @@ -255,6 +247,14 @@ const handlePaymentCancel = withSessionId(async (sid) => { return paymentErrorResponse("Payment session not found"); } + if (session.paymentStatus !== "paid") { + const discarded = await discardPendingCheckoutSessions([sid]); + // Close the provider session too, so an old tab cannot pay for a checkout + // whose staged details are gone. Best-effort — a session we fail to close + // (or a provider with no way to close one) simply books fresh if paid late. + if (discarded > 0) await provider.expireCheckoutSession?.(sid); + } + return cancelPageResponse(session, logCancelError); }); @@ -391,6 +391,16 @@ const handlePaymentWebhook = async (request: Request): Promise => { if (auth instanceof Response) return auth; const { provider, listing } = auth; + // A checkout that expired unpaid can never complete: discard its staged + // details now instead of waiting out the prune window. The discard's + // "no payment has claimed this session" guard makes a race with a + // just-completed payment safe — it simply deletes nothing. + if (listing.type === provider.checkoutExpiredEventType) { + const expiredId = asString((listing.data.object as { id?: unknown }).id); + if (expiredId) await discardPendingCheckoutSessions([expiredId]); + return webhookAckResponse(); + } + // Only handle checkout completed listings if (listing.type !== provider.checkoutCompletedEventType) { // Acknowledge other listings without processing diff --git a/src/features/index.ts b/src/features/index.ts index c18415bf3c..fdc67dabfd 100644 --- a/src/features/index.ts +++ b/src/features/index.ts @@ -93,11 +93,13 @@ import { logRequest, runWithRequestId, } from "#shared/logger.ts"; +import { getPaymentWebhookUrl } from "#shared/payment-webhook-url.ts"; import { addPendingWork, flushPendingWork } from "#shared/pending-work.ts"; import { runWithRequestCache } from "#shared/request-cache.ts"; import type { ResponseHandler } from "#shared/response-steps.ts"; import { routePathPatternToRegex } from "#shared/route-pattern.ts"; import { runWithSessionContext } from "#shared/session-context.ts"; +import { STRIPE_WEBHOOK_EVENTS_VERSION } from "#shared/stripe-webhook-events.ts"; import { getRethrowErrors } from "#shared/test-overrides.ts"; import { readOnlyPage } from "#templates/public/errors.tsx"; @@ -618,6 +620,14 @@ const applyFlashFromCookie = (request: Request, url: URL): string | null => { return flash ? flashId : null; }; +/** Load the Stripe upgrade module only for an install whose event set is stale. */ +const reconcileStripeWebhook = async (): Promise => { + const { reconcileStoredStripeWebhook } = await import( + "#shared/stripe-webhook-reconcile.ts" + ); + await reconcileStoredStripeWebhook(getPaymentWebhookUrl()); +}; + /** * Run settings load, schedule pruning, resolve effective domain. * These are per-request setup tasks that must happen before routing. @@ -655,6 +665,16 @@ const prepareRequestEnvironment = async ( // Load effective domain (custom_domain from DB if set, else request hostname) loadEffectiveDomain(url); + + // Existing Stripe installs gain newly-required events once per event-set + // version. The current marker costs no extra query (it is in the infra + // bundle); only a stale install loads Stripe and calls its API. + if ( + settings.paymentProvider === "stripe" && + settings.stripe.webhookEventsVersion !== STRIPE_WEBHOOK_EVENTS_VERSION + ) { + addPendingWork(reconcileStripeWebhook()); + } }; /** Route the request and attach security headers / flash cookie clearing */ diff --git a/src/features/public/qr-book.ts b/src/features/public/qr-book.ts index 7c743bf770..95b56af35d 100644 --- a/src/features/public/qr-book.ts +++ b/src/features/public/qr-book.ts @@ -14,6 +14,7 @@ import { buildTicketListing } from "#shared/booking/model.ts"; import { capacityDateFor } from "#shared/capacity-rules.ts"; import { getBookableStartDates } from "#shared/dates.ts"; import { getGroupRemainingForListing } from "#shared/db/attendees/capacity/groups.ts"; +import { stagedSessionCreator } from "#shared/db/checkout-stages.ts"; import { isHiddenPackageMember } from "#shared/db/groups.ts"; import { getActiveHolidays } from "#shared/db/holidays.ts"; import { @@ -122,7 +123,7 @@ const skipToCheckout = ( return runCheckoutFlow( `qr-book listing=${listing.id}`, request, - intent, + stagedSessionCreator(intent), (msg, status) => htmlResponse(qrBookCheckoutErrorPage(listing.slug, msg), status), ); diff --git a/src/features/public/ticket-payment.ts b/src/features/public/ticket-payment.ts index bbfa43fb72..0993a305bf 100644 --- a/src/features/public/ticket-payment.ts +++ b/src/features/public/ticket-payment.ts @@ -42,7 +42,6 @@ import { getBookableStartDates, isBookingRangeValid } from "#shared/dates.ts"; import { getPublicStatusId } from "#shared/db/attendee-statuses.ts"; import type { ChildAllocation, - CreateAttendeeResult, LineBooking, ListingBooking, } from "#shared/db/attendee-types.ts"; @@ -52,8 +51,8 @@ import { createBookingAtomic, } from "#shared/db/attendees/api.ts"; import { getDatelessGroupRemaining } from "#shared/db/attendees/capacity/groups.ts"; -import { ensureAllBookings } from "#shared/db/attendees/create.ts"; import { expandChildAllocations } from "#shared/db/attendees/order-parents.ts"; +import { stagedSessionCreator } from "#shared/db/checkout-stages.ts"; import { getGroupIdsByListingIds, getHiddenPackageMemberIds, @@ -137,7 +136,10 @@ export const withPaymentProvider = async ( export const runCheckoutFlow = ( label: string, request: Request, - intent: CheckoutIntent, + createSession: ( + provider: Awaited> & object, + baseUrl: string, + ) => Promise, onError: (msg: string, status: number) => Response, ): Promise => { logDebug("Payment", `Starting ${label} checkout`); @@ -156,7 +158,7 @@ export const runCheckoutFlow = ( logDebug("Payment", `Using provider=${provider.type} for ${label}`); const baseUrl = getBaseUrl(request); logDebug("Payment", `Creating checkout session baseUrl=${baseUrl}`); - const result = await provider.createCheckoutSession(intent, baseUrl); + const result = await createSession(provider, baseUrl); if (result && "error" in result) { logDebug( "Payment", @@ -235,7 +237,7 @@ export const handlePaymentFlow = ( runCheckoutFlow( `ticket items=${intent.items.length}`, request, - intent, + stagedSessionCreator(intent), (msg) => errorRedirect(ticketPageUrl(ctx), msg), ); @@ -431,7 +433,7 @@ export const createFreeReservation = async ({ // (hasDuplicateBookingSlot) permits same-child/different-parent rows because // it keys on (listingId, date, parentListingId, packageGroupId). The // expanded list replaces the summed list for the create call; - // ensureAllBookings' count uses the expanded length. Each child row is then + // The atomic create count uses the expanded length. Each child row is then // stamped with its parent's package when the parent books through exactly // one path, so a bundle's add-ons group under it. const expanded: ListingBooking[] = @@ -462,19 +464,23 @@ export const createFreeReservation = async ({ ledgerOrder !== null || modifierUsages.length > 0 ? await createBookingAtomic( input, - await bookingBatchPlan(modifierUsages, { - eventId: crypto.randomUUID(), - occurredAt: nowIso(), - pricedOrder: ledgerOrder ?? EMPTY_PRICED_ORDER, - }), + await bookingBatchPlan( + null, + modifierUsages, + { + eventId: crypto.randomUUID(), + occurredAt: nowIso(), + pricedOrder: ledgerOrder ?? EMPTY_PRICED_ORDER, + }, + null, + ), ) : await createAttendeeAtomic(input); if (result === "sold-out") { return { error: MODIFIER_SOLD_OUT_MESSAGE, success: false }; } - const check = await ensureAllBookings(result, finalBookings.length, "public"); - if (!check.ok) { + if (!result.success) { // A package order must never name a member in the capacity error — a hidden // package would leak the listing it concealed. Omit the name (generic // message) for a package; a non-package order keeps its first listing's name. @@ -482,15 +488,11 @@ export const createFreeReservation = async ({ ? "" : listingById.get(items[0]!.listingId)!.name; return { - error: formatAtomicError(check.reason, errorName), + error: formatAtomicError(result.reason, errorName), success: false, }; } - // ensureAllBookings's ok check guarantees result.success here. - const { attendees } = result as Extract< - CreateAttendeeResult, - { success: true } - >; + const { attendees } = result; const entries: EmailEntry[] = attendees.map((attendee) => ({ attendee, diff --git a/src/features/settings-bundles.ts b/src/features/settings-bundles.ts index 5205561793..6596e0b19d 100644 --- a/src/features/settings-bundles.ts +++ b/src/features/settings-bundles.ts @@ -50,6 +50,7 @@ const INFRA_SETTINGS: readonly string[] = [ CONFIG_KEYS.HEADER_IMAGE_URL, CONFIG_KEYS.PAYMENT_PROVIDER, CONFIG_KEYS.SQUARE_SANDBOX, + CONFIG_KEYS.STRIPE_WEBHOOK_EVENTS_VERSION, CONFIG_KEYS.LAST_PRUNED_PAYMENTS, CONFIG_KEYS.LAST_PRUNED_SESSIONS, CONFIG_KEYS.LAST_PRUNED_SUMUP, diff --git a/src/features/tickets/token-utils.ts b/src/features/tickets/token-utils.ts index 3470904649..dc91ae44a2 100644 --- a/src/features/tickets/token-utils.ts +++ b/src/features/tickets/token-utils.ts @@ -143,6 +143,9 @@ const buildAttendeeView = ( name: "", package_group_id: booking.package_group_id, payment_id: "", + // The public ticket view never shows the admin mid-payment indicator, so the + // flag is not looked up here (like the blanked PII fields above and below). + pending_checkout: 0, phone: "", pii_blob: base.pii_blob, price_paid: String(booking.price_paid), diff --git a/src/locales/en/admin.json b/src/locales/en/admin.json index 619bdfd407..d0d764ece6 100644 --- a/src/locales/en/admin.json +++ b/src/locales/en/admin.json @@ -110,6 +110,11 @@ "admin.attendee_table.check_in": "Check in", "admin.attendee_table.refunded_badge": "Refunded", "admin.attendee_table.no_quantity": "No quantity", + "admin.attendee_table.payment_in_progress": "Payment in progress", + "admin.merge.pending_checkout": "One of these records is mid-payment. It cannot be merged until the payment finishes or expires.", + "admin.merge.held_cash": "One of these records holds a payment that was not refunded. Refund it before merging.", + "admin.listing.held_cash_delete": "A booking on this listing holds a payment that was not refunded. Refund it before you delete this listing.", + "admin.listing.pending_checkout_delete": "This listing has a booking mid-payment, so it cannot be deleted yet. Hide it to stop new bookings, then delete it once the payment finishes or expires.", "admin.attendee_table.servicing": "Service", "admin.checkin.title": "Check-in", "admin.checkin.heading": "Check-in", diff --git a/src/locales/en/attendees.json b/src/locales/en/attendees.json index 9ce465c387..63e4eeb103 100644 --- a/src/locales/en/attendees.json +++ b/src/locales/en/attendees.json @@ -148,6 +148,11 @@ "attendee_form.error_no_lines": "Book at least one listing before saving", "attendee_form.error_built_site_no_qty": "Unassign the built site from this booking before marking it no quantity.", "attendee_form.error_paid_no_qty": "Refund this booking's payment before marking it no quantity.", + "attendee_form.error_pending_checkout": "This booking is mid-payment. Wait for the payment to finish or expire.", + "attendee_form.pending_checkout_banner": "Checkout pending: the customer may still be paying. This record is locked until the payment finishes or expires.", + "attendee_form.deleted_listing": "Deleted listing", + "attendee_form.deleted_listing_locked": "This record has a booking for a deleted listing. That line is locked and cannot be changed.", + "attendee_form.error_held_cash": "This booking holds a payment that was not refunded. Refund it before you delete this record.", "attendee_form.error_capacity": "Not enough spots available for one or more selected listings — nothing was saved. Please review the quantities and try again.", "attendee_form.saved_added": "Added {value}", "attendee_form.saved_updated": "Updated {value}", diff --git a/src/locales/en/csv.json b/src/locales/en/csv.json index ddd5fbfbed..30e9a13f2f 100644 --- a/src/locales/en/csv.json +++ b/src/locales/en/csv.json @@ -7,6 +7,7 @@ "csv.col.transaction_id": "Transaction ID", "csv.col.ticket_token": "Ticket Token", "csv.col.ticket_url": "Ticket URL", + "csv.col.checkout_pending": "Checkout pending", "csv.col.type": "Type", "csv.col.attendees": "Attendees", "csv.col.capacity": "Capacity", diff --git a/src/locales/en/public.json b/src/locales/en/public.json index 373b67a3bb..a2a94ce044 100644 --- a/src/locales/en/public.json +++ b/src/locales/en/public.json @@ -15,6 +15,7 @@ "public.buy_now": "Buy now", "public.unavailable": "Unavailable", "public.currently_unavailable": "Currently Unavailable", + "public.checkout_unavailable": "Sorry, this can no longer be booked. It may have sold out or been taken off sale while you were booking.", "public.date_filter.label": "Have a date in mind? Check availability:", "public.date_filter.search": "Search", "public.date_filter.show_all": "Show all", diff --git a/src/shared/accounting/rows.ts b/src/shared/accounting/rows.ts index af2255224a..9c3f8e956d 100644 --- a/src/shared/accounting/rows.ts +++ b/src/shared/accounting/rows.ts @@ -140,12 +140,35 @@ const renderInsert = ( }; }; +/** Render several transfer plans as one INSERT statement. The column plan stays + * shared with every single-row writer; only the VALUES groups are combined. */ +const renderManyInsert = (rows: LegColumn[][]): SqlStatement => { + if (rows.length === 0) { + throw new Error("Cannot build a transfer insert with no rows"); + } + const first = rows[0]!; + return { + args: rows.flatMap((columns) => columns.flatMap((column) => column.args)), + sql: `INSERT INTO transfers (${first.map((column) => column.col).join(", ")}) VALUES ${rows + .map((columns) => `(${columns.map((column) => column.expr).join(", ")})`) + .join(", ")}`, + }; +}; + /** Build the INSERT for one transfer. `recordedAt` is the write-time clock. */ export const insertStatement = ( t: TransferInput, recordedAt: string, ): SqlStatement => renderInsert(legColumns(t, recordedAt)); +/** Build one INSERT containing every leg of an event. The caller guarantees a + * non-empty event and performs conflict checks before applying the statement. */ +export const insertManyStatement = ( + inputs: TransferInput[], + recordedAt: string, +): SqlStatement => + renderManyInsert(inputs.map((input) => legColumns(input, recordedAt))); + /** * Rewrite a built transfer INSERT as `INSERT OR IGNORE`, so a leg whose unique * `reference` is already stored is dropped rather than raising a constraint diff --git a/src/shared/attendee-table-rows.ts b/src/shared/attendee-table-rows.ts index 44ef4468e7..2a37cc1829 100644 --- a/src/shared/attendee-table-rows.ts +++ b/src/shared/attendee-table-rows.ts @@ -6,7 +6,8 @@ * these helpers only reshape them. */ -import { sumOf } from "#fp"; +import { sumOf, unique } from "#fp"; +import { t } from "#i18n"; import type { AttendeeRowListing, AttendeeTableRow, @@ -32,10 +33,9 @@ export const attendeeLineRow = ( * each row's listings keep that order, so the Listings cell matches the * listings page. Quantities sum across the attendee's lines — the order's * total tickets. Attendee order follows first appearance in `attendees`. - * A line whose listing is absent from `orderedListings` (the LEFT-JOIN - * `listing_id = 0` broken-linkage sentinel) is dropped, and an attendee with - * no surviving listing is omitted entirely — exactly which lines the - * per-line tables skipped before grouping. + * A positive listing id absent from `orderedListings` is a retained booking for + * a deleted listing and stays visible as an unlinked placeholder. The LEFT-JOIN + * `listing_id = 0` broken-linkage sentinel is still dropped. */ export const groupAttendeeRows = ( attendees: DisplayAttendee[], @@ -44,9 +44,20 @@ export const groupAttendeeRows = ( const rows: AttendeeTableRow[] = []; for (const lines of Map.groupBy(attendees, (a) => a.id).values()) { const bookedIds = new Set(lines.map((line) => line.listing_id)); - const listings = orderedListings + const liveListings = orderedListings .filter((listing) => bookedIds.has(listing.id)) .map((listing) => ({ id: listing.id, name: listing.name })); + const liveIds = new Set(orderedListings.map((listing) => listing.id)); + const deletedListings = unique( + lines + .map((line) => line.listing_id) + .filter((listingId) => listingId > 0 && !liveIds.has(listingId)), + ).map((id) => ({ + deleted: true as const, + id, + name: t("attendee_form.deleted_listing"), + })); + const listings = [...liveListings, ...deletedListings]; if (listings.length === 0) continue; const quantity = sumOf((line: DisplayAttendee) => line.quantity)(lines); rows.push({ attendee: { ...lines[0]!, quantity }, listings }); diff --git a/src/shared/booking-lines.ts b/src/shared/booking-lines.ts index 41ce5e83ec..bf62be5950 100644 --- a/src/shared/booking-lines.ts +++ b/src/shared/booking-lines.ts @@ -24,15 +24,18 @@ import { soleParentPackageIds, stampChildRowPackages, } from "#shared/booking/page-packages.ts"; +import { lineGroupId } from "#shared/booking/signed-metadata.ts"; import { type BookingDateSource, bookingDateFields, } from "#shared/booking-date-fields.ts"; import type { ChildAllocation, + LineBooking, ListingBooking, } from "#shared/db/attendee-types.ts"; import { expandChildAllocations } from "#shared/db/attendees/order-parents.ts"; +import type { BookingItem } from "#shared/payments.ts"; /** One signed paid line: the booking slot (listing id + package path), the * quantity, the listing's date/duration facts, and the line's charged @@ -55,6 +58,30 @@ export type SignedPaidLine = { pricePaid?: number; }; +/** A canonical signed booking row: cart metadata plus the resolved quantity, + * date, and duration required by staging, activation, and capacity checks. */ +export type CanonicalBooking = ListingBooking & LineBooking; + +/** The listing and package path that identify one signed booking line. */ +export const bookingSlot = ( + item: BookingItem, +): Pick => ({ + listingId: item.e, + packageGroupId: lineGroupId(item) ?? 0, +}); + +/** Shape one signed item for the canonical booking-row builder. */ +export const signedPaidLine = ( + item: BookingItem, + listing: BookingDateSource, + pricePaid?: number, +): SignedPaidLine => ({ + ...bookingSlot(item), + listing, + ...(pricePaid !== undefined ? { pricePaid } : {}), + quantity: item.q, +}); + /** Input to {@link orderBookings}: the signed paid lines plus the order's * shared date, day count, and per-(child, parent) allocations. */ export type OrderBookingsInput = { @@ -87,9 +114,11 @@ export type OrderBookingsInput = { * versus omitted distinction, and one shared order token across every * expanded row. */ -export const orderBookings = (input: OrderBookingsInput): ListingBooking[] => { +export const orderBookings = ( + input: OrderBookingsInput, +): CanonicalBooking[] => { const { lines, date, dayCount, allocations } = input; - const rawBookings: ListingBooking[] = lines.map((line) => ({ + const rawBookings: CanonicalBooking[] = lines.map((line) => ({ listingId: line.listingId, packageGroupId: line.packageGroupId, quantity: line.quantity, @@ -107,3 +136,15 @@ export const orderBookings = (input: OrderBookingsInput): ListingBooking[] => { soleParentPackageIds(lines), ); }; + +/** Build canonical rows from an intent's shared order fields and shaped lines. */ +export const orderBookingsFor = ( + intent: Omit, + lines: SignedPaidLine[], +): CanonicalBooking[] => + orderBookings({ + allocations: intent.allocations, + date: intent.date, + dayCount: intent.dayCount, + lines, + }); diff --git a/src/shared/booking.ts b/src/shared/booking.ts index fef9ff2f30..78a8ac689b 100644 --- a/src/shared/booking.ts +++ b/src/shared/booking.ts @@ -14,7 +14,8 @@ import { createAttendeeAtomic, hasAvailableSpots, } from "#shared/db/attendees/api.ts"; -import type { LedgerPoster } from "#shared/db/attendees/create.ts"; +import type { LedgerPoster } from "#shared/db/attendees/create-batch.ts"; +import { createStagedCheckout } from "#shared/db/checkout-stages.ts"; import { nowIso } from "#shared/now.ts"; import { singleListingAnswerIds } from "#shared/payment-helpers.ts"; import { checkoutItem, getActivePaymentProvider } from "#shared/payments.ts"; @@ -93,7 +94,8 @@ export const processBooking = async ( const provider = (await getActivePaymentProvider())!; const unitPrice = customUnitPrice ?? listing.unit_price; - const result = await provider.createCheckoutSession( + const result = await createStagedCheckout( + provider, { ...contact, date, diff --git a/src/shared/checkout-complete.ts b/src/shared/checkout-complete.ts index e62fa801d6..bd0b4bf70f 100644 --- a/src/shared/checkout-complete.ts +++ b/src/shared/checkout-complete.ts @@ -20,7 +20,7 @@ import type { PricedOrder } from "#shared/checkout-pricing.ts"; import type { BookingBatchPlan, LedgerPoster, -} from "#shared/db/attendees/create.ts"; +} from "#shared/db/attendees/create-batch.ts"; import { type TxScope, update } from "#shared/db/client.ts"; import type { ModifierUsage } from "#shared/db/modifier-usage.ts"; import { nowIso } from "#shared/now.ts"; @@ -40,14 +40,15 @@ const BATCH_LEG_ATTENDEE_PLACEHOLDER = 1; * the same batch. The paid path keys `eventId` on its payment session id, so the * legs are attendee-id-independent and can be built before the attendee exists. */ export const bookingBatchPlan = async ( + attendeeId: number | null, usages: ModifierUsage[], ledger: { pricedOrder: PricedOrder; occurredAt: string; eventId: string }, - finalize?: { paymentReference: string; sessionId: string }, + finalize: { paymentReference: string; sessionId: string } | null, ): Promise => ({ - ...(finalize !== undefined ? { finalize } : {}), + finalize, legs: await mapBooking( bookingFactsFromOrder(ledger.pricedOrder, { - attendeeId: BATCH_LEG_ATTENDEE_PLACEHOLDER, + attendeeId: attendeeId ?? BATCH_LEG_ATTENDEE_PLACEHOLDER, eventId: ledger.eventId, occurredAt: ledger.occurredAt, }), diff --git a/src/shared/columns/attendee-columns.ts b/src/shared/columns/attendee-columns.ts index 470a674323..bd8feac87d 100644 --- a/src/shared/columns/attendee-columns.ts +++ b/src/shared/columns/attendee-columns.ts @@ -47,12 +47,15 @@ const listings: AttendeeCol = { cell: (row) => { const fullList = escapeHtml(row.listings.map((l) => l.name).join(", ")); const links = row.listings - .map((l) => `${escapeHtml(l.name)}`) + .map((l) => + l.deleted + ? escapeHtml(l.name) + : `${escapeHtml(l.name)}`, + ) .join(", "); return `${links}`; }, - description: - "The row's listings in display order, each linked to its detail page", + description: "The row's listings in display order, with live listings linked", isHtml: true, label: "Listings", }; @@ -175,12 +178,18 @@ const ticket: AttendeeCol = { // A no-quantity sentinel row has no live customer ticket: /t renders the // attendee's OTHER real bookings (or 404s for an all-ghost attendee), so a // link here would let staff copy a customer-facing URL that doesn't match this - // row's cancelled/interested listing. Show the indicator instead. + // row's cancelled/interested listing. Show the indicator instead — and for a + // mid-payment staged row, say the payment is in flight rather than the + // "No quantity" wording that invites an operator edit. cell: (row, opts) => isServicing(row.attendee.kind) ? `${t("admin.attendee_table.servicing")}` : !hasTicketQuantity(row.attendee) - ? `${t("admin.attendee_table.no_quantity")}` + ? `${t( + row.attendee.pending_checkout + ? "admin.attendee_table.payment_in_progress" + : "admin.attendee_table.no_quantity", + )}` : `${row.attendee.ticket_token}`, description: "Clickable ticket token link", isHtml: true, diff --git a/src/shared/db/attendee-types.ts b/src/shared/db/attendee-types.ts index 436211cd85..bc89bf85ab 100644 --- a/src/shared/db/attendee-types.ts +++ b/src/shared/db/attendee-types.ts @@ -38,7 +38,6 @@ export type EncryptedAttendeeData = { /** Input for encrypting attendee fields */ export type EncryptInput = ContactInfo & { paymentId: string; - pricePaid: number; }; /** Input for building an Attendee result from an insert */ @@ -65,6 +64,15 @@ export type CreateAttendeeResult = | { success: true; attendees: Attendee[] } | { success: false; reason: "capacity_exceeded" | "encryption_error" }; +/** The success arm of an atomic create, for call sites whose input makes + * failure impossible: a quantity-0 overbook insert has no capacity gate, and + * if the PII can't encrypt the whole system is already broken — we don't + * defend against that. */ +export type CreateAttendeeSuccess = Extract< + CreateAttendeeResult, + { success: true } +>; + /** A single listing booking within a multi-listing attendee creation */ export type ListingBooking = { listingId: number; @@ -114,6 +122,9 @@ export type AttendeeInput = ContactFields & { * checkout path can never be silently left uncounted; the admin manual-add * paths pass "admin" explicitly. */ source?: BookingSource; + /** Exact ticket token to encrypt and index. Paid recovery prepares this once; + * other create paths omit it and receive a fresh token. */ + ticketToken?: string; }; /** Row from listing_attendees — per-listing booking data */ @@ -157,6 +168,8 @@ export type AttendeeWithBookings = { export type BatchAvailabilityItem = { listingId: number; quantity: number; + /** Per-line date. When omitted, the batch's shared date is used. */ + date?: string | null; /** Duration in days for multi-day bookings (defaults to 1 when absent). */ durationDays?: number; }; diff --git a/src/shared/db/attendees/activate.ts b/src/shared/db/attendees/activate.ts new file mode 100644 index 0000000000..2fa445bebe --- /dev/null +++ b/src/shared/db/attendees/activate.ts @@ -0,0 +1,276 @@ +import { insertManyStatement } from "#shared/accounting/rows.ts"; +import { assertPostable } from "#shared/accounting/store.ts"; +import type { CanonicalBooking } from "#shared/booking-lines.ts"; +import type { AttendeeInput } from "#shared/db/attendee-types.ts"; +import { + type ActivationFailure, + findStageProblem, + refusalReason, +} from "#shared/db/attendees/activation-refusal.ts"; +import { + bookingCapacityFields, + currentBatchCapacityCondition, +} from "#shared/db/attendees/capacity/checks.ts"; +import { bookingStartAt } from "#shared/db/attendees/capacity/range.ts"; +import { + bookingWriteGuard, + type FinalizedBookingBatchPlan, + isBookingWriteGuardFailure, + isRequiredColumnFailure, +} from "#shared/db/attendees/create-batch.ts"; +import { annotateOrderParents } from "#shared/db/attendees/order-parents.ts"; +import { + attendeeEncryptionInput, + encryptAttendeeFields, +} from "#shared/db/attendees/pii.ts"; +import { + andConditions, + executeBatchWithResults, + type SqlStatement, + update, +} from "#shared/db/client.ts"; +import { + allModifiersInStockCondition, + usageBatchInsert, +} from "#shared/db/modifier-usage.ts"; +import { batchFinalizeStatement } from "#shared/db/payment-finalize.ts"; +import { UNRESOLVED_RESERVATION } from "#shared/db/processed-payments.ts"; +import { nowIso } from "#shared/now.ts"; + +type ActivationLine = { + listingId: number; + packageGroupId: number; + parentListingId: number; + quantity: number; + startAt: string | null; +}; + +const activationLines = (bookings: CanonicalBooking[]): ActivationLine[] => + bookings.map((booking) => ({ + listingId: booking.listingId, + packageGroupId: booking.packageGroupId ?? 0, + parentListingId: booking.parentListingId ?? 0, + quantity: booking.quantity, + startAt: bookingStartAt(booking), + })); + +const expectedLineMatch = ( + bookingAlias: string, + expectedAlias: string, +): string => + `${bookingAlias}.listing_id = json_extract(${expectedAlias}.value, '$.listingId') + AND ${bookingAlias}.start_at IS json_extract(${expectedAlias}.value, '$.startAt') + AND ${bookingAlias}.parent_listing_id = json_extract(${expectedAlias}.value, '$.parentListingId') + AND ${bookingAlias}.package_group_id = json_extract(${expectedAlias}.value, '$.packageGroupId')`; + +/** The staged rows must still be exactly the signed order. Count plus one match + * per expected slot is complete because booking slots are unique. */ +const stagedLinesCondition = ( + attendeeId: number, + linesJson: string, + lineCount: number, +): SqlStatement => ({ + args: [attendeeId, lineCount, linesJson, attendeeId], + sql: `(SELECT COUNT(*) FROM listing_attendees AS booking + WHERE booking.attendee_id = ?) = ? + AND NOT EXISTS ( + SELECT 1 FROM json_each(?) AS expected + WHERE NOT EXISTS ( + SELECT 1 FROM listing_attendees AS booking + WHERE booking.attendee_id = ? AND booking.quantity = 0 + AND ${expectedLineMatch("booking", "expected")} + ) + )`, +}); + +/** Claim the right to activate without changing state yet. Capacity, modifier + * stock, and stage shape are one verdict under the batch's write lock. Stage and + * payment ownership are established by the preceding guarded statements. */ +const activationVerdict = ( + sessionId: string, + attendeeId: number, + linesJson: string, + lineCount: number, + capacity: SqlStatement, + plan: FinalizedBookingBatchPlan, +): SqlStatement => { + const allowed = andConditions([ + stagedLinesCondition(attendeeId, linesJson, lineCount), + capacity, + allModifiersInStockCondition(plan.usages), + ]); + return { + args: [sessionId, attendeeId, ...allowed.args], + sql: `UPDATE checkout_stages SET state = state + WHERE payment_session_id = ? AND attendee_id = ? AND state = 'pending' + AND ${allowed.sql}`, + }; +}; + +/** Turn all staged rows live in one statement. The preceding verdict established + * the exact row set and holds the write lock until this batch commits. */ +const activateLinesStatement = ( + attendeeId: number, + linesJson: string, +): SqlStatement => ({ + args: [linesJson, attendeeId], + sql: `WITH expected AS (SELECT value FROM json_each(?)) + UPDATE listing_attendees AS booking + SET quantity = ( + SELECT CAST(json_extract(expected.value, '$.quantity') AS INTEGER) + FROM expected + WHERE ${expectedLineMatch("booking", "expected")} + ) + WHERE booking.attendee_id = ? AND booking.quantity = 0 + AND EXISTS ( + SELECT 1 FROM expected + WHERE ${expectedLineMatch("booking", "expected")} + )`, +}); + +/** Abort the batch if payment finalization did not affect its one reserved row. + * A separate constraint signature keeps this invariant failure distinct from a + * refused activation verdict. */ +const paymentFinalizeGuard = (sessionId: string): SqlStatement => ({ + args: [`${sessionId}:activation-finalize-guard`], + sql: `INSERT INTO processed_payments (payment_session_id, processed_at) + SELECT ?, NULL WHERE changes() != 1`, +}); + +/** Establish this exact pending stage as the batch's activation target. */ +const stageOwnershipStatement = ( + sessionId: string, + attendeeId: number, +): SqlStatement => ({ + args: [sessionId, attendeeId], + sql: `UPDATE checkout_stages SET state = state + WHERE payment_session_id = ? AND attendee_id = ? AND state = 'pending'`, +}); + +/** Abort when the stage ownership statement did not match exactly one row. */ +const stageOwnershipGuard = (sessionId: string): SqlStatement => ({ + args: [`${sessionId}:activation-stage-guard`], + sql: `INSERT INTO checkout_stages + (payment_session_id, attendee_id, provider, ticket_tokens, state, created_at) + SELECT ?, NULL, '', '', '', '' WHERE changes() != 1`, +}); + +/** Establish the unresolved processed-payment claim under the same write lock. */ +const paymentOwnershipStatement = (sessionId: string): SqlStatement => ({ + args: [sessionId], + sql: `UPDATE processed_payments SET processed_at = processed_at + WHERE payment_session_id = ? AND ${UNRESOLVED_RESERVATION}`, +}); + +const isPaymentFinalizeGuardFailure = isRequiredColumnFailure( + "processed_payments.processed_at", +); + +const isStageOwnershipGuardFailure = isRequiredColumnFailure( + "checkout_stages.attendee_id", +); + +/** Claim every staged booking row and complete its money state in one fixed-size + * batch. A missed capacity or modifier guard rolls everything back. */ +export const activateStagedBooking = async ( + sessionId: string, + attendeeId: number, + ticketToken: string, + input: Omit & { + bookings: CanonicalBooking[]; + paymentId: string; + }, + plan: FinalizedBookingBatchPlan, +): Promise< + { success: true } | { reason: ActivationFailure; success: false } +> => { + const bookings = await annotateOrderParents(input.bookings); + const stageProblem = await findStageProblem(attendeeId, bookings); + if (stageProblem) return { reason: stageProblem, success: false }; + const capacity = await currentBatchCapacityCondition( + bookings.map((booking) => ({ + date: booking.date, + ...bookingCapacityFields(booking), + })), + undefined, + { primary: true }, + ); + if (!capacity) return { reason: "capacity_exceeded", success: false }; + const encryptionInput = attendeeEncryptionInput( + { ...input, bookings }, + input.paymentId, + ); + const enc = await encryptAttendeeFields(encryptionInput, ticketToken); + if (!enc) throw new Error("Could not encrypt staged attendee"); + assertPostable(plan.legs); + const linesJson = JSON.stringify(activationLines(bookings)); + const piiUpdate = update( + "attendees", + { pii_blob: enc.encryptedPiiBlob }, + { id: attendeeId }, + ); + const finalize = await batchFinalizeStatement( + sessionId, + "?", + attendeeId, + { args: [], sql: "1 = 1" }, + plan.finalize.paymentReference, + ticketToken, + ); + const statements: SqlStatement[] = [ + stageOwnershipStatement(sessionId, attendeeId), + stageOwnershipGuard(sessionId), + paymentOwnershipStatement(sessionId), + paymentFinalizeGuard(sessionId), + activationVerdict( + sessionId, + attendeeId, + linesJson, + bookings.length, + capacity, + plan, + ), + bookingWriteGuard(), + piiUpdate, + activateLinesStatement(attendeeId, linesJson), + ...(plan.usages.length > 0 + ? [usageBatchInsert(plan.usages, attendeeId)] + : []), + ...(plan.legs.length > 0 + ? [ + insertManyStatement(plan.legs, nowIso()), + { + args: [plan.legs[0]!.eventGroup, attendeeId], + sql: `UPDATE listing_attendees SET ledger_event_group = ? + WHERE attendee_id = ?`, + }, + ] + : []), + finalize, + paymentFinalizeGuard(sessionId), + { + args: [sessionId, attendeeId], + sql: `UPDATE checkout_stages SET state = 'booked', ticket_tokens = '' + WHERE payment_session_id = ? AND attendee_id = ? AND state = 'pending'`, + }, + ]; + + try { + await executeBatchWithResults(statements); + return { success: true }; + } catch (error) { + if (isPaymentFinalizeGuardFailure(error)) { + throw new Error(`Payment session ${sessionId} was not finalized`); + } + if (isStageOwnershipGuardFailure(error)) { + throw new Error( + `Checkout stage for session ${sessionId} was not this attendee's pending stage at activation (attendee ${attendeeId})`, + ); + } + if (!isBookingWriteGuardFailure(error)) throw error; + return { + reason: await refusalReason(attendeeId, bookings, plan.usages), + success: false, + }; + } +}; diff --git a/src/shared/db/attendees/activation-refusal.ts b/src/shared/db/attendees/activation-refusal.ts new file mode 100644 index 0000000000..79568c4aa9 --- /dev/null +++ b/src/shared/db/attendees/activation-refusal.ts @@ -0,0 +1,88 @@ +/** + * Why couldn't a staged booking be activated? The activation claim's UPDATE + * only touches rows that are still the exact staged order (quantity 0, same + * lines) with capacity and modifier stock to spare, so a refusal means one of + * those gave way. This module answers which one — checked against the database + * directly, so the answer stays right even when the change happened while the + * claim was in flight. + */ + +/* jscpd:ignore-start */ +import type { CanonicalBooking } from "#shared/booking-lines.ts"; +import { loadExistingLines } from "#shared/db/attendees/atomic-update.ts"; +import { bookingSlotKey } from "#shared/db/attendees/booking-slot.ts"; +import { bookingStartAt } from "#shared/db/attendees/capacity/range.ts"; +import { + anyModifierSoldOut, + type ModifierUsage, +} from "#shared/db/modifier-usage.ts"; +/* jscpd:ignore-end */ + +export type ActivationFailure = + | "capacity_exceeded" + | "sold_out" + | "stage_active" + | "stage_mismatch"; + +const expectedLineKey = (booking: CanonicalBooking): string => + bookingSlotKey( + booking.listingId, + bookingStartAt(booking), + booking.parentListingId, + booking.packageGroupId, + ); + +/** + * Check the staged rows are still exactly the order we are about to claim. + * Returns the problem — "stage_active" when any row was already given a real + * quantity outside this payment (the rows may be a live booking, so the caller + * must hold the money for the operator rather than refund or re-claim), or + * "stage_mismatch" when the booking lines changed (still all quantity 0, so + * nothing is live and a refund is safe) — or null when the stage is untouched. + * + * Zero staged rows is an IMPOSSIBLE state, so it throws rather than returning a + * reason: a pending stage and its rows are only ever removed together (every + * attendee delete cascades the stage; the prune deletes both), admin + * edits/merges/deletes are blocked while pending, and a listing can't be + * deleted while it has a pending checkout. No rows here means a deletion path + * skipped the stage cascade — a bug to surface, not to book fresh around. + */ +export const findStageProblem = async ( + attendeeId: number, + bookings: CanonicalBooking[], +): Promise | null> => { + const existing = await loadExistingLines(attendeeId); + if (existing.length === 0) { + throw new Error( + `Staged attendee ${attendeeId} has no booking rows at activation — a pending checkout stage must never be deleted while its payment can still land`, + ); + } + if (existing.some(({ booking }) => booking.quantity !== 0)) { + return "stage_active"; + } + const expected = bookings.map(expectedLineKey).toSorted(); + const actual = existing.map(({ key }) => key).toSorted(); + return JSON.stringify(actual) === JSON.stringify(expected) + ? null + : "stage_mismatch"; +}; + +/** + * The failure to report for a refused activation claim: a stage problem first + * — a row flipped live or the lines changed, possibly while the claim was in + * flight — then sold-out modifier stock, then plain capacity. A stage problem + * must never classify as a refundable capacity failure: "stage_active" rows + * may be a live booking whose money the operator must decide. + */ +export const refusalReason = async ( + attendeeId: number, + bookings: CanonicalBooking[], + usages: ModifierUsage[], +): Promise => { + const stageProblem = await findStageProblem(attendeeId, bookings); + if (stageProblem) return stageProblem; + return (await anyModifierSoldOut(usages)) ? "sold_out" : "capacity_exceeded"; +}; diff --git a/src/shared/db/attendees/api.ts b/src/shared/db/attendees/api.ts index 10244f1412..9d80a6bf1b 100644 --- a/src/shared/db/attendees/api.ts +++ b/src/shared/db/attendees/api.ts @@ -45,7 +45,7 @@ export const createAttendeeAtomic = ( ): Promise => attendeesApi.createAttendeeAtomic(...args); /** Wrapper for test mocking - delegates to attendeesApi at runtime. Creates a - * booking and posts its ledger legs as one batch (the fast checkout path). */ + * booking and posts its ledger legs in one all-or-nothing batch. */ export const createBookingAtomic = ( ...args: Parameters ): ReturnType => diff --git a/src/shared/db/attendees/atomic-update.ts b/src/shared/db/attendees/atomic-update.ts index ed3a04fe0a..ae87f77020 100644 --- a/src/shared/db/attendees/atomic-update.ts +++ b/src/shared/db/attendees/atomic-update.ts @@ -41,7 +41,7 @@ import { import { dateToStartEnd } from "#shared/db/attendees/capacity/range.ts"; import { LISTING_ATTENDEE_ROW_COLS } from "#shared/db/attendees/queries.ts"; import { buildCapacityCondition } from "#shared/db/capacity.ts"; -import { executeBatchWithResults, queryAll } from "#shared/db/client.ts"; +import { executeBatchWithResults, queryAllPrimary } from "#shared/db/client.ts"; /** * A guard statement that aborts the whole write batch when the immediately @@ -119,17 +119,20 @@ export type ExistingLine = { booking: ListingAttendeeRow; }; -/** Read all current listing_attendees rows for an attendee, with line keys. */ +/** Read all current listing_attendees rows for an attendee, with line keys. + * Pinned to the primary: every caller is a read-modify-write (atomic edits, + * servicing edits, staged activation) or feeds one, and a replica lagging a + * just-staged write would misread the lines as missing or changed. */ export const loadExistingLines = async ( attendeeId: number, ): Promise => { - const rows = await queryAll( + const bookings = await queryAllPrimary( `SELECT ${LISTING_ATTENDEE_ROW_COLS} FROM listing_attendees WHERE attendee_id = ? ORDER BY start_at, listing_id`, [attendeeId], ); - return rows.map((booking) => ({ + return bookings.map((booking) => ({ booking, key: lineKeyFromBooking(booking), })); diff --git a/src/shared/db/attendees/capacity/checks.ts b/src/shared/db/attendees/capacity/checks.ts index ed67f91900..f6c3f5af28 100644 --- a/src/shared/db/attendees/capacity/checks.ts +++ b/src/shared/db/attendees/capacity/checks.ts @@ -1,5 +1,6 @@ import type { InValue } from "@libsql/client"; -import { byId, map } from "#fp"; +import { assert } from "@std/assert"; +import { byId, groupBy, unique } from "#fp"; import { capacityDateFor, countsPerDate } from "#shared/capacity-rules.ts"; import type { BatchAvailabilityItem, @@ -7,16 +8,34 @@ import type { ListingBooking, } from "#shared/db/attendee-types.ts"; import { - buildBatchCapacitySql, + buildBatchCapacityCondition, buildCapacityCondition, type CapacityBucket, } from "#shared/db/capacity.ts"; -import { inPlaceholders, queryAll, queryOne } from "#shared/db/client.ts"; -import { getGroupIdsByListingIds } from "#shared/db/groups.ts"; +import { + andConditions, + inPlaceholders, + type PrimaryReadOptions, + queryBatch, + queryBatchPrimary, + queryOne, + resultRows, + type SqlStatement, +} from "#shared/db/client.ts"; +import { ascending } from "#shared/types.ts"; import { useListingById } from "./listing.ts"; import { dateToStartEnd, expandDailyRange } from "./range.ts"; import type { ListingCapacityRow } from "./types.ts"; +/** The quantity, listing, and duration fields used by capacity checks. */ +export const bookingCapacityFields = ( + booking: Pick, +): Omit => ({ + durationDays: booking.durationDays, + listingId: booking.listingId, + quantity: booking.quantity, +}); + /** Build an INSERT into listing_attendees, capacity-checked by default. */ export const buildCapacityCheckedInsert = ( booking: ListingBooking, @@ -121,8 +140,9 @@ const addDemandToBucket = ( item: BatchAvailabilityItem, date: string | null | undefined, ): void => { - if (countsPerDate(listing.listing_type) && date) { - for (const day of expandDailyRange(date, item.durationDays ?? 1)) { + const bookingDate = item.date === undefined ? date : item.date; + if (countsPerDate(listing.listing_type) && bookingDate) { + for (const day of expandDailyRange(bookingDate, item.durationDays ?? 1)) { bucket.perDay.set(day, (bucket.perDay.get(day) ?? 0) + item.quantity); } } else { @@ -130,6 +150,116 @@ const addDemandToBucket = ( } }; +type GroupMembershipRow = { group_id: number; listing_id: number }; + +const membershipFor = ( + membership: ReadonlyMap, + listingId: number, +): number[] => { + const groupIds = membership.get(listingId); + assert(groupIds, `Missing capacity membership for listing ${listingId}`); + return groupIds; +}; + +/** Refuse a following write if listing types or group memberships changed after + * the primary-pinned capacity snapshot was prepared. */ +const capacityMetadataCondition = ( + listings: ListingCapacityRow[], + membership: ReadonlyMap, +): SqlStatement => ({ + args: [ + JSON.stringify( + listings.map((listing) => ({ + groupIds: membershipFor(membership, listing.id).toSorted(ascending), + id: listing.id, + listingType: listing.listing_type, + })), + ), + ], + sql: `NOT EXISTS ( + SELECT 1 + FROM json_each(?) AS expectedListing + LEFT JOIN listings AS listing + ON listing.id = json_extract(expectedListing.value, '$.id') + WHERE listing.id IS NULL + OR listing.listing_type != json_extract(expectedListing.value, '$.listingType') + OR EXISTS ( + SELECT 1 FROM group_listings AS liveGroup + WHERE liveGroup.listing_id = json_extract(expectedListing.value, '$.id') + AND liveGroup.group_id NOT IN ( + SELECT CAST(expectedGroup.value AS INTEGER) + FROM json_each(expectedListing.value, '$.groupIds') AS expectedGroup + ) + ) + OR EXISTS ( + SELECT 1 + FROM json_each(expectedListing.value, '$.groupIds') AS expectedGroup + WHERE NOT EXISTS ( + SELECT 1 FROM group_listings AS liveGroup + WHERE liveGroup.listing_id = json_extract(expectedListing.value, '$.id') + AND liveGroup.group_id = CAST(expectedGroup.value AS INTEGER) + ) + ) + )`, +}); + +/** Prepare the live whole-cart condition, optionally pinned to the primary for + * a condition that gates a following write. */ +export const currentBatchCapacityCondition = async ( + items: BatchAvailabilityItem[], + date?: string | null, + { primary = false }: PrimaryReadOptions = {}, +): Promise => { + if (items.length === 0) return { args: [], sql: "1 = 1" }; + if (items.some((item) => item.quantity < 0)) return null; + const listingIds = unique(items.map((item) => item.listingId)); + const placeholders = inPlaceholders(listingIds); + const [listingResult, membershipResult] = await (primary + ? queryBatchPrimary + : queryBatch)([ + { + args: listingIds, + sql: `SELECT listing.id, listing.max_attendees, listing.listing_type, + listing.booked_quantity AS attendee_count + FROM listings AS listing + WHERE listing.id IN (${placeholders})`, + }, + { + args: listingIds, + sql: `SELECT groupListing.listing_id, groupListing.group_id + FROM group_listings AS groupListing + WHERE groupListing.listing_id IN (${placeholders})`, + }, + ]); + const listings = resultRows(listingResult!); + if (listings.length !== listingIds.length) return null; + const membershipRows = resultRows(membershipResult!); + const membershipRowsByListing = groupBy( + membershipRows, + (row) => row.listing_id, + ); + const membership = new Map( + listingIds.map((listingId) => [ + listingId, + (membershipRowsByListing.get(listingId) ?? []).map((row) => row.group_id), + ]), + ); + const context: BatchAvailabilityContext = { + date, + items, + listingsById: byId(listings), + }; + return andConditions([ + buildBatchCapacityCondition( + aggregateDemand(context, (listing) => [listing.id]), + aggregateDemand(context, (_listing, item) => + membershipFor(membership, item.listingId), + ), + ), + capacityMetadataCondition(listings, membership), + ]); +}; + interface BatchAvailabilityContext { date: string | null | undefined; items: BatchAvailabilityItem[]; @@ -160,28 +290,11 @@ export const checkBatchAvailabilityImpl = async ( date?: string | null, ): Promise => { if (items.length === 0) return true; - if (items.some((item) => item.quantity < 0)) return false; - const listingIds = map((item: BatchAvailabilityItem) => item.listingId)( - items, - ); - const listingRows = await queryAll( - `SELECT listing.id, listing.max_attendees, listing.listing_type, - listing.booked_quantity as attendee_count - FROM listings AS listing - WHERE listing.id IN (${inPlaceholders(listingIds)})`, - listingIds, - ); - const listingsById = byId(listingRows); - if (items.some((item) => !listingsById.has(item.listingId))) return false; - - const membership = await getGroupIdsByListingIds(listingIds); - const context: BatchAvailabilityContext = { date, items, listingsById }; - const listingDemand = aggregateDemand(context, (listing) => [listing.id]); - const groupDemand = aggregateDemand( - context, - (_listing, item) => membership.get(item.listingId) ?? [], - ); - const { sql, args } = buildBatchCapacitySql(listingDemand, groupDemand); - const row = (await queryOne<{ fits: number }>(sql, args))!; + const condition = await currentBatchCapacityCondition(items, date); + if (!condition) return false; + const row = (await queryOne<{ fits: number }>( + `SELECT CASE WHEN ${condition.sql} THEN 1 ELSE 0 END AS fits`, + condition.args, + ))!; return row.fits === 1; }; diff --git a/src/shared/db/attendees/capacity/range.ts b/src/shared/db/attendees/capacity/range.ts index 7b1069ea6d..a2402d2091 100644 --- a/src/shared/db/attendees/capacity/range.ts +++ b/src/shared/db/attendees/capacity/range.ts @@ -1,5 +1,6 @@ import { filter, map, pipe, sumOf } from "#fp"; import { addDays } from "#shared/dates.ts"; +import type { LineBooking } from "#shared/db/attendee-types.ts"; import { dateToRange } from "#shared/db/capacity.ts"; import { normalizeDurationDays } from "#shared/types.ts"; @@ -13,6 +14,11 @@ export const dateToStartEnd = ( return { endAt: range.endAt, startAt: range.startAt }; }; +/** The stored start timestamp for a booking line. */ +export const bookingStartAt = ( + booking: Pick, +): string | null => dateToStartEnd(booking.date).startAt; + /** Expand a daily-listing range into individual day strings. */ export const expandDailyRange = ( date: string, diff --git a/src/shared/db/attendees/create-batch.ts b/src/shared/db/attendees/create-batch.ts new file mode 100644 index 0000000000..aa9d294168 --- /dev/null +++ b/src/shared/db/attendees/create-batch.ts @@ -0,0 +1,229 @@ +import { bookingLegBatchInsert } from "#shared/accounting/rows.ts"; +import { assertPostable } from "#shared/accounting/store.ts"; +import type { EncryptedAttendeeData } from "#shared/db/attendee-types.ts"; +import { + andConditions, + executeBatchWithResults, + inPlaceholders, + type SqlStatement, + type TxScope, + withTransaction, +} from "#shared/db/client.ts"; +import { + allModifiersInStockCondition, + type ModifierUsage, + usageInsert, +} from "#shared/db/modifier-usage.ts"; +import { batchFinalizeStatement } from "#shared/db/payment-finalize.ts"; +import type { TransferInput } from "#shared/ledger/types.ts"; +import { nowIso } from "#shared/now.ts"; + +export type PreparedWrite = { + enc: EncryptedAttendeeData; + attendeeInsert: SqlStatement; + bookingStatements: SqlStatement[]; +}; + +export type WriteOutcome = { + insertId: number | bigint | undefined; +}; + +/** Posts ledger legs inside the attendee transaction when a caller cannot + * prepare the whole operation as a batch. */ +export type LedgerPoster = (tx: TxScope, attendeeId: number) => Promise; + +export type BookingBatchPlan = { + usages: ModifierUsage[]; + legs: TransferInput[]; + finalize: { paymentReference: string; sessionId: string } | null; +}; + +export type FinalizedBookingBatchPlan = Omit & { + finalize: { paymentReference: string; sessionId: string }; +}; + +/** The new attendee id inside a batch. last_insert_rowid() cannot be used after + * later inserts, while ticket_token_index uniquely identifies this row. */ +export const ATTENDEE_BY_TOKEN_SQL = + "(SELECT MAX(id) FROM attendees WHERE ticket_token_index = ?)"; + +/** Abort a whole libsql batch when the preceding guarded booking did not land. + * The deliberate NOT NULL failure rolls the transaction back; no compensating + * delete is needed and no partial attendee becomes visible. */ +export const bookingWriteGuard = (expectedChanges = 1): SqlStatement => ({ + args: [expectedChanges], + sql: `INSERT INTO listing_attendees (listing_id, attendee_id, quantity) + SELECT NULL, NULL, 1 WHERE changes() != ?`, +}); + +const MODIFIER_WRITE_GUARD: SqlStatement = { + args: [], + sql: `INSERT INTO modifier_usages + (modifier_id, attendee_id, quantity, amount_applied, created) + SELECT NULL, NULL, 1, 0, '' WHERE changes() = 0`, +}; + +/** Match the deliberate missing-column failure used to abort a guarded batch. */ +export const isRequiredColumnFailure = + (column: string) => + (error: unknown): boolean => + error instanceof Error && + error.message.includes(`NOT NULL constraint failed: ${column}`); + +export const isBookingWriteGuardFailure: (error: unknown) => boolean = + isRequiredColumnFailure("listing_attendees.listing_id"); + +const guardedBookingStatements = ( + bookingStatements: SqlStatement[], +): SqlStatement[] => + bookingStatements.flatMap((statement) => [statement, bookingWriteGuard()]); + +const runAtomicBatch = async ( + prepared: PreparedWrite, + leading: SqlStatement[] = [], + trailing: SqlStatement[] = [], +): Promise => { + try { + const results = await executeBatchWithResults([ + prepared.attendeeInsert, + ...leading, + ...guardedBookingStatements(prepared.bookingStatements), + ...trailing, + { + args: [prepared.enc.ticketTokenIndex], + sql: "SELECT id FROM attendees WHERE ticket_token_index = ?", + }, + ]); + const attendeeResult = results[results.length - 1]!; + return { + insertId: Number(attendeeResult.rows[0]!.id), + }; + } catch (error) { + if (isBookingWriteGuardFailure(error)) return null; + throw error; + } +}; + +class IncompleteBooking extends Error {} + +/** Create an attendee and run a callback in one interactive transaction. */ +export const writeWithLedger = ( + prepared: PreparedWrite, + postLedger: LedgerPoster, +): Promise => + withTransaction(async (tx) => { + const insertId = (await tx.execute(prepared.attendeeInsert)) + .lastInsertRowid; + for (const statement of prepared.bookingStatements) { + if ((await tx.execute(statement)).rowsAffected === 0) { + throw new IncompleteBooking(); + } + } + await postLedger(tx, Number(insertId)); + return { insertId }; + }).catch((error) => { + if (error instanceof IncompleteBooking) return null; + throw error; + }); + +/** Create an attendee and all booking rows, rolling the transaction back when + * any requested row cannot land. */ +export const writeAsBatch = ( + prepared: PreparedWrite, +): Promise => runAtomicBatch(prepared); + +class ModifierStockFailure extends Error {} + +export const isModifierStockFailure = ( + error: unknown, +): error is ModifierStockFailure => error instanceof ModifierStockFailure; + +const noExistingLedgerCondition = (legs: TransferInput[]): SqlStatement => { + if (legs.length === 0) return { args: [], sql: "1 = 1" }; + const eventGroup = legs[0]!.eventGroup; + const references = legs.map((leg) => leg.reference); + return { + args: [eventGroup, ...references], + sql: `NOT EXISTS (SELECT 1 FROM transfers WHERE event_group = ?) + AND NOT EXISTS (SELECT 1 FROM transfers WHERE reference IN (${inPlaceholders( + references, + )}))`, + }; +}; + +export const bookingBatchCondition = (plan: BookingBatchPlan): SqlStatement => + andConditions([ + allModifiersInStockCondition(plan.usages), + noExistingLedgerCondition(plan.legs), + ]); + +/** Create the booking, consume modifier stock, post ledger legs, and optionally + * finalize its payment in one transaction. */ +export const writeAsLedgerBatch = async ( + prepared: PreparedWrite, + plan: BookingBatchPlan, +): Promise => { + assertPostable(plan.legs); + const recordedAt = nowIso(); + const tokenIndex = prepared.enc.ticketTokenIndex; + const always = { args: [], sql: "1 = 1" }; + const modifierCondition = allModifiersInStockCondition(plan.usages); + const modifierCheckStatements: SqlStatement[] = + plan.usages.length > 0 + ? [ + { + args: [tokenIndex, ...modifierCondition.args], + sql: `UPDATE attendees SET id = id + WHERE ticket_token_index = ? AND ${modifierCondition.sql}`, + }, + MODIFIER_WRITE_GUARD, + ] + : []; + const usageStatements = plan.usages.map((usage) => + usageInsert(usage, ATTENDEE_BY_TOKEN_SQL, [tokenIndex], always), + ); + const legStatements = plan.legs.map((leg) => + bookingLegBatchInsert( + leg, + recordedAt, + ATTENDEE_BY_TOKEN_SQL, + tokenIndex, + always, + ), + ); + const eventGroupStatements: SqlStatement[] = + plan.legs.length > 0 + ? [ + { + args: [plan.legs[0]!.eventGroup, tokenIndex], + sql: `UPDATE listing_attendees SET ledger_event_group = ? + WHERE attendee_id = ${ATTENDEE_BY_TOKEN_SQL}`, + }, + ] + : []; + const finalizeStatements: SqlStatement[] = plan.finalize + ? [ + await batchFinalizeStatement( + plan.finalize.sessionId, + ATTENDEE_BY_TOKEN_SQL, + tokenIndex, + always, + plan.finalize.paymentReference, + prepared.enc.ticketToken, + ), + ] + : []; + try { + return await runAtomicBatch(prepared, modifierCheckStatements, [ + ...usageStatements, + ...legStatements, + ...eventGroupStatements, + ...finalizeStatements, + ]); + } catch (error) { + if (isRequiredColumnFailure("modifier_usages.modifier_id")(error)) { + throw new ModifierStockFailure(); + } + throw error; + } +}; diff --git a/src/shared/db/attendees/create.ts b/src/shared/db/attendees/create.ts index 03ded7ea37..d2ad8c7e6b 100644 --- a/src/shared/db/attendees/create.ts +++ b/src/shared/db/attendees/create.ts @@ -2,10 +2,8 @@ * Atomic attendee creation across one or more listing bookings. */ -/* jscpd:ignore-start */ import type { InValue } from "@libsql/client"; -import { bookingLegBatchInsert } from "#shared/accounting/rows.ts"; -import { assertPostable } from "#shared/accounting/store.ts"; +import { generateTicketToken } from "#shared/crypto/utils.ts"; import { addDays } from "#shared/dates.ts"; import type { AttendeeInput, @@ -15,87 +13,30 @@ import type { } from "#shared/db/attendee-types.ts"; import { hasDuplicateBookingSlot } from "#shared/db/attendees/booking-slot.ts"; import { buildCapacityCheckedInsert } from "#shared/db/attendees/capacity/checks.ts"; -import { deleteAttendee } from "#shared/db/attendees/delete.ts"; +import { + ATTENDEE_BY_TOKEN_SQL, + type BookingBatchPlan, + bookingBatchCondition, + isModifierStockFailure, + type LedgerPoster, + type PreparedWrite, + type WriteOutcome, + writeAsBatch, + writeAsLedgerBatch, + writeWithLedger, +} from "#shared/db/attendees/create-batch.ts"; import { ATTENDEE_KIND } from "#shared/db/attendees/kind.ts"; import { annotateOrderParents } from "#shared/db/attendees/order-parents.ts"; import { + ATTENDEE_ECHO_DEFAULTS, + attendeeContactInfo, + attendeeEncryptionInput, contactFields, encryptAttendeeFields, } from "#shared/db/attendees/pii.ts"; -import { - executeBatchWithResults, - inPlaceholders, - insert, - type SqlStatement, - type TxScope, - withTransaction, -} from "#shared/db/client.ts"; -import { - hashEmail, - hashPhone, - recordVisit, - unrecordVisit, -} from "#shared/db/contact-preferences.ts"; -import { - type BookingSource, - recordBooking, - unrecordBooking, -} from "#shared/db/contact-tokens.ts"; -import { - allModifiersInStockCondition, - anyModifierSoldOut, - type ModifierUsage, - usageInsert, -} from "#shared/db/modifier-usage.ts"; -import { batchFinalizeStatement } from "#shared/db/payment-finalize.ts"; -import type { TransferInput } from "#shared/ledger/types.ts"; -import { bestEffort } from "#shared/logger.ts"; -import { namedError } from "#shared/named-error.ts"; -import { nowIso } from "#shared/now.ts"; -import { - type Attendee, - type ContactInfo, - normalizeDurationDays, -} from "#shared/types.ts"; -/* jscpd:ignore-end */ - -/** - * Enforce all-or-nothing semantics on a (greedy) create result. - * - * `createAttendeeAtomic` fulfils bookings greedily: it returns success as - * long as at least one booking was created. Callers that need every - * requested line to succeed pass the expected count here; if the result is - * short, the partially-created attendee is rolled back and a failure reason - * is returned. Shared by the public checkout flow, the webhook flow, and the - * admin manual-add form so the "no half-saved attendee" rule lives in one - * place. - */ -export const ensureAllBookings = async ( - result: CreateAttendeeResult, - expectedCount: number, - source: BookingSource, -): Promise< - { ok: true } | { ok: false; reason: "capacity_exceeded" | "encryption_error" } -> => { - if (result.success && result.attendees.length >= expectedCount) { - return { ok: true }; - } - if (result.success && result.attendees.length > 0) { - const attendee = result.attendees[0]!; - await deleteAttendee(attendee.id); - // The greedy create already recorded a visit + booking for this contact; - // undo it now that the order is being rolled back. Best-effort: callers - // such as the paid webhook refund after this returns, so a contact-stats - // write failure must not escape here and skip the refund. - await bestEffort("reverseOrderActivity on partial rollback", () => - reverseOrderActivity(attendee.email, attendee.phone, source), - ); - } - return { - ok: false, - reason: result.success ? "capacity_exceeded" : result.reason, - }; -}; +import { insert, type SqlStatement } from "#shared/db/client.ts"; +import { recordOrderActivity } from "#shared/db/contact-tokens.ts"; +import { type Attendee, normalizeDurationDays } from "#shared/types.ts"; /** Order-level fields shared by every booking in one atomic create. */ type AttendeeOrderFields = { @@ -120,23 +61,12 @@ export const buildAttendeeInsert = ( ticket_token_index: enc.ticketTokenIndex, }); -/** The order's contact PII, with the optional fields defaulted to "" — the one - * shape both the encrypt step and the per-booking result read from an input. */ -const contactInfoFromInput = (input: AttendeeInput): ContactInfo => ({ - address: input.address ?? "", - email: input.email, - name: input.name, - phone: input.phone ?? "", - special_instructions: input.special_instructions ?? "", -}); - /** Build plain Attendee object from insert result */ const buildAttendeeResult = (input: BuildAttendeeInput): Attendee => ({ id: Number(input.insertId), listing_id: input.listingId, ...contactFields(input), - attachment_downloads: 0, - checked_in: false, + ...ATTENDEE_ECHO_DEFAULTS, created: input.created, date: input.date, // Exclusive end (start + duration), matching SUBSTR(end_at) on the read path. @@ -145,205 +75,16 @@ const buildAttendeeResult = (input: BuildAttendeeInput): Attendee => ({ ? addDays(input.date, normalizeDurationDays(input.durationDays ?? 1)) : null, kind: input.kind, - // A booking never pins a location; only an admin can, later. - lat: "", - lng: "", package_group_id: input.packageGroupId, payment_id: input.paymentId, - pii_blob: "", price_paid: String(input.pricePaid), quantity: input.quantity, - refunded: false, remaining_balance: input.remainingBalance, - split_logistics_agents: false, status_id: input.statusId, ticket_token: input.ticketToken, ticket_token_index: input.ticketTokenIndex, }); -/** Collect the contact-identity hashes for an order (email and/or phone). */ -const orderContactHashes = ( - email: unknown, - phone: unknown, -): Promise => { - const hashes: Promise[] = []; - if (typeof email === "string" && email.trim()) { - hashes.push(hashEmail(email)); - } - if (typeof phone === "string" && phone.trim()) { - hashes.push(hashPhone(phone)); - } - return Promise.all(hashes); -}; - -/** Run one per-contact effect against every contact identity on an order, so - * recording and its exact reverse share one iteration. */ -const forEachOrderContact = - (perContact: (hash: string) => Promise) => - async (email: unknown, phone: unknown): Promise => { - await Promise.all((await orderContactHashes(email, phone)).map(perContact)); - }; - -/** Record a visit + source-tagged booking (with the attendee's ticket token, so - * the contact's encrypted token list gains this booking) for every contact on - * an order. */ -const recordOrderActivity = ( - email: unknown, - phone: unknown, - source: BookingSource, - ticketToken: string, -): Promise => - forEachOrderContact(async (hash) => { - await recordVisit(hash); - await recordBooking(hash, source, ticketToken); - })(email, phone); - -/** Reverse {@link recordOrderActivity}'s counts when an order is rolled back - * after the greedy create already recorded it (partial booking, post-payment - * refund). The token entry is left for the read side to filter — the rolled-back - * attendee is deleted, so its token resolves to nothing. */ -export const reverseOrderActivity = ( - email: unknown, - phone: unknown, - source: BookingSource, -): Promise => - forEachOrderContact(async (hash) => { - await unrecordVisit(hash); - await unrecordBooking(hash, source); - })(email, phone); - -/** Per-booking success flags and the new attendee row id (always set in - * practice — an INSERT returns its rowid). */ -type WriteOutcome = { flags: boolean[]; insertId: number | bigint | undefined }; - -/** Posts the ledger legs for a created attendee inside the same transaction, so - * a booking and its legs commit or roll back together. The id is only known - * after the attendee insert, so it is passed in. */ -export type LedgerPoster = (tx: TxScope, attendeeId: number) => Promise; - -/** Thrown to roll the transaction back when no booking could be created (the - * ledger-posting path has no final cleanup DELETE; it just rolls back). */ -class NoBookingsCreated extends namedError("NoBookingsCreated") {} - -/** Remove the just-inserted attendee when none of its capacity-checked booking - * inserts landed a row (the batch path's all-failed cleanup). */ -const cleanupDeleteStatement = (ticketTokenIndex: InValue): SqlStatement => ({ - args: [ticketTokenIndex, ticketTokenIndex], - sql: `DELETE FROM attendees WHERE id = ( - SELECT MAX(id) FROM attendees WHERE ticket_token_index = ? - ) AND NOT EXISTS ( - SELECT 1 FROM listing_attendees WHERE attendee_id = ( - SELECT MAX(id) FROM attendees WHERE ticket_token_index = ? - ) - )`, -}); - -/** - * Run one ACID batch whose statements are, in order: the attendee INSERT, the - * `bookingCount` capacity-checked booking INSERTs, then any number of follow-up - * statements (cleanup, and — for the ledger batch — modifier/leg/finalize). The - * per-booking landed flags come from results 1..bookingCount; null is returned - * when none landed (the attendee was cleaned up). The single place the attendee/ - * booking batch result decoding lives, shared by the plain and ledger batches. */ -const runAttendeeBatch = async ( - statements: SqlStatement[], - bookingCount: number, -): Promise => { - const batchResults = await executeBatchWithResults(statements); - const flags = Array.from( - { length: bookingCount }, - (_, i) => batchResults[i + 1]!.rowsAffected > 0, - ); - return flags.some(Boolean) - ? { flags, insertId: batchResults[0]!.lastInsertRowid } - : null; -}; - -/** The fast path: one ACID batch (attendee, bookings, all-failed cleanup). - * Returns null when no booking landed (the attendee was cleaned up). */ -const writeAsBatch = ( - attendeeInsert: SqlStatement, - bookingStatements: SqlStatement[], - ticketTokenIndex: InValue, -): Promise => - runAttendeeBatch( - [ - attendeeInsert, - ...bookingStatements, - cleanupDeleteStatement(ticketTokenIndex), - ], - bookingStatements.length, - ); - -/** The ledger path: an interactive transaction so the ledger legs commit - * atomically with the attendee and bookings. This path is all-or-nothing — - * the legs describe the whole order, so if any booking fails its capacity check - * the transaction rolls back and null is returned (the caller refunds), rather - * than posting legs for listings that were not booked. */ -const writeWithLedger = ( - attendeeInsert: SqlStatement, - bookingStatements: SqlStatement[], - postLedger: LedgerPoster, -): Promise => - withTransaction(async (tx) => { - const insertId = (await tx.execute(attendeeInsert)).lastInsertRowid; - const flags: boolean[] = []; - for (const statement of bookingStatements) { - flags.push((await tx.execute(statement)).rowsAffected > 0); - } - if (!flags.every(Boolean)) throw new NoBookingsCreated(); - await postLedger(tx, Number(insertId)); - return { flags, insertId }; - }).catch((error) => { - if (error instanceof NoBookingsCreated) return null; - throw error; - }); - -/** - * The attendee-id subquery used everywhere a freshly-inserted attendee's id must - * be referenced later in the SAME batch (its booking links, its ledger legs, the - * finalize). last_insert_rowid() can't be used — it shifts after each INSERT in - * the batch — and ticket_token_index is unique, so MAX(id) for that token is - * this attendee. The single `?` binds the token index. */ -export const ATTENDEE_BY_TOKEN_SQL = - "(SELECT MAX(id) FROM attendees WHERE ticket_token_index = ?)"; - -/** SQL gate that holds only once every one of the order's `expectedCount` - * bookings has landed, so the ledger legs / finalize apply on full success and - * are skipped on a partial booking (cleaned up afterwards). */ -const allBookingsLandedGuard = ( - ticketTokenIndex: InValue, - expectedCount: number, -): SqlStatement => ({ - args: [ticketTokenIndex, expectedCount], - sql: `(SELECT COUNT(*) FROM listing_attendees WHERE attendee_id = ${ATTENDEE_BY_TOKEN_SQL}) = ?`, -}); - -/** What a prepared write needs in hand before touching the database. */ -type PreparedWrite = { - enc: EncryptedAttendeeData; - attendeeInsert: SqlStatement; - bookingStatements: SqlStatement[]; -}; - -const andConditions = (conditions: SqlStatement[]): SqlStatement => ({ - args: conditions.flatMap((condition) => condition.args), - sql: conditions.map((condition) => `(${condition.sql})`).join(" AND "), -}); - -const noExistingLedgerCondition = (legs: TransferInput[]): SqlStatement => { - if (legs.length === 0) return { args: [], sql: "1 = 1" }; - const eventGroup = legs[0]!.eventGroup; - const references = legs.map((leg) => leg.reference); - return { - args: [eventGroup, ...references], - sql: `NOT EXISTS (SELECT 1 FROM transfers WHERE event_group = ?) - AND NOT EXISTS (SELECT 1 FROM transfers WHERE reference IN (${inPlaceholders( - references, - )}))`, - }; -}; - /** * Validate the order and encrypt the attendee, returning the attendee INSERT and * the capacity-checked booking INSERTs — or a failure reason. `extraCondition` is @@ -388,12 +129,10 @@ const prepareAttendeeWrite = async ( // packages, so there is no order-level value to apply here. const bookings = await annotateOrderParents(rawBookings); - // Use first booking's pricePaid for encryption (PII blob is shared) - const enc = await encryptAttendeeFields({ - ...contactInfoFromInput(input), - paymentId, - pricePaid: bookings[0]!.pricePaid ?? 0, - }); + const enc = await encryptAttendeeFields( + attendeeEncryptionInput({ ...input, bookings }, paymentId), + input.ticketToken ?? generateTicketToken(), + ); if (!enc) { return { failure: { reason: "encryption_error", success: false }, @@ -448,31 +187,27 @@ const finishAttendeeWrite = async ( enc: EncryptedAttendeeData, ): Promise => { const { bookings, source = "public" } = input; - const contactInfo = contactInfoFromInput(input); - const successfulBookings: Attendee[] = bookings.flatMap((booking, i) => - written.flags[i] - ? [ - buildAttendeeResult({ - insertId: written.insertId, - listingId: booking.listingId, - ...contactInfo, - created: enc.created, - date: booking.date ?? null, - ...(booking.durationDays !== undefined - ? { durationDays: booking.durationDays } - : {}), - kind: input.kind ?? ATTENDEE_KIND, - packageGroupId: booking.packageGroupId ?? 0, - paymentId: input.paymentId ?? "", - pricePaid: booking.pricePaid ?? 0, - quantity: booking.quantity ?? 1, - remainingBalance: input.remainingBalance ?? 0, - statusId: input.statusId ?? null, - ticketToken: enc.ticketToken, - ticketTokenIndex: enc.ticketTokenIndex, - }), - ] - : [], + const contactInfo = attendeeContactInfo(input); + const successfulBookings: Attendee[] = bookings.map((booking) => + buildAttendeeResult({ + insertId: written.insertId, + listingId: booking.listingId, + ...contactInfo, + created: enc.created, + date: booking.date ?? null, + ...(booking.durationDays !== undefined + ? { durationDays: booking.durationDays } + : {}), + kind: input.kind ?? ATTENDEE_KIND, + packageGroupId: booking.packageGroupId ?? 0, + paymentId: input.paymentId ?? "", + pricePaid: booking.pricePaid ?? 0, + quantity: booking.quantity ?? 1, + remainingBalance: input.remainingBalance ?? 0, + statusId: input.statusId ?? null, + ticketToken: enc.ticketToken, + ticketTokenIndex: enc.ticketTokenIndex, + }), ); if (successfulBookings.some((b) => b.quantity > 0)) { await recordOrderActivity( @@ -512,11 +247,16 @@ const createWith = : strategy.noBooking(); }; +const capacityFailure = (): CreateAttendeeResult => ({ + reason: "capacity_exceeded", + success: false, +}); + /** * Atomically create an attendee linked to one or more listings. * 1. INSERT attendee (unconditional) * 2..N+1. For each booking: INSERT listing_attendees with capacity check - * 3. Clean up / roll back the attendee if ALL capacity checks failed + * 3. Abort and roll back the whole batch if any capacity check fails * Returns one Attendee per successful booking. When `postLedger` is given, the * write runs in one interactive transaction and the ledger legs are posted in * it, so the booking and its legs are all-or-nothing. @@ -526,126 +266,35 @@ export const createAttendeeAtomicImpl = ( postLedger?: LedgerPoster, ): Promise => createWith({ - noBooking: () => ({ reason: "capacity_exceeded", success: false }), - // Ledger path: an interactive transaction so the legs commit with the - // attendee/bookings (all-or-nothing). Plain path: one batch with an - // all-failed cleanup DELETE. - write: ({ attendeeInsert, bookingStatements, enc }) => + noBooking: capacityFailure, + write: (prepared) => postLedger - ? writeWithLedger(attendeeInsert, bookingStatements, postLedger) - : writeAsBatch(attendeeInsert, bookingStatements, enc.ticketTokenIndex), + ? writeWithLedger(prepared, postLedger) + : writeAsBatch(prepared), })(input); /** - * The ledger work to commit atomically with a booking, as DATA rather than a - * transaction callback — so the whole booking can be one libsql batch instead of - * a chatty interactive transaction. `legs` are the booking's ledger legs (built - * by mapBooking with a placeholder attendee id; their references/event group are - * attendee-id-independent, and the real id is spliced in by subquery at write - * time). `finalize`, when set, finalizes that payment session in the same batch - * as the attendee INSERT. */ -export type BookingBatchPlan = { - usages: ModifierUsage[]; - legs: TransferInput[]; - finalize?: { paymentReference: string; sessionId: string }; -}; - -/** - * Assemble and run the single batch for a booking that posts ledger legs: - * attendee INSERT, capacity- AND modifier-stock-guarded booking INSERTs, then — - * each gated on every booking having landed — the modifier-usage consumes, the - * `INSERT OR IGNORE` legs, the ledger_event_group stamp, the optional finalize, - * and finally the all-failed cleanup DELETE. One round-trip, one transaction: - * commits the whole booking or, when a booking can't land, leaves nothing the - * caller's all-or-nothing check won't clean up. Returns the flags + new id, or - * null when no booking landed. */ -const writeAsLedgerBatch = async ( - prepared: PreparedWrite, - plan: BookingBatchPlan, - expectedCount: number, -): Promise => { - const { attendeeInsert, bookingStatements, enc } = prepared; - const tokenIndex = enc.ticketTokenIndex; - const guard = allBookingsLandedGuard(tokenIndex, expectedCount); - - assertPostable(plan.legs); - const recordedAt = nowIso(); - const usageStatements = plan.usages.map((usage) => - usageInsert(usage, ATTENDEE_BY_TOKEN_SQL, [tokenIndex], guard), - ); - const legStatements = plan.legs.map((leg) => - bookingLegBatchInsert(leg, recordedAt, ATTENDEE_BY_TOKEN_SQL, tokenIndex, { - args: guard.args, - sql: guard.sql, - }), - ); - // Stamp the order's event group onto the booking rows so each row's amount-paid - // projection resolves exactly this booking's legs — only once all bookings landed. - const eventGroupUpdate: SqlStatement[] = - plan.legs.length > 0 - ? [ - { - args: [plan.legs[0]!.eventGroup, tokenIndex, ...guard.args], - sql: `UPDATE listing_attendees SET ledger_event_group = ? - WHERE attendee_id = ${ATTENDEE_BY_TOKEN_SQL} AND ${guard.sql}`, - }, - ] - : []; - const finalizeStatements: SqlStatement[] = plan.finalize - ? [ - await batchFinalizeStatement( - plan.finalize.sessionId, - ATTENDEE_BY_TOKEN_SQL, - tokenIndex, - guard, - plan.finalize.paymentReference, - ), - ] - : []; - - return runAttendeeBatch( - [ - attendeeInsert, - ...bookingStatements, - ...usageStatements, - ...legStatements, - ...eventGroupUpdate, - ...finalizeStatements, - cleanupDeleteStatement(tokenIndex), - ], - bookingStatements.length, - ); -}; - -/** - * Create a booking and post its ledger legs as ONE libsql batch — the fast path - * that replaces the interactive transaction for the paid/free checkout. The + * Create a booking and post its ledger legs as ONE libsql batch. The * booking, its modifier-stock consumes, its sale/payment legs, the booking-row * event-group stamp, and (when finalizing a paid session) the session finalize - * all commit or roll back together, in a single round-trip that never holds an - * interactive write transaction open against the primary. + * all commit or roll back together. Each booking insert is followed by a guard + * that aborts the batch on a miss, so no compensating deletes are needed. * * Returns `"sold-out"` when a chosen modifier had no stock left (the * stock-guarded booking insert lands no row), so the caller keeps a placeholder - * and refunds; otherwise the usual create result (a partial cart is the caller's - * all-or-nothing concern, via ensureAllBookings). */ -export const createBookingAtomic = ( + * and refunds; otherwise the usual all-or-nothing create result. */ +export const createBookingAtomic = async ( input: AttendeeInput, plan: BookingBatchPlan, -): Promise => - createWith({ - condition: andConditions([ - allModifiersInStockCondition(plan.usages), - noExistingLedgerCondition(plan.legs), - ]), - // No booking landed: tell capacity-full from a sold-out modifier so the - // caller shows the right reason (and keeps the right placeholder). - noBooking: async () => - (await anyModifierSoldOut(plan.usages)) - ? "sold-out" - : { reason: "capacity_exceeded", success: false }, - // expectedCount === one booking statement per booking, so it equals the - // prepared booking-statement count. - write: (prepared) => - writeAsLedgerBatch(prepared, plan, prepared.bookingStatements.length), - })(input); +): Promise => { + try { + return await createWith({ + condition: bookingBatchCondition(plan), + noBooking: capacityFailure, + write: (prepared) => writeAsLedgerBatch(prepared, plan), + })(input); + } catch (error) { + if (isModifierStockFailure(error)) return "sold-out"; + throw error; + } +}; diff --git a/src/shared/db/attendees/delete.ts b/src/shared/db/attendees/delete.ts index 275f7c088e..523e0bb8df 100644 --- a/src/shared/db/attendees/delete.ts +++ b/src/shared/db/attendees/delete.ts @@ -57,16 +57,26 @@ const DEPENDENT_ROW_TARGETS = [ { field: "attendee_id", table: "listing_attendees" }, { field: "attendee_id", table: "system_notes" }, { field: "servicing_attendee_id", table: "service_costs" }, + { field: "attendee_id", table: "checkout_stages" }, ] as const; -/** Build the common dependent-row deletes for one or many attendee ids. */ +/** Build the common dependent-row deletes for one or many attendee ids. + * Checkout stages are always last, so a caller whose id query reads that table + * can place related statements immediately before its guaranteed final delete. */ export const attendeeDependentDeleteStatements = ( attendeeIds: SqlStatement, -): SqlStatement[] => - DEPENDENT_ROW_TARGETS.map(({ field, table }) => ({ + beforeCheckoutStage: SqlStatement[] = [], +): SqlStatement[] => { + const deletes = DEPENDENT_ROW_TARGETS.map(({ field, table }) => ({ args: attendeeIds.args, sql: `DELETE FROM ${table} WHERE ${field} IN (${attendeeIds.sql})`, })); + return [ + ...deletes.slice(0, -1), + ...beforeCheckoutStage, + ...deletes.slice(-1), + ]; +}; /** Delete an attendee and all dependent data tied to the attendee record. */ const purgeAttendee = ( diff --git a/src/shared/db/attendees/order-parents.ts b/src/shared/db/attendees/order-parents.ts index c31af6e4de..751d1dd9d1 100644 --- a/src/shared/db/attendees/order-parents.ts +++ b/src/shared/db/attendees/order-parents.ts @@ -62,9 +62,9 @@ const inOrderParentByChild = async ( * `orderToken` and exact `parentListingId`. This function skips recomputation * for those to preserve true multi-parent provenance. */ -export const annotateOrderParents = async ( - bookings: ListingBooking[], -): Promise => { +export const annotateOrderParents = async ( + bookings: T[], +): Promise => { // Pre-expanded orders (expandChildAllocations path) already carry orderToken // and exact parentListingId. Skip the edge-based recomputation to preserve // true multi-parent provenance — recomputing would overwrite correct parent diff --git a/src/shared/db/attendees/pii.ts b/src/shared/db/attendees/pii.ts index beb1fffd6c..0ad1cd10a9 100644 --- a/src/shared/db/attendees/pii.ts +++ b/src/shared/db/attendees/pii.ts @@ -14,8 +14,8 @@ import { encryptWithOwnerKey, } from "#shared/crypto/keys.ts"; import type { OwnerKeyEncrypted } from "#shared/crypto/sealed.ts"; -import { generateTicketToken } from "#shared/crypto/utils.ts"; import type { + AttendeeInput, AttendeePii, EncryptedAttendeeData, EncryptInput, @@ -29,6 +29,26 @@ import type { ContactInfo, PiiBlob } from "#shared/types.ts"; /** Current PII blob schema version */ export const PII_BLOB_VERSION = 1; +/** The five stored contact fields from an attendee write, with the optional + * ones defaulted to "". Shared by the create result and the encryption input so + * both read the same contact shape from one place. */ +export const attendeeContactInfo = (input: AttendeeInput): ContactInfo => ({ + address: input.address ?? "", + email: input.email, + name: input.name, + phone: input.phone ?? "", + special_instructions: input.special_instructions ?? "", +}); + +/** Project one attendee write into the fields stored in its encrypted PII. */ +export const attendeeEncryptionInput = ( + input: AttendeeInput, + paymentId: string, +): EncryptInput => ({ + ...attendeeContactInfo(input), + paymentId, +}); + /** Build a PII blob JSON from contact fields. An unpinned latitude/longitude * ("") is left out of the JSON so blobs without a pin stay as small as before. */ export const buildPiiBlob = (info: AttendeePii): string => @@ -157,14 +177,31 @@ export const contactFields = ({ special_instructions, }); +/** The fields every freshly-built attendee echo carries with the same fixed + * values: a new booking is un-checked-in, un-refunded, and location-less, and + * the encrypted blob is never echoed back. Shared by the create result and the + * committed-rows recovery read, so the two projections cannot drift. */ +export const ATTENDEE_ECHO_DEFAULTS = { + attachment_downloads: 0, + checked_in: false, + lat: "", + lng: "", + // Staging inserts its stage row after the attendee, and no echo consumer + // reads the flag — the projected value comes from the read path. + pending_checkout: 0, + pii_blob: "", + refunded: false, + split_logistics_agents: false, +} as const; + /** Encrypt attendee fields into a PII blob, returning null if key not configured */ export const encryptAttendeeFields = async ( input: EncryptInput, + ticketToken: string, ): Promise => { const publicKeyJwk = settings.publicKey; if (!publicKeyJwk) return null; - const ticketToken = generateTicketToken(); // Bookings never carry a pinned location — lat/lng are admin-side only. const piiJson = buildPiiBlob({ ...contactFields(input), diff --git a/src/shared/db/attendees/queries.ts b/src/shared/db/attendees/queries.ts index 4888f5918c..708c291956 100644 --- a/src/shared/db/attendees/queries.ts +++ b/src/shared/db/attendees/queries.ts @@ -5,7 +5,11 @@ /* jscpd:ignore-start */ import type { InValue } from "@libsql/client"; import * as v from "valibot"; -import { saleLegPredicate } from "#shared/accounting/projection-sql.ts"; +import { ATTENDEE } from "#shared/accounting/accounts.ts"; +import { + accountBalanceSubquery, + saleLegPredicate, +} from "#shared/accounting/projection-sql.ts"; import { computeTicketTokenIndex } from "#shared/crypto/hashing.ts"; import type { OwnerKeyEncrypted } from "#shared/crypto/sealed.ts"; import { ATTENDEE_KIND } from "#shared/db/attendees/kind.ts"; @@ -23,7 +27,9 @@ import { } from "#shared/db/attendees/select.ts"; import { inPlaceholders, + primaryMatchingIdSet, queryAll, + queryAllPrimary, queryOne, rowExists, rowExistsForIdList, @@ -355,6 +361,54 @@ export const hasPaidLine = rowExistsForIdList( ) LIMIT 1`, ); +/** + * True when the attendee holds provider cash the ledger has not returned or + * applied to a sale — a POSITIVE account balance (money paid in, nothing owed + * against it). The only source is a stage_active conflict's held `payment` leg + * (a charge the operator must reconcile), which posts no `sale`, so + * {@link hasPaidLine} (sale-scoped) and the price_paid guard (0 without a sale) + * both miss it. Read from the live ledger, no decryption. A fully-paid booking + * nets to 0, a deposit is negative (owed), and a refunded record nets back to 0, + * so none of them read as held cash. + * + * Array in, set out — call with one id for the single case. Pinned to the + * primary: this gates writes (no-quantity edits, deletes, merges), and a replica + * lagging the just-posted held-payment leg would let a mutation slip through the + * guard — the same reason the pending-checkout guard reads the primary. + */ +export const attendeeIdsHoldingUnreturnedCash = primaryMatchingIdSet( + (placeholders) => + `SELECT attendee.id AS id FROM attendees AS attendee + WHERE attendee.id IN (${placeholders}) + AND ${accountBalanceSubquery(ATTENDEE, "attendee.id")} > 0`, +); + +/** Whether this one attendee holds unreturned conflict cash — single-id form of + * {@link attendeeIdsHoldingUnreturnedCash} for the no-quantity and delete guards. */ +export const attendeeHoldsUnreturnedCash = async ( + attendeeId: number, +): Promise => + (await attendeeIdsHoldingUnreturnedCash([attendeeId])).has(attendeeId); + +/** Whether any attendee booked on this listing holds unreturned conflict cash + * — the listing-scoped form of {@link attendeeIdsHoldingUnreturnedCash}, for + * the listing delete guard: deleting the listing cascades its booking rows, + * and the in-app refund needs an active booking line, so the delete would + * strand the held charge with no refund path. Primary-pinned like the other + * cash guards. */ +export const listingHoldsUnreturnedCash = async ( + listingId: number, +): Promise => { + const rows = await queryAllPrimary<{ attendee_id: number }>( + "SELECT DISTINCT attendee_id FROM listing_attendees WHERE listing_id = ?", + [listingId], + ); + const held = await attendeeIdsHoldingUnreturnedCash( + rows.map((row) => row.attendee_id), + ); + return held.size > 0; +}; + /** * The id of the attendee whose booking owns this ledger event group, or null * when none does. The single-batch booking write stamps every one of an diff --git a/src/shared/db/attendees/select.ts b/src/shared/db/attendees/select.ts index 16074ff425..7e79e267ab 100644 --- a/src/shared/db/attendees/select.ts +++ b/src/shared/db/attendees/select.ts @@ -168,6 +168,16 @@ const listingIntColumn = (join: AttendeeJoin, name: string): string => ? `COALESCE(listingAttendee.${name}, 0) as ${name}` : `listingAttendee.${name}`; +/** 0/1: does this attendee's checkout still have a pending stage? Correlated on + * `attendee.id` (always present, even under a LEFT join), and cheap — the + * checkout_stages table only ever holds in-flight and recently-resolved + * checkouts. Every table and export reads it, so a mid-payment record can say + * "payment in progress" instead of the "No quantity" sentinel wording. */ +const PENDING_CHECKOUT_SQL = + "(SELECT EXISTS(SELECT 1 FROM checkout_stages AS stage" + + ` WHERE stage.attendee_id = attendee.id AND stage.state = 'pending'))` + + " AS pending_checkout"; + /** Identity columns plus the cheap per-listing columns every caller reads. */ const coreColumns = (join: AttendeeJoin): string => [ @@ -178,6 +188,7 @@ const coreColumns = (join: AttendeeJoin): string => "attendee.pii_blob", "attendee.status_id", "attendee.split_logistics_agents", + PENDING_CHECKOUT_SQL, listingIntColumn(join, "listing_id"), "SUBSTR(listingAttendee.start_at, 1, 10) as date", listingIntColumn(join, "quantity"), diff --git a/src/shared/db/attendees/servicing.ts b/src/shared/db/attendees/servicing.ts index 21829c6e6f..46d6c8f54d 100644 --- a/src/shared/db/attendees/servicing.ts +++ b/src/shared/db/attendees/servicing.ts @@ -22,10 +22,7 @@ import { loadExistingLines, } from "#shared/db/attendees/atomic-update.ts"; import { dateToStartEnd } from "#shared/db/attendees/capacity/range.ts"; -import { - createAttendeeAtomicImpl as createAttendeeAtomic, - ensureAllBookings, -} from "#shared/db/attendees/create.ts"; +import { createAttendeeAtomicImpl as createAttendeeAtomic } from "#shared/db/attendees/create.ts"; import { deleteAttendee } from "#shared/db/attendees/delete.ts"; import { SERVICING_KIND } from "#shared/db/attendees/kind.ts"; import { @@ -168,47 +165,18 @@ const joinedListingNames = async (ids: number[]): Promise => { .join(", "); }; -/** The requested listing ids among `bookings` that did NOT land a booking row - * in create `result` — a multiset diff by listing id, so a listing requested - * twice and fulfilled only once is still named. When NOTHING landed at all - * (the create's own failure shape, `result.success === false` — e.g. a - * single-listing hold that didn't fit), every requested listing is named: - * there's no partial attendee to diff against, and for a single booking that - * IS the specific listing that failed. */ -const unfulfilledListingIds = ( - bookings: ListingBooking[], - result: CreateAttendeeResult, -): number[] => { - if (!result.success) return unique(bookings.map((b) => b.listingId)); - const remaining = new Map(); - for (const attendee of result.attendees) { - remaining.set( - attendee.listing_id, - (remaining.get(attendee.listing_id) ?? 0) + 1, - ); - } - const failed: number[] = []; - for (const booking of bookings) { - const have = remaining.get(booking.listingId) ?? 0; - if (have > 0) remaining.set(booking.listingId, have - 1); - else failed.push(booking.listingId); - } - return unique(failed); -}; - const ensureServicingCreateBookings = async ( result: CreateAttendeeResult, bookings: ListingBooking[], ): Promise> => { - const check = await ensureAllBookings(result, bookings.length, "admin"); - if (!check.ok) { + if (!result.success) { const names = - check.reason === "capacity_exceeded" - ? await joinedListingNames(unfulfilledListingIds(bookings, result)) + result.reason === "capacity_exceeded" + ? await joinedListingNames(unique(bookings.map((b) => b.listingId))) : ""; - throw new Error(formatServicingCapacityError(check.reason, names)); + throw new Error(formatServicingCapacityError(result.reason, names)); } - return result as Extract; + return result; }; const normalizedCreateInput = ( diff --git a/src/shared/db/backup-snapshot.ts b/src/shared/db/backup-snapshot.ts new file mode 100644 index 0000000000..3a3c919c61 --- /dev/null +++ b/src/shared/db/backup-snapshot.ts @@ -0,0 +1,157 @@ +import { chunk, requiredMapValue } from "#fp"; +import { + queryAllPrimary, + queryBatchPrimary, + queryOnePrimary, + resultRows, + type SqlStatement, +} from "#shared/db/client.ts"; +import { SCHEMA_TABLE_NAMES } from "#shared/db/migrations.ts"; +import { readLimit } from "#shared/limits.ts"; + +/** A single table's backup: its name, repopulating SQL, and row count. */ +export type TableBackup = { + table: string; + sql: string; + rowCount: number; +}; + +export type BackupCapture = { + checkoutStageRevision?: { revision: number }; + tables: TableBackup[]; +}; + +/** Double-quote a trusted SQL identifier. */ +export const quoteSqlIdentifier = (name: string): string => `"${name}"`; + +const existingSchemaTables = async (): Promise => { + const rows = await queryAllPrimary<{ name: string }>( + "SELECT name FROM sqlite_master WHERE type = 'table'", + [], + ); + const existing = new Set(rows.map((row) => row.name)); + return SCHEMA_TABLE_NAMES.filter((table) => existing.has(table)); +}; + +const escapeSql = (value: unknown): string => { + if (value === null || value === undefined) return "NULL"; + if (typeof value === "number") return String(value); + return `'${String(value).replace(/'/g, "''")}'`; +}; + +const ROWS_PER_INSERT = 100; +const DEFAULT_BACKUP_PAGE_SIZE = 500; +const ROWID_ALIAS = "__backup_rowid__"; +type BackupRow = Record; + +const tablePageStatement = ( + table: string, + cursor: number, + pageSize: number, +): SqlStatement => ({ + args: [cursor, pageSize], + sql: + `SELECT rowid AS ${ROWID_ALIAS}, * FROM ${quoteSqlIdentifier(table)} ` + + "WHERE rowid > ? ORDER BY rowid LIMIT ?", +}); + +/** Export one table in deterministic, primary-pinned keyset pages. */ +export const exportTable = async ( + table: string, + pageSize: number = readLimit("BACKUP_PAGE_SIZE", DEFAULT_BACKUP_PAGE_SIZE), + firstPage?: BackupRow[], +): Promise<{ sql: string; rowCount: number }> => { + const quoted = quoteSqlIdentifier(table); + const statements: string[] = []; + let rowCount = 0; + let cols: string[] = []; + let colList = ""; + const tuple = (row: Record): string => + `(${cols.map((column) => escapeSql(row[column])).join(", ")})`; + let cursor = 0; + let suppliedPage = firstPage; + + for (;;) { + const rows = + suppliedPage ?? + (await queryAllPrimary( + tablePageStatement(table, cursor, pageSize).sql, + [cursor, pageSize], + )); + suppliedPage = undefined; + if (rows.length === 0) break; + if (rowCount === 0) { + cols = Object.keys(rows[0]!).filter((column) => column !== ROWID_ALIAS); + colList = cols.map(quoteSqlIdentifier).join(", "); + } + for (const group of chunk(ROWS_PER_INSERT)(rows)) { + statements.push( + `INSERT INTO ${quoted} (${colList}) VALUES ${group + .map(tuple) + .join(", ")};`, + ); + } + rowCount += rows.length; + cursor = Number(rows[rows.length - 1]![ROWID_ALIAS]); + if (rows.length < pageSize) break; + } + return { rowCount, sql: statements.join("\n") }; +}; + +const exportTables = async (tables: string[]): Promise => { + const pageSize = readLimit("BACKUP_PAGE_SIZE", DEFAULT_BACKUP_PAGE_SIZE); + const firstPages = await queryBatchPrimary( + tables.map((table) => tablePageStatement(table, 0, pageSize)), + ); + const pagesByIndex = new Map(firstPages.entries()); + return Promise.all( + tables.map(async (table, index) => ({ + table, + ...(await exportTable( + table, + pageSize, + resultRows( + requiredMapValue( + pagesByIndex, + index, + `Backup page missing for ${table}`, + ), + ), + )), + })), + ); +}; + +/** Complete bounded exports attempted before continuous writes abort capture. */ +export const BACKUP_CAPTURE_ATTEMPTS = 3; + +const checkoutStageRevision = async (): Promise => + ( + await queryOnePrimary<{ revision: number }>( + "SELECT revision FROM checkout_stage_revisions WHERE id = 1", + [], + ) + )?.revision ?? 0; + +/** Capture without a long transaction. Equal revisions before and after the + * bounded export certify that no stage mutation split the related tables. */ +export const captureBackup = async (): Promise => { + for (let attempt = 0; attempt < BACKUP_CAPTURE_ATTEMPTS; attempt += 1) { + const tables = await existingSchemaTables(); + if (!tables.includes("checkout_stages")) { + return { tables: await exportTables(tables) }; + } + const before = await checkoutStageRevision(); + const exported = await exportTables(tables); + const after = await checkoutStageRevision(); + if (before === after) { + return { + checkoutStageRevision: { revision: after }, + tables: exported, + }; + } + } + throw new Error( + `Checkout stages kept changing during backup after ${BACKUP_CAPTURE_ATTEMPTS} attempts`, + ); +}; diff --git a/src/shared/db/backup-storage.ts b/src/shared/db/backup-storage.ts new file mode 100644 index 0000000000..ac287f9bdc --- /dev/null +++ b/src/shared/db/backup-storage.ts @@ -0,0 +1,83 @@ +import { compact } from "#fp"; +import { parseDateMs } from "#shared/dates.ts"; +import { requireEnv } from "#shared/env.ts"; +import { MAX_BACKUPS } from "#shared/limits.ts"; +import { deleteFile, getBasename, listFiles } from "#shared/storage.ts"; + +export const isRemoteDatabase = (): boolean => { + const url = requireEnv("DB_URL"); + return url.startsWith("libsql://") || url.startsWith("https://"); +}; + +/** Extract the stable database identity used as its storage folder. */ +export const dbName = (url: string = requireEnv("DB_URL")): string => { + if (!URL.canParse(url)) return "local"; + const host = new URL(url).hostname; + const first = host.split(".")[0]!; + if (host.endsWith(".turso.io")) return first; + const dashIndex = first.indexOf("-"); + return dashIndex === -1 ? first : first.slice(dashIndex + 1); +}; + +export const backupDir = (name: string = dbName()): string => `${name}/`; + +export const backupLeaf = (timestamp: string): string => + `backup-${timestamp}.zip`; + +export const backupKey = (timestamp: string, name: string = dbName()): string => + `${backupDir(name)}${backupLeaf(timestamp)}`; + +export const backupTimestamp = (date = new Date()): string => + date.toISOString().replace(/[:.]/g, "-"); + +/** One hour: the pre-upgrade gate requires a backup newer than this. */ +export const BACKUP_REQUIRED_WITHIN_MS = 60 * 60 * 1000; + +const BACKUP_TIMESTAMP = String.raw`(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z`; +const BACKUP_TIME_TAIL = new RegExp(`${BACKUP_TIMESTAMP}\\.zip$`); +const BACKUP_LEAF = new RegExp(`^backup-${BACKUP_TIMESTAMP}\\.zip$`); + +export const parseBackupTime = (filename: string): number | null => { + const match = filename.match(BACKUP_TIME_TAIL); + if (!match) return null; + return parseDateMs( + `${match[1]}T${match[2]}:${match[3]}:${match[4]}.${match[5]}Z`, + ); +}; + +export const isBackupLeaf = (leaf: string): boolean => BACKUP_LEAF.test(leaf); + +export const isBackupPath = (key: string): boolean => + isBackupLeaf(getBasename(key)); + +export const hasRecentBackup = async ( + maxAgeMs: number = BACKUP_REQUIRED_WITHIN_MS, + name: string = dbName(), +): Promise => { + const now = Date.now(); + const files = await listFiles(backupDir(name)); + return files.some((file) => { + if (!isBackupPath(file)) return false; + const milliseconds = parseBackupTime(file); + return milliseconds !== null && now - milliseconds < maxAgeMs; + }); +}; + +/** Remove oldest backups beyond `keep`; storage deletion is best-effort. */ +export const pruneOldBackups = async ( + keep = MAX_BACKUPS, +): Promise => { + const files = await listFiles(backupDir()); + const stale = files.filter(isBackupPath).reverse().slice(keep); + const removed = await Promise.all( + stale.map(async (file) => { + try { + await deleteFile(file); + return file; + } catch { + return null; + } + }), + ); + return compact(removed); +}; diff --git a/src/shared/db/backup.ts b/src/shared/db/backup.ts index 825671d6c7..88a63bc8e3 100644 --- a/src/shared/db/backup.ts +++ b/src/shared/db/backup.ts @@ -11,14 +11,23 @@ */ import { unzipSync, zipSync } from "fflate"; -import { chunk, compact, requiredMapValue } from "#fp"; -import { parseDateMs } from "#shared/dates.ts"; +import * as v from "valibot"; +import { + captureBackup, + quoteSqlIdentifier, + type TableBackup, +} from "#shared/db/backup-snapshot.ts"; +import { + backupKey, + backupTimestamp, + pruneOldBackups, +} from "#shared/db/backup-storage.ts"; +import { OPEN_CHECKOUT_STAGE_SQL } from "#shared/db/checkout-stage-state.ts"; import { executeBatch, - queryAll, - queryBatch, + queryBatchPrimary, + queryOnePrimary, resultRows, - type SqlStatement, } from "#shared/db/client.ts"; import { MIGRATION_IDS } from "#shared/db/migrations/registry.ts"; import { @@ -29,21 +38,13 @@ import { SCHEMA_HASH, SCHEMA_TABLE_NAMES, } from "#shared/db/migrations.ts"; -import { queryColumnSet } from "#shared/db/query.ts"; import { dumpMigrationState, legacyColumnRestores, } from "#shared/db/restore-legacy-columns.ts"; -import { requireEnv } from "#shared/env.ts"; -import { MAX_BACKUPS, readLimit } from "#shared/limits.ts"; import { namedError } from "#shared/named-error.ts"; import { nowIso } from "#shared/now.ts"; -import { - deleteFile, - getBasename, - listFiles, - uploadRaw, -} from "#shared/storage.ts"; +import { uploadRaw } from "#shared/storage.ts"; /** Thrown by restoreFromSql after resetDatabase() runs but a later step fails, * so callers can distinguish a post-reset failure (DB wiped) from a pre-reset @@ -52,45 +53,25 @@ export class PostResetError extends namedError("PostResetError") {} // ─── Types ────────────────────────────────────────────────────── -/** A single table's backup: table name, the SQL to repopulate it, and row count */ -export type TableBackup = { - table: string; - sql: string; - rowCount: number; -}; +const NonNegativeIntegerSchema = v.pipe(v.number(), v.integer(), v.minValue(0)); -/** Metadata stored in manifest.json inside the backup zip */ -export type BackupManifest = { - schemaHash: string; - latestUpdate: string; - timestamp: string; - tables: Record; -}; - -// ─── Helpers ──────────────────────────────────────────────────── - -/** Double-quote a SQL identifier (table or column name) */ -const quoteId = (name: string): string => `"${name}"`; - -/** Get existing table names in one round-trip. */ -const getExistingTableNames = (): Promise> => - queryColumnSet("SELECT name FROM sqlite_master WHERE type = 'table'", "name"); +/** Metadata stored in manifest.json inside the backup zip. */ +const BackupManifestSchema = v.object({ + checkoutStageRevision: v.optional( + v.object({ revision: NonNegativeIntegerSchema }), + ), + latestUpdate: v.string(), + schemaHash: v.string(), + tables: v.record( + v.pipe(v.string(), v.regex(/^\w+$/)), + NonNegativeIntegerSchema, + ), + timestamp: v.string(), +}); -/** - * The schema's tables that currently exist, in SCHEMA (FK-dependency) order. - * Skips tables a pending migration has not created yet. - */ -const existingSchemaTables = async (): Promise => { - const existing = await getExistingTableNames(); - return SCHEMA_TABLE_NAMES.filter((table) => existing.has(table)); -}; +export type BackupManifest = v.InferOutput; -/** Escape a SQL string value (single quotes doubled) */ -const escapeSql = (value: unknown): string => { - if (value === null || value === undefined) return "NULL"; - if (typeof value === "number") return String(value); - return `'${String(value).replace(/'/g, "''")}'`; -}; +// ─── Helpers ──────────────────────────────────────────────────── /** * Split SQL text into individual statements. @@ -106,188 +87,40 @@ export const splitStatements = (sql: string): string[] => { .map((s) => (s.endsWith(";") ? s : `${s};`)); }; -/** Check if DB_URL points to a remote database */ -export const isRemoteDatabase = (): boolean => { - const url = requireEnv("DB_URL"); - return url.startsWith("libsql://") || url.startsWith("https://"); -}; - -/** - * Extract a short database name from DB_URL for use in backup filenames. - * e.g. "libsql://01KFXB...-tickets-spencer.lite.bunnydb.net/" → "tickets-spencer" - * For Turso URLs the full first hostname segment is used as-is (it is already - * the unique database identity: "{db-name}-{org}.turso.io"). - * Falls back to "local" for non-remote or unparseable URLs. - */ -export const dbName = (url: string = requireEnv("DB_URL")): string => { - if (!URL.canParse(url)) return "local"; - - const host = new URL(url).hostname; - const first = host.split(".")[0]!; - - // Turso hostnames: {db-name}-{org}.turso.io — the full first segment is unique - if (host.endsWith(".turso.io")) return first; - - // Bunny DB hostnames: {uuid}-{name}.lite.bunnydb.net — drop the UUID prefix - const dashIdx = first.indexOf("-"); - if (dashIdx === -1) return first; - return first.slice(dashIdx + 1); -}; - -// ─── Backup ───────────────────────────────────────────────────── - -/** Max rows per multi-row INSERT. Batching writes the column list and statement - * prefix once per group instead of once per row, shrinking the dump and - * cutting the number of statements replayed on restore. */ -const ROWS_PER_INSERT = 100; - -/** - * Rows fetched per keyset page when exporting a table. A whole-table - * `SELECT *` makes libsqld (the server behind Bunny's databases) serialize the - * entire result into one response, which trips its "Response is too large" - * payload cap on big tables. Paging by rowid keeps each read's response - * bounded. Overridable per call (tests) and via the `BACKUP_PAGE_SIZE` env var. - */ -const DEFAULT_BACKUP_PAGE_SIZE = 500; - -/** Result-set key carrying the keyset cursor (rowid); stripped from the dump. */ -const ROWID_ALIAS = "__backup_rowid__"; - -type BackupRow = Record; - -const tablePageStatement = ( - table: string, - cursor: number, - pageSize: number, -): SqlStatement => ({ - args: [cursor, pageSize], - sql: - `SELECT rowid AS ${ROWID_ALIAS}, * FROM ${quoteId(table)} ` + - "WHERE rowid > ? ORDER BY rowid LIMIT ?", -}); - -/** Export a single table as multi-row INSERT statements (deterministic order). - * Reads are keyset-paginated by rowid so no single response exceeds libsqld's - * payload cap. Column names come from the row keys (minus the cursor alias), - * so no extra schema query is needed. */ -export const exportTable = async ( - table: string, - pageSize: number = readLimit("BACKUP_PAGE_SIZE", DEFAULT_BACKUP_PAGE_SIZE), - firstPage?: BackupRow[], -): Promise<{ sql: string; rowCount: number }> => { - const quoted = quoteId(table); - const statements: string[] = []; - let rowCount = 0; - let cols: string[] = []; - let colList = ""; - const tuple = (row: Record): string => - `(${cols.map((c) => escapeSql(row[c])).join(", ")})`; - // App invariant: every table's rowids are positive autoincrement ids, so a - // cursor starting below 1 reads the whole table. - let cursor = 0; - let suppliedPage = firstPage; - - for (;;) { - const rows = - suppliedPage ?? - (await queryAll( - tablePageStatement(table, cursor, pageSize).sql, - [cursor, pageSize], - )); - suppliedPage = undefined; - if (rows.length === 0) break; - if (rowCount === 0) { - cols = Object.keys(rows[0]!).filter((c) => c !== ROWID_ALIAS); - colList = cols.map(quoteId).join(", "); - } - for (const group of chunk(ROWS_PER_INSERT)(rows)) { - statements.push( - `INSERT INTO ${quoted} (${colList}) VALUES ${group - .map(tuple) - .join(", ")};`, - ); - } - rowCount += rows.length; - cursor = Number(rows[rows.length - 1]![ROWID_ALIAS]); - if (rows.length < pageSize) break; - } - return { rowCount, sql: statements.join("\n") }; -}; - -/** Create a full backup — one TableBackup per table in SCHEMA order. - * Skips tables that don't exist yet (e.g. new tables about to be created by a migration). */ -export const createBackup = async (): Promise => { - const tables = await existingSchemaTables(); - const pageSize = readLimit("BACKUP_PAGE_SIZE", DEFAULT_BACKUP_PAGE_SIZE); - const firstPages = await queryBatch( - tables.map((table) => tablePageStatement(table, 0, pageSize)), - ); - const pagesByIndex = new Map(firstPages.entries()); - return Promise.all( - tables.map(async (table, index) => ({ - table, - ...(await exportTable( - table, - pageSize, - resultRows( - requiredMapValue( - pagesByIndex, - index, - `Backup page missing for ${table}`, - ), - ), - )), - })), - ); -}; - -/** - * Per-site folder that scopes a database's backups within shared storage - * (defaults to the current DB; pass a name from `dbName(url)` to target another - * instance). Because it is a real path segment — not a name prefix — listing one - * site's folder can never pick up another's, even when one db name is a string - * prefix of another ("tickets" vs "tickets-spencer"). - */ -export const backupDir = (name: string = dbName()): string => `${name}/`; - -/** Leaf filename for a backup taken at `timestamp`, e.g. - * "backup-2024-01-15T12-30-00-000Z.zip". Lives inside `backupDir()`. */ -export const backupLeaf = (timestamp: string): string => - `backup-${timestamp}.zip`; - -/** Full storage key for a backup: "{name}/backup-{timestamp}.zip". Defaults to - * the current DB; pass a name to target another instance. */ -export const backupKey = (timestamp: string, name: string = dbName()): string => - `${backupDir(name)}${backupLeaf(timestamp)}`; - -/** Generate a timestamp string for backup filenames */ -export const backupTimestamp = (date = new Date()): string => - date.toISOString().replace(/[:.]/g, "-"); - /** Build the manifest object for a backup */ const buildManifest = ( tables: TableBackup[], timestamp: string, -): BackupManifest => ({ - latestUpdate: LATEST_UPDATE, - schemaHash: SCHEMA_HASH, - tables: Object.fromEntries( - tables.map(({ table, rowCount }) => [table, rowCount]), - ), - timestamp, -}); + checkoutStageRevision?: { revision: number }, +): BackupManifest => { + const manifest: BackupManifest = { + latestUpdate: LATEST_UPDATE, + schemaHash: SCHEMA_HASH, + tables: Object.fromEntries( + tables.map(({ table, rowCount }) => [table, rowCount]), + ), + timestamp, + }; + return checkoutStageRevision + ? { ...manifest, checkoutStageRevision } + : manifest; +}; /** Create a zip archive from table backups with manifest */ export const createBackupZip = async (): Promise => { const encoder = new TextEncoder(); const timestamp = nowIso(); - const tables = await createBackup(); - const manifest = buildManifest(tables, timestamp); + const capture = await captureBackup(); + const manifest = buildManifest( + capture.tables, + timestamp, + capture.checkoutStageRevision, + ); const files: Record = { "manifest.json": encoder.encode(JSON.stringify(manifest, null, 2)), }; - for (const { table, sql } of tables) { + for (const { table, sql } of capture.tables) { files[`${table}.sql`] = encoder.encode(sql); } return zipSync(files, { level: 1 }); @@ -304,110 +137,47 @@ export const createAndUploadBackup = async (): Promise => { return filename; }; -/** - * How fresh a backup must be to satisfy the pre-upgrade gate on /admin/update - * and the per-site update button — updates are blocked unless a backup for that - * database was taken within this window. One hour. - */ -export const BACKUP_REQUIRED_WITHIN_MS = 60 * 60 * 1000; - -/** ISO-8601-ish timestamp as it appears in a backup filename (":"/"." → "-"), - * with the date/time pieces captured so parseBackupTime can rebuild the real - * ISO string. Defined once and reused by every backup-filename matcher. */ -const BACKUP_TIMESTAMP = String.raw`(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z`; - -/** Matches the "{timestamp}.zip" tail at the end of a backup key. */ -const BACKUP_TIME_TAIL = new RegExp(`${BACKUP_TIMESTAMP}\\.zip$`); - -/** Matches a leaf that is *exactly* "backup-{timestamp}.zip" — no directory and - * no extra characters, so it also rejects any path separators. */ -const BACKUP_LEAF = new RegExp(`^backup-${BACKUP_TIMESTAMP}\\.zip$`); - -/** - * Parse the epoch-ms encoded in a backup filename, or null if it doesn't match. - * Inverse of backupTimestamp: "…/backup-2024-01-15T12-30-00-000Z.zip" → epoch ms. - */ -export const parseBackupTime = (filename: string): number | null => { - const m = filename.match(BACKUP_TIME_TAIL); - if (!m) return null; - return parseDateMs(`${m[1]}T${m[2]}:${m[3]}:${m[4]}.${m[5]}Z`); -}; - -/** True when a bare leaf name is exactly "backup-{timestamp}.zip". Anchored, so - * it also doubles as traversal-proofing for the download route's filename. */ -export const isBackupLeaf = (leaf: string): boolean => BACKUP_LEAF.test(leaf); - -/** True when a storage key ("{name}/backup-…zip") is one of our backups — i.e. - * its leaf is a valid backup filename. Picks backups out of a folder listing - * while ignoring anything else stored alongside them. */ -export const isBackupPath = (key: string): boolean => - isBackupLeaf(getBasename(key)); - -/** - * True if a backup younger than `maxAgeMs` exists for the given database - * (defaults to the current DB) within the upgrade-gate window. Callers gating - * another instance pass `dbName(site.dbUrl)`. - */ -export const hasRecentBackup = async ( - maxAgeMs: number = BACKUP_REQUIRED_WITHIN_MS, - name: string = dbName(), -): Promise => { - const now = Date.now(); - const files = await listFiles(backupDir(name)); - for (const file of files) { - // Only real backups count — ignore anything else left in the folder, so a - // stray "{name}/manual-…Z.zip" can't spoof the freshness gate (mirrors - // pruneOldBackups). - if (!isBackupPath(file)) continue; - const ms = parseBackupTime(file); - if (ms !== null && now - ms < maxAgeMs) return true; - } - return false; -}; - -/** - * Purge the oldest backups beyond `keep` for the current DB, keeping the - * newest. Filenames embed ISO timestamps, so name order is chronological. - * Deletes run in parallel and are best-effort — a failed delete never blocks - * backup creation. Returns the filenames that were removed. - */ -export const pruneOldBackups = async ( - keep = MAX_BACKUPS, -): Promise => { - const files = await listFiles(backupDir()); - const stale = files.filter(isBackupPath).reverse().slice(keep); - const removed = await Promise.all( - stale.map(async (file) => { - try { - await deleteFile(file); - return file; - } catch { - return null; - } - }), - ); - return compact(removed); -}; - // ─── Restore ──────────────────────────────────────────────────── -/** Validate that a parsed object has the expected BackupManifest shape */ -const isValidManifest = (v: unknown): v is BackupManifest => - typeof v === "object" && - v !== null && - typeof (v as Record).schemaHash === "string" && - typeof (v as Record).latestUpdate === "string" && - typeof (v as Record).timestamp === "string" && - typeof (v as Record).tables === "object" && - (v as Record).tables !== null; - /** Read and parse manifest.json from a backup zip. Returns null if missing or invalid. */ export const readManifest = (zipData: Uint8Array): BackupManifest | null => { const files = unzipSync(zipData); const manifestBytes = files["manifest.json"]; if (!manifestBytes) return null; const parsed: unknown = JSON.parse(new TextDecoder().decode(manifestBytes)); - return isValidManifest(parsed) ? parsed : null; + const result = v.safeParse(BackupManifestSchema, parsed); + return result.success ? result.output : null; +}; + +const validateArchiveFiles = ( + files: Record, + manifest: BackupManifest | null, +): void => { + const hasStages = + files["checkout_stages.sql"] !== undefined || + manifest?.tables.checkout_stages !== undefined; + if (hasStages && !manifest?.checkoutStageRevision) { + throw new Error( + "Backup contains checkout_stages but has no checkout stage revision certificate", + ); + } + if (!manifest) return; + const manifestTables = new Set(Object.keys(manifest.tables)); + for (const table of manifestTables) { + if (!files[`${table}.sql`]) { + throw new Error(`Backup is missing table file ${table}.sql`); + } + } + for (const filename of Object.keys(files).filter((name) => + name.endsWith(".sql"), + )) { + const table = filename.slice(0, -4); + if (!manifestTables.has(table)) { + throw new Error( + `Backup table file ${filename} is absent from its manifest`, + ); + } + } }; /** Count SQL statements across all .sql files in a zip archive */ @@ -499,6 +269,8 @@ export const restoreFromSql = async (sql: string): Promise => { */ export const restoreFromZip = async (zipData: Uint8Array): Promise => { const files = unzipSync(zipData); + const manifest = readManifest(zipData); + validateArchiveFiles(files, manifest); const decoder = new TextDecoder(); const allSql: string[] = []; @@ -509,4 +281,62 @@ export const restoreFromZip = async (zipData: Uint8Array): Promise => { } await restoreFromSql(allSql.join("\n")); + + let validationError: string | undefined; + try { + if (manifest) { + // Older archives can name a table since removed or renamed. Preserve the + // existing restore contract: migration history decides whether that age + // is valid, while counts are checked for every table this build restores. + const tables = Object.keys(manifest.tables).filter((table) => + SCHEMA_TABLE_NAMES.includes(table), + ); + const results = await queryBatchPrimary( + tables.map((table) => ({ + args: [], + sql: `SELECT COUNT(*) AS count FROM ${quoteSqlIdentifier(table)}`, + })), + ); + for (const [index, table] of tables.entries()) { + const actual = Number( + resultRows<{ count: number }>(results[index]!)[0]!.count, + ); + const expected = manifest.tables[table]!; + if (actual !== expected) { + throw new Error( + `Restored table ${table} has ${actual} rows; backup manifest records ${expected}`, + ); + } + } + } + + const brokenStage = await queryOnePrimary<{ + attendee_id: number; + payment_session_id: string; + problem: string; + }>( + `SELECT stage.payment_session_id, stage.attendee_id, + CASE WHEN attendee.id IS NULL THEN 'attendee' + ELSE 'booking' END AS problem + FROM checkout_stages AS stage + LEFT JOIN attendees AS attendee ON attendee.id = stage.attendee_id + WHERE stage.state ${OPEN_CHECKOUT_STAGE_SQL} + AND (attendee.id IS NULL OR NOT EXISTS ( + SELECT 1 FROM listing_attendees AS booking + WHERE booking.attendee_id = stage.attendee_id + )) + LIMIT 1`, + [], + ); + if (brokenStage) { + throw new Error( + `Restored open checkout stage ${brokenStage.payment_session_id} has no ${brokenStage.problem} for attendee ${brokenStage.attendee_id}`, + ); + } + } catch (error) { + validationError = String(error); + } + if (validationError !== undefined) { + throw new PostResetError(validationError); + } }; diff --git a/src/shared/db/capacity.ts b/src/shared/db/capacity.ts index 56731ff72f..30a7c2581f 100644 --- a/src/shared/db/capacity.ts +++ b/src/shared/db/capacity.ts @@ -103,7 +103,7 @@ const buildUndatedListingCountSql = ( /** The count subquery for one listing — daily listings count overlapping rows * for the day, others read the running `booked_quantity`. Exported so the batch - * read preflight ({@link buildBatchCapacitySql}) counts a listing the SAME way + * read preflight ({@link buildBatchCapacityCondition}) counts a listing the SAME way * the atomic write predicate does. */ export const buildListingCountSql = ( listingId: number, @@ -367,14 +367,10 @@ const bucketClauses = ( return bucket.total > 0 ? [clauseFor(null, bucket.total)] : []; }; -/** - * One SELECT returning `fits` (1/0) for a whole cart's combined demand, built - * from the SAME listing/group count subqueries the atomic write predicate uses - * — so the read-time preflight and the write-time guard can never count - * capacity differently. Listing demand is checked per listing; group demand is - * checked per group with the cart's non-daily demand folded into each day. - */ -export const buildBatchCapacitySql = ( +/** The whole-cart capacity predicate used by both the availability SELECT and + * staged activation's guarded write. Demand is already combined per listing, + * group, and occupied day, so one condition protects the complete cart. */ +export const buildBatchCapacityCondition = ( listingDemand: Map, groupDemand: Map, ): SqlStatement => { @@ -393,10 +389,7 @@ export const buildBatchCapacitySql = ( ), ); } - if (clauses.length === 0) return { args: [], sql: "SELECT 1 AS fits" }; + if (clauses.length === 0) return { args: [], sql: "1 = 1" }; const combined = joinStatements(clauses, " AND "); - return { - args: combined.args, - sql: `SELECT CASE WHEN ${combined.sql} THEN 1 ELSE 0 END AS fits`, - }; + return combined; }; diff --git a/src/shared/db/checkout-stage-cleanup.ts b/src/shared/db/checkout-stage-cleanup.ts new file mode 100644 index 0000000000..9c07d8a580 --- /dev/null +++ b/src/shared/db/checkout-stage-cleanup.ts @@ -0,0 +1,83 @@ +import type { InValue } from "@libsql/client"; +import { attendeeDependentDeleteStatements } from "#shared/db/attendees/delete.ts"; +import { + execute, + executeBatchWithResults, + inPlaceholders, + type SqlStatement, +} from "#shared/db/client.ts"; +import { UNRESOLVED_RESERVATION } from "#shared/db/processed-payments.ts"; + +const pendingStageAttendees = (where: string): string => + `SELECT stage.attendee_id + FROM checkout_stages AS stage + WHERE stage.state = 'pending' + AND ${where} + AND NOT EXISTS ( + SELECT 1 + FROM processed_payments AS payment + WHERE payment.payment_session_id = stage.payment_session_id + )`; + +/** Delete pending checkout PII only while no payment request has claimed it. + * The shared purge removes every dependent row, with checkout_stages last so + * its attendee-id query remains usable throughout the batch. */ +const discardPendingCheckoutsWhere = async ( + where: string, + args: InValue[], + leading: SqlStatement[] = [], +): Promise => { + const attendeeIds = { args, sql: pendingStageAttendees(where) }; + const attendeeDelete = { + args, + sql: `DELETE FROM attendees WHERE id IN (${attendeeIds.sql})`, + }; + const results = await executeBatchWithResults([ + ...leading, + ...attendeeDependentDeleteStatements(attendeeIds, [attendeeDelete]), + ]); + return results[results.length - 1]!.rowsAffected; +}; + +/** Discard one or more cancelled sessions through the shared atomic purge. */ +export const discardPendingCheckoutSessions = ( + sessionIds: string[], +): Promise => + sessionIds.length === 0 + ? Promise.resolve(0) + : discardPendingCheckoutsWhere( + `stage.payment_session_id IN (${inPlaceholders(sessionIds)})`, + sessionIds, + ); + +/** Remove old pending stages and their stale payment claims as one transaction, + * then remove old terminal replay guards without touching their attendees. A + * fresh or finalized payment row still protects an old pending stage. */ +export const pruneCheckoutStageRows = async ( + pendingCutoffIso: string, + staleReservationCutoffIso: string, + resolvedCutoffIso: string, +): Promise => { + const staleReservationDelete: SqlStatement = { + args: [staleReservationCutoffIso, pendingCutoffIso], + sql: `DELETE FROM processed_payments AS payment + WHERE ${UNRESOLVED_RESERVATION} + AND payment.processed_at < ? + AND EXISTS ( + SELECT 1 FROM checkout_stages AS stage + WHERE stage.payment_session_id = payment.payment_session_id + AND stage.state = 'pending' AND stage.created_at < ? + )`, + }; + const pending = await discardPendingCheckoutsWhere( + "stage.created_at < ?", + [pendingCutoffIso], + [staleReservationDelete], + ); + const resolved = await execute( + `DELETE FROM checkout_stages + WHERE state IN ('booked', 'failed') AND created_at < ?`, + [resolvedCutoffIso], + ); + return pending + resolved.rowsAffected; +}; diff --git a/src/shared/db/checkout-stage-state.ts b/src/shared/db/checkout-stage-state.ts new file mode 100644 index 0000000000..181d74b664 --- /dev/null +++ b/src/shared/db/checkout-stage-state.ts @@ -0,0 +1,18 @@ +import * as v from "valibot"; + +export const CheckoutStageStateSchema = v.picklist([ + "pending", + "refunding", + "booked", + "failed", +]); + +export type CheckoutStageState = v.InferOutput; + +/** Whether a stage still owns its quantity-zero rows: either the checkout may + * activate them, or a started refund must finish without letting them activate. */ +export const isOpenCheckoutStage = (state: CheckoutStageState): boolean => + state === "pending" || state === "refunding"; + +/** SQL membership test matching {@link isOpenCheckoutStage}. */ +export const OPEN_CHECKOUT_STAGE_SQL = "IN ('pending', 'refunding')"; diff --git a/src/shared/db/checkout-stages.ts b/src/shared/db/checkout-stages.ts new file mode 100644 index 0000000000..1eb69c9c9f --- /dev/null +++ b/src/shared/db/checkout-stages.ts @@ -0,0 +1,338 @@ +/* jscpd:ignore-start */ +import * as v from "valibot"; +import { unique } from "#fp"; +import { t } from "#i18n"; +import { + type CanonicalBooking, + orderBookingsFor, + signedPaidLine, +} from "#shared/booking-lines.ts"; +import type { EnvKeyEncrypted } from "#shared/crypto/sealed.ts"; +import { generateTicketToken } from "#shared/crypto/utils.ts"; +import { getPublicStatusId } from "#shared/db/attendee-statuses.ts"; +import type { CreateAttendeeSuccess } from "#shared/db/attendee-types.ts"; +import { + checkBatchAvailability, + createAttendeeAtomic, +} from "#shared/db/attendees/api.ts"; +import { bookingCapacityFields } from "#shared/db/attendees/capacity/checks.ts"; +import { + type CheckoutStageState, + CheckoutStageStateSchema, + isOpenCheckoutStage, + OPEN_CHECKOUT_STAGE_SQL, +} from "#shared/db/checkout-stage-state.ts"; +import { + execute, + insert, + primaryMatchingIdSet, + queryOnePrimary, + type TxScope, +} from "#shared/db/client.ts"; +import { getListingsWithCountsByIds } from "#shared/db/listings/records.ts"; +import { + decryptSessionTokens, + encryptTicketTokens, +} from "#shared/db/processed-payments.ts"; +import { nowIso } from "#shared/now.ts"; +import { toBookingItems } from "#shared/payment-helpers.ts"; +import type { + CheckoutIntent, + CheckoutSessionResult, + PaymentProvider, + PaymentProviderType, +} from "#shared/payments.ts"; + +/* jscpd:ignore-end */ + +type CheckoutStageRow = { + attendee_id: number; + state: CheckoutStageState; + ticket_tokens: EnvKeyEncrypted; +}; + +export type CheckoutStage = { + attendeeId: number; + state: CheckoutStageState; + ticketToken: string; +}; + +type TerminalCheckoutStageState = Extract< + CheckoutStageState, + "booked" | "failed" +>; + +/** The order's real-quantity bookings from the signed intent — the single + * source both the availability preflight and the quantity-0 staging derive + * from, so what we check is exactly what we stage. */ +const orderedBookings = async ( + intent: CheckoutIntent, +): Promise => { + const items = toBookingItems(intent); + const listings = await getListingsWithCountsByIds( + unique(items.map((item) => item.e)), + ); + const listingById = new Map(listings.map((listing) => [listing.id, listing])); + const lines = items.map((item) => { + const listing = listingById.get(item.e); + if (!listing) throw new Error(`Listing ${item.e} vanished before checkout`); + return { item, listing }; + }); + return orderBookingsFor( + intent, + lines.map(({ item, listing }) => signedPaidLine(item, listing)), + ); +}; + +const stagedBookings = async ( + intent: CheckoutIntent, +): Promise => + (await orderedBookings(intent)).map((booking) => ({ + ...booking, + quantity: 0, + })); + +/** Refuse a checkout whose order provably can't be booked — no room for its real + * quantities, or a listing that's off sale — BEFORE the provider session is + * created, so the customer is told up front instead of paying and being + * refunded. The staged rows themselves claim nothing (quantity 0), so the + * authoritative capacity claim still runs at activation with the real + * quantities; this only stops a checkout that cannot succeed. Returns the error + * to show, or null when the order is bookable. */ +const stageableRefusal = async ( + intent: CheckoutIntent, +): Promise<{ error: string } | null> => { + const bookings = await orderedBookings(intent); + const available = await checkBatchAvailability( + bookings.map(bookingCapacityFields), + intent.date, + ); + return available ? null : { error: t("public.checkout_unavailable") }; +}; + +const stageInsert = async ( + tx: TxScope, + sessionId: string, + attendeeId: number, + provider: PaymentProviderType, + ticketToken: string, +): Promise => { + await tx.execute( + insert("checkout_stages", { + attendee_id: attendeeId, + created_at: nowIso(), + payment_session_id: sessionId, + provider, + state: "pending", + ticket_tokens: await encryptTicketTokens([ticketToken]), + }), + ); +}; + +/** Save a fresh checkout as one attendee with exact quantity-zero path rows. */ +export const stageCheckout = async ( + sessionId: string, + provider: PaymentProviderType, + intent: CheckoutIntent, +): Promise => { + const ticketToken = generateTicketToken(); + // The overbook insert below cannot refuse (see CreateAttendeeSuccess), so + // the result is the success arm by construction. + const result = (await createAttendeeAtomic( + { + address: intent.address, + // A quantity-0 staged row claims nothing, so it must never be capacity + // gated: an already-overbooked listing (a supported admin state) or one + // deactivated mid-checkout would otherwise refuse to even START the + // checkout. Real capacity is enforced at activation, with the real + // quantities — that check refusing is what the refund path is for. + allowOverbook: true, + bookings: await stagedBookings(intent), + email: intent.email, + name: intent.name, + phone: intent.phone, + source: "public", + special_instructions: intent.special_instructions, + statusId: await getPublicStatusId(), + ticketToken, + }, + (tx, attendeeId) => + stageInsert(tx, sessionId, attendeeId, provider, ticketToken), + )) as CreateAttendeeSuccess; + return { + attendeeId: result.attendees[0]!.id, + state: "pending", + ticketToken, + }; +}; + +/** Check the order is bookable, create the provider session, then stage it + * before exposing the checkout URL. The order is refused up front when its + * listings can't fit the real quantities or are off sale, so no unbookable order + * ever reaches the provider. Only NEW bookings run through here — a balance + * payment settles an existing booking and goes straight to the provider + * (`balance.ts`), never staging a fresh attendee — so this always validates and + * stages. */ +export const createStagedCheckout = async ( + provider: PaymentProvider, + intent: CheckoutIntent, + baseUrl: string, +): Promise => { + const refusal = await stageableRefusal(intent); + if (refusal) return refusal; + const result = await provider.createCheckoutSession(intent, baseUrl); + if (!result || "error" in result) return result; + await stageCheckout(result.sessionId, provider.type, intent); + return result; +}; + +/** The session-creator callback {@link runCheckoutFlow} expects: stage this + * order at quantity zero, then create the provider checkout for it. Curried so + * every checkout entry point injects staging the same way. */ +export const stagedSessionCreator = + (intent: CheckoutIntent) => (provider: PaymentProvider, baseUrl: string) => + createStagedCheckout(provider, intent, baseUrl); + +export const getCheckoutStageOrNull = async ( + sessionId: string, +): Promise => { + const row = await queryOnePrimary< + CheckoutStageRow & { attendee_exists: number | null } + >( + `SELECT stage.attendee_id, stage.state, stage.ticket_tokens, + attendee.id AS attendee_exists + FROM checkout_stages AS stage + LEFT JOIN attendees AS attendee ON attendee.id = stage.attendee_id + WHERE stage.payment_session_id = ?`, + [sessionId], + ); + if (!row) return null; + const parsedState = v.parse(CheckoutStageStateSchema, row.state); + // An OPEN stage whose attendee is gone is an IMPOSSIBLE state: every + // deletion path removes the stage with the attendee (deleteAttendee cascades + // it; the prune deletes both), admin edits/merges/deletes are blocked while + // open, and a listing can't be deleted while it has an open checkout. So + // a dangling open stage means a delete skipped the stage cascade — surface + // the bug, don't silently book fresh around it. (A RESOLVED (booked/failed) + // dangling stage is returned as-is, so the resolved-stage guard still refuses + // to re-process money already handled.) + if (row.attendee_exists === null && isOpenCheckoutStage(parsedState)) { + throw new Error( + `Checkout stage for session ${sessionId} points at deleted attendee ${row.attendee_id} while still open — an open stage must never outlive its attendee`, + ); + } + // Terminal rows survive briefly as replay guards, but no longer need the + // bearer credential. Return their state without decrypting an empty field. + if (!isOpenCheckoutStage(parsedState)) { + return { + attendeeId: row.attendee_id, + state: parsedState, + ticketToken: "", + }; + } + const ticketToken = await decryptSessionTokens(row.ticket_tokens); + if (!ticketToken) throw new Error(`Checkout stage ${sessionId} has no token`); + return { + attendeeId: row.attendee_id, + state: parsedState, + ticketToken, + }; +}; + +/** + * Which of these attendees are mid-payment: the customer may still be paying, + * or a paid order is finishing its refund. Admin mutations (edits, merges, + * deletes) must leave either state alone. Array in, set out — call with one id + * for the single case. + */ +// Pinned to the primary: this gates mutations, and a replica lagging the +// just-staged insert would let an edit slip through the guard. +export const attendeeIdsWithPendingStage = primaryMatchingIdSet( + (placeholders) => + `SELECT attendee_id AS id FROM checkout_stages + WHERE state ${OPEN_CHECKOUT_STAGE_SQL} + AND attendee_id IN (${placeholders})`, +); + +/** Does this listing have any attendee mid-payment (an open staged checkout)? + * Gates listing deletion: deleting a listing removes its booking rows but leaves + * the attendee behind, which would strand an open stage's payment (its rows + * vanish, so the claim can never land). Primary-pinned, like the mutation guard + * above — a replica lagging a just-staged insert must not let the delete slip. */ +export const listingHasPendingCheckout = async ( + listingId: number, +): Promise => + (await queryOnePrimary<{ one: number }>( + `SELECT 1 AS one + FROM checkout_stages AS stage + JOIN listing_attendees AS booking ON booking.attendee_id = stage.attendee_id + WHERE stage.state ${OPEN_CHECKOUT_STAGE_SQL} AND booking.listing_id = ? + LIMIT 1`, + [listingId], + )) !== null; + +/** Whether this one attendee is mid-payment — an open staged checkout the + * payment may still claim or refund. Single-id form of {@link attendeeIdsWithPendingStage} + * for the admin-edit guards that block a save while the payment is in flight. */ +export const hasPendingCheckout = async ( + attendeeId: number, +): Promise => + (await attendeeIdsWithPendingStage([attendeeId])).has(attendeeId); + +export const markCheckoutStage = async ( + sessionId: string, + state: TerminalCheckoutStageState = "failed", +): Promise => { + await execute( + "UPDATE checkout_stages SET state = ?, ticket_tokens = '' WHERE payment_session_id = ?", + [state, sessionId], + ); +}; + +/** Permanently route a paid stage away from activation before asking the + * provider for a refund. A retry may continue refunding, but can never book. */ +export const beginCheckoutStageRefund = async ( + sessionId: string, +): Promise => { + const result = await execute( + `UPDATE checkout_stages SET state = 'refunding' + WHERE payment_session_id = ? AND state = 'pending'`, + [sessionId], + ); + if (result.rowsAffected !== 1) { + throw new Error( + `Checkout stage for session ${sessionId} did not enter refunding`, + ); + } +}; + +/** Resolve a stage a crash left open after its money was already recorded. + * Guarded to open states so a booked stage is never downgraded, and a session with + * no stage is a no-op. Called when the ledger preflight answers "already + * handled": the outcome is known, so the record must not read as mid-payment + * forever — an open stage blocks edits and merges and holds the staged + * details unprunable. */ +export const resolvePendingStage = async (sessionId: string): Promise => { + await execute( + `UPDATE checkout_stages SET state = 'failed', ticket_tokens = '' + WHERE payment_session_id = ? AND state ${OPEN_CHECKOUT_STAGE_SQL}`, + [sessionId], + ); +}; + +/** Heal an open stage from the attendee that already owns the durable payment + * outcome. The staged attendee was booked only when it is that same owner; a + * different owner means rollback-era code booked a second attendee, so the + * untouched quantity-zero stage failed. Neither case moves bookings or money. */ +export const resolveOpenStageFromOwner = async ( + sessionId: string, + ownerAttendeeId: number, +): Promise => { + await execute( + `UPDATE checkout_stages + SET state = CASE WHEN attendee_id = ? THEN 'booked' ELSE 'failed' END, + ticket_tokens = '' + WHERE payment_session_id = ? AND state ${OPEN_CHECKOUT_STAGE_SQL}`, + [ownerAttendeeId, sessionId], + ); +}; diff --git a/src/shared/db/client.ts b/src/shared/db/client.ts index a61f709901..9e31d9f9da 100644 --- a/src/shared/db/client.ts +++ b/src/shared/db/client.ts @@ -255,6 +255,10 @@ const executeTrackedStatement: StatementRunner = (sql, args) => * single-statement query and write helpers below. */ type SqlArgs = [sql: string, args?: InValue[]]; +/** Like {@link SqlArgs} but with the args required — the primary readers always + * key on a written row's id, so an argless call is a mistake. */ +type PrimarySqlArgs = [sql: string, args: InValue[]]; + /** * Run a single statement: track it for the query log / N+1 guard, then fire any * table-scoped cache invalidation. Every single-statement read and write goes @@ -288,21 +292,36 @@ const firstRowOrNull = (result: ResultSet): T | null => { export const queryOne = async (...[sql, args]: SqlArgs): Promise => firstRowOrNull(await execute(sql, args)); +/** Run one statement on the primary ("write" mode, read-your-writes) and hand + * back its ResultSet. Shared by the single-row and all-rows primary readers. */ +const onePrimaryResult = async ( + sql: string, + args: InValue[], +): Promise => { + const [result] = await queryBatchPrimary([{ args, sql }]); + return result!; +}; + /** - * Query a single row on the primary (read-your-writes), returning null if not - * found. Use this to read a row back immediately after committing its own write: - * a plain {@link queryOne} runs in "read" mode, which Turso can route to a - * replica lagging the just-committed write and so miss the row (returning null); - * routing through {@link queryBatchPrimary} ("write" mode) always hits the - * primary. Mirrors the same guard on {@link syncListingPrices}. `args` is - * required — every read-back keys on the written row's id. + * Query all rows on the primary (read-your-writes). Use this to read rows back + * immediately after committing their own write, or for a read that gates or + * feeds a write: a plain {@link queryAll}/{@link queryOne} runs in "read" mode, + * which Turso can route to a replica lagging the just-committed write and so + * miss the row; routing through {@link queryBatchPrimary} ("write" mode) always + * hits the primary. Mirrors the same guard on {@link syncListingPrices}. `args` + * is required — every read-back keys on the written row's id. */ +export const queryAllPrimary = async ( + ...[sql, args]: PrimarySqlArgs +): Promise => resultRows(await onePrimaryResult(sql, args)); + +/** Query a single row on the primary (read-your-writes), returning null if not + * found — the single-row companion to {@link queryAllPrimary}. */ export const queryOnePrimary = async ( - sql: string, - args: InValue[], + ...args: PrimarySqlArgs ): Promise => { - const [result] = await queryBatchPrimary([{ args, sql }]); - return firstRowOrNull(result!); + const rows = await queryAllPrimary(...args); + return rows[0] ?? null; }; /** Query all rows, returning a typed array */ @@ -333,6 +352,46 @@ export const rowExistsForIdList = (leadingId: number, ids: number[]): Promise => rowExists(buildSql(inPlaceholders(ids)), [leadingId, ...ids]); +/** + * Of a batch of ids, the ones a single-column `... AS id` query returns. Your + * `buildSql` receives the `IN (...)` placeholder string and must select exactly + * one column aliased `id`. Empty input short-circuits to an empty set (an + * `IN ()` clause is never valid SQL). Reads the replica by default; pass + * `primary` for a set that gates a write and so must not see a lagging replica. + * Shared by the batch membership lookups so their empty-guard and set-building + * live in one place. + */ +/** A set of the `id` column from rows of a single-`id`-column query. */ +const idSetFromRows = (rows: readonly { id: number }[]): Set => + new Set(rows.map((row) => row.id)); + +/** Select whether a read that can gate a write must use the primary. */ +export type PrimaryReadOptions = { primary?: boolean }; + +export const matchingIdSet = async ( + ids: readonly number[], + buildSql: (idsPlaceholders: string) => string, + options: PrimaryReadOptions = {}, +): Promise> => { + const { primary = false } = options; + if (ids.length === 0) return new Set(); + const sql = buildSql(inPlaceholders(ids)); + const args = [...ids]; + const rows = primary + ? await queryAllPrimary<{ id: number }>(sql, args) + : await queryAll<{ id: number }>(sql, args); + return idSetFromRows(rows); +}; + +/** A primary-pinned {@link matchingIdSet} lookup as a reusable membership guard: + * give it the query builder, get back the `(ids) => Set` check every mutation + * guard shares (a replica lagging a just-written row must not let a mutation slip + * past). Curried so each guard is `export const X = primaryMatchingIdSet(sql)`. */ +export const primaryMatchingIdSet = + (buildSql: (idsPlaceholders: string) => string) => + (ids: readonly number[]): Promise> => + matchingIdSet(ids, buildSql, { primary: true }); + /** * The next `sort_order` for rows of `table` in one group: one past the current * max, or 0 when the group is empty. `table` and `groupColumn` must be trusted @@ -441,6 +500,21 @@ export const joinStatements = ( sql: statements.map((statement) => statement.sql).join(joiner), }); +/** Join SQL conditions with AND, keeping each fragment grouped and its bound + * args in fragment order. An empty condition list is always true. */ +export const andConditions = ( + conditions: readonly SqlStatement[], +): SqlStatement => + conditions.length === 0 + ? { args: [], sql: "1 = 1" } + : joinStatements( + conditions.map((condition) => ({ + args: condition.args, + sql: `(${condition.sql})`, + })), + " AND ", + ); + /** * Execute a batch with optional query logging, then invalidate caches for every * table the batch mutated. Invalidation runs once the transaction has diff --git a/src/shared/db/contact-preferences.ts b/src/shared/db/contact-preferences.ts index ccf74103fa..18972e1398 100644 --- a/src/shared/db/contact-preferences.ts +++ b/src/shared/db/contact-preferences.ts @@ -99,20 +99,6 @@ export const getUnsubscribedHashSet = (): Promise> => "contact_hash", ); -/** Record one visit against a contact, creating the row on first activity. */ -export const recordVisit = (hash: string): Promise => - run( - "INSERT INTO contact_preferences (contact_hash, last_activity, visits) VALUES (?, ?, 1) ON CONFLICT(contact_hash) DO UPDATE SET visits = visits + 1, last_activity = excluded.last_activity", - [hash, nowMs()], - ); - -/** Reverse one visit increment, clamped at zero. */ -export const unrecordVisit = (hash: string): Promise => - run( - "UPDATE contact_preferences SET visits = MAX(visits - 1, 0), last_activity = ? WHERE contact_hash = ?", - [nowMs(), hash], - ); - export const getVisits = async (hash: string): Promise => { const row = await queryOne<{ visits: number }>( "SELECT visits FROM contact_preferences WHERE contact_hash = ?", diff --git a/src/shared/db/contact-tokens.ts b/src/shared/db/contact-tokens.ts index e8b7679751..ac7bf04c5d 100644 --- a/src/shared/db/contact-tokens.ts +++ b/src/shared/db/contact-tokens.ts @@ -24,6 +24,11 @@ import { nowMs } from "#shared/now.ts"; * counted in its own plaintext column so the split survives without the owner * key. */ export type BookingSource = "admin" | "public"; +type BookingActivityWriter = ( + hash: string, + source: BookingSource, + ticketToken: string, +) => Promise; /** A booked ticket token linked to a contact, with the source that booked it. */ export type BookingToken = { source: BookingSource; token: string }; @@ -107,67 +112,80 @@ const parseTokenEntry = async ( return { source: TAG_SOURCE[decoded[0]!]!, token: decoded.slice(1) }; }; -/** The conflict update for appending one encrypted ticket token. */ -const tokenAppendUpdate = (column: BookingCountColumn | null): string => - [ - ...(column === null ? [] : [`${column} = ${column} + 1`]), - "last_activity = excluded.last_activity", - "attendee_tokens_blob = attendee_tokens_blob || excluded.attendee_tokens_blob", - ].join(",\n "); - -/** Append one encrypted ticket token, optionally bumping a source count too. */ -const appendBookingToken = async ( +/** Append a ticket token to a contact's encrypted list without touching counts. */ +const addBookingToken = async ( hash: string, - source: BookingSource, ticketToken: string, - column: BookingCountColumn | null, + source: BookingSource, ): Promise => { - const countColumn = column === null ? "" : `, ${column}`; - const countValue = column === null ? "" : ", 1"; await execute( - `INSERT INTO contact_preferences (contact_hash, last_activity${countColumn}, attendee_tokens_blob) - VALUES (?, ?${countValue}, ?) + `INSERT INTO contact_preferences (contact_hash, last_activity, attendee_tokens_blob) + VALUES (?, ?, ?) ON CONFLICT(contact_hash) DO UPDATE SET - ${tokenAppendUpdate(column)}`, + last_activity = excluded.last_activity, + attendee_tokens_blob = attendee_tokens_blob || excluded.attendee_tokens_blob`, [hash, nowMs(), await encryptTokenEntry(hash, source, ticketToken)], ); }; -/** - * Record one booking against a contact: bump its source's plaintext count and - * append the booked ticket token to the encrypted, append-only token list. - */ -export const recordBooking = async ( - hash: string, - source: BookingSource, - ticketToken: string, -): Promise => { - await appendBookingToken(hash, source, ticketToken, BOOKING_COLUMN[source]); -}; - -/** Reverse a {@link recordBooking}'s count, e.g. when an order is rolled back. - * The token entry is left in place: a rollback deletes the attendee, so its - * token resolves to nothing and is filtered out on read. */ -export const unrecordBooking = async ( - hash: string, - source: BookingSource, -): Promise => { +/** Record one visit and booking against a contact. The token marker guards the + * whole write, so retrying after a lost database result changes nothing. */ +export const recordBookingActivity: BookingActivityWriter = async ( + hash, + source, + ticketToken, +) => { const column = BOOKING_COLUMN[source]; + const marker = `${await tokenMarkerFor(hash, ticketToken)}${TOKEN_LINE_SEPARATOR}`; await execute( - `UPDATE contact_preferences SET ${column} = MAX(${column} - 1, 0), last_activity = ? WHERE contact_hash = ?`, - [nowMs(), hash], + `INSERT INTO contact_preferences + (contact_hash, last_activity, visits, ${column}, attendee_tokens_blob) + VALUES (?, ?, 1, 1, ?) + ON CONFLICT(contact_hash) DO UPDATE SET + visits = visits + 1, + ${column} = ${column} + 1, + last_activity = excluded.last_activity, + attendee_tokens_blob = attendee_tokens_blob || excluded.attendee_tokens_blob + WHERE INSTR(attendee_tokens_blob, ?) = 0`, + [hash, nowMs(), await encryptTokenEntry(hash, source, ticketToken), marker], ); }; -/** Append a ticket token to a contact's encrypted list without touching counts. */ -const addBookingToken = async ( - hash: string, - ticketToken: string, - source: BookingSource, -): Promise => { - await appendBookingToken(hash, source, ticketToken, null); +/** Collect the contact hashes for the non-empty email and phone on an order. */ +const orderContactHashes = ( + email: unknown, + phone: unknown, +): Promise => { + const contacts: [ContactChannel, string][] = []; + if (typeof email === "string" && email.trim()) { + contacts.push(["email", email]); + } + if (typeof phone === "string" && phone.trim()) { + contacts.push(["sms", phone]); + } + return Promise.all( + contacts.map(([channel, value]) => contactHash(channel, value)), + ); }; +/** Run one effect against every contact identity on an order. */ +const forEachOrderContact = + (run: (hash: string) => Promise) => + async (email: unknown, phone: unknown): Promise => { + await Promise.all((await orderContactHashes(email, phone)).map(run)); + }; + +/** Record one replay-safe visit and booking for every contact on an order. */ +export const recordOrderActivity = ( + email: unknown, + phone: unknown, + source: BookingSource, + ticketToken: string, +): Promise => + forEachOrderContact((hash) => + recordBookingActivity(hash, source, ticketToken), + )(email, phone); + /** Load a contact's encrypted token blob, or null when no row exists. */ const loadTokenBlob = async (hash: string): Promise => { const row = await queryOne<{ attendee_tokens_blob: string }>( diff --git a/src/shared/db/groups.ts b/src/shared/db/groups.ts index 2fc472c2df..6b814da57a 100644 --- a/src/shared/db/groups.ts +++ b/src/shared/db/groups.ts @@ -19,6 +19,7 @@ import { execute, executeBatch, inPlaceholders, + matchingIdSet, queryAll, queryIdColumn, rowExists, @@ -387,21 +388,19 @@ export const anyListingInPackageGroup = async ( * group (`is_package = 1`) with `hide_package_listings = 1`. Buyers must never * meet these members standalone: the package name is the only public surface, * so every buyer-facing discovery/direct path drops them. */ -export const getHiddenPackageMemberIds = async ( +export const getHiddenPackageMemberIds = ( listingIds: readonly number[], -): Promise> => { - if (listingIds.length === 0) return new Set(); - const rows = await queryAll<{ listing_id: number }>( - `SELECT DISTINCT groupListing.listing_id - FROM group_listings AS groupListing - JOIN groups AS groupRow ON groupRow.id = groupListing.group_id - WHERE groupListing.listing_id IN (${inPlaceholders(listingIds)}) - AND groupRow.is_package = 1 - AND groupRow.hide_package_listings = 1`, - [...listingIds], +): Promise> => + matchingIdSet( + listingIds, + (placeholders) => + `SELECT DISTINCT groupListing.listing_id AS id + FROM group_listings AS groupListing + JOIN groups AS groupRow ON groupRow.id = groupListing.group_id + WHERE groupListing.listing_id IN (${placeholders}) + AND groupRow.is_package = 1 + AND groupRow.hide_package_listings = 1`, ); - return new Set(rows.map((r) => r.listing_id)); -}; /** Whether a single listing is a HIDDEN package's member — the one-listing form * of `getHiddenPackageMemberIds`, for the buyer-facing guards (API lookup, QR diff --git a/src/shared/db/listing-prices.ts b/src/shared/db/listing-prices.ts index 13c5647150..a11d1eee54 100644 --- a/src/shared/db/listing-prices.ts +++ b/src/shared/db/listing-prices.ts @@ -34,8 +34,8 @@ import { execute, executeBatch, inPlaceholders, - queryBatchPrimary, queryIdColumn, + queryOnePrimary, type TxScope, } from "#shared/db/client.ts"; import { type DayPrices, parseDayPrices } from "#shared/types.ts"; @@ -413,17 +413,14 @@ export const syncListingPricesForIds = async ( /** Re-sync one listing's `base` row from its current `unit_price` column. Called * after every listing insert/update (the form/API `afterCommit`) so the mirror - * never drifts from the column. The source row is read on the primary - * (write-mode batch) so it reflects the just-committed write rather than a - * lagging replica. A missing listing is a no-op. Day-count rows are written from - * input by the write paths, not re-derived here. */ + * never drifts from the column. The source row is read on the primary so it + * reflects the just-committed write rather than a lagging replica. A missing + * listing is a no-op. Day-count rows are written from input by the write paths, + * not re-derived here. */ export const syncListingPrices = async (listingId: number): Promise => { - const [result] = await queryBatchPrimary([ - { - args: [listingId], - sql: "SELECT id, unit_price FROM listings WHERE id = ?", - }, - ]); - const row = (result?.rows as unknown as ListingPriceSourceRow[])[0]; + const row = await queryOnePrimary( + "SELECT id, unit_price FROM listings WHERE id = ?", + [listingId], + ); if (row) await executeBatch(sourceRowStatements(row)); }; diff --git a/src/shared/db/listings/delete.ts b/src/shared/db/listings/delete.ts index 247051b837..f3d1f70c79 100644 --- a/src/shared/db/listings/delete.ts +++ b/src/shared/db/listings/delete.ts @@ -1,5 +1,6 @@ /** Listing deletion and owned-row cleanup. */ +import { OPEN_CHECKOUT_STAGE_SQL } from "#shared/db/checkout-stage-state.ts"; import { executeBatch } from "#shared/db/client.ts"; import { clearImageUsesForItemStatement } from "#shared/db/images.ts"; import { clearItemEdgesStatement } from "#shared/db/site-page-items.ts"; @@ -9,7 +10,19 @@ export const deleteListing = async (listingId: number): Promise => { await executeBatch([ { args: [listingId], - sql: "DELETE FROM listing_attendees WHERE listing_id = ?", + // Keep the rows of an attendee whose checkout is still mid-payment. The + // delete guard (listingDeleteError) blocks a delete while a pending + // checkout exists, but it is a preflight: a stage inserted in the window + // between that read and this delete would otherwise have its booking rows + // cascaded away, stranding the paid order on an empty staged record when + // the payment lands. Preserving the (quantity-0) rows keeps that order + // whole; the checkout resolves or the prune removes the attendee outright. + sql: `DELETE FROM listing_attendees + WHERE listing_id = ? + AND attendee_id NOT IN ( + SELECT attendee_id FROM checkout_stages + WHERE state ${OPEN_CHECKOUT_STAGE_SQL} + )`, }, { args: [listingId], diff --git a/src/shared/db/migrations.ts b/src/shared/db/migrations.ts index f7b6a0e223..e05ecd69c5 100644 --- a/src/shared/db/migrations.ts +++ b/src/shared/db/migrations.ts @@ -284,11 +284,11 @@ export const rebuildWipedSchema = async (): Promise => { logDebug("Migration", "Rebuilding wiped database from current schema"); await executeBatch(noArgStatements(fullSchemaCreateStatements())); // A compound CREATE TRIGGER … BEGIN … END body carries internal semicolons - // that batch transports mis-split, so triggers run one by one, exactly as - // syncTriggers sends them. - for (const trigger of TRIGGERS) { - await runMigration(trigger.sql); - } + // that batch transports mis-split. executeMultiple parses complete SQL + // scripts, so every idempotent trigger is rebuilt in one fixed-cost call. + await getDb().executeMultiple( + TRIGGERS.map((trigger) => `${trigger.sql};`).join("\n"), + ); await sealFreshSchema(); }; diff --git a/src/shared/db/migrations/2026-07-12_checkout_stages.ts b/src/shared/db/migrations/2026-07-12_checkout_stages.ts new file mode 100644 index 0000000000..1f9d0119ef --- /dev/null +++ b/src/shared/db/migrations/2026-07-12_checkout_stages.ts @@ -0,0 +1,35 @@ +import { schemaMigration } from "./define.ts"; + +export default schemaMigration( + "2026-07-12_checkout_stages", + "Add checkout stages so paid orders stay at quantity zero before payment, backups can certify a stable stage snapshot, and payment reservations cannot finish onto another attendee.", + { + columns: { + processed_payments: ["checkout_stage_attendee_id"], + }, + indexes: [ + "idx_checkout_stages_attendee_id", + "idx_checkout_stages_state_created_at", + ], + newTables: ["checkout_stage_revisions", "checkout_stages"], + triggers: [ + "trg_checkout_stages_revision_insert", + "trg_checkout_stages_revision_update", + "trg_checkout_stages_revision_delete", + "trg_processed_payments_checkout_stage_claim_insert", + "trg_processed_payments_checkout_stage_finalize_insert", + "trg_processed_payments_checkout_stage_finalize_update", + ], + }, + async ({ getDb }) => { + await getDb().execute(`UPDATE processed_payments + SET checkout_stage_attendee_id = ( + SELECT stage.attendee_id FROM checkout_stages AS stage + WHERE stage.payment_session_id = processed_payments.payment_session_id + ) + WHERE EXISTS ( + SELECT 1 FROM checkout_stages AS stage + WHERE stage.payment_session_id = processed_payments.payment_session_id + )`); + }, +); diff --git a/src/shared/db/migrations/registry.ts b/src/shared/db/migrations/registry.ts index 32166914f4..12ace0ef17 100644 --- a/src/shared/db/migrations/registry.ts +++ b/src/shared/db/migrations/registry.ts @@ -336,6 +336,11 @@ export const MIGRATION_REGISTRY: MigrationRegistryEntry[] = [ "2026-07-12_remove_broken_image_records", () => import("./2026-07-12_remove_broken_image_records.ts"), ), + // Store fresh paid orders at quantity zero before leaving for the provider. + entry( + "2026-07-12_checkout_stages", + () => import("./2026-07-12_checkout_stages.ts"), + ), ]; /* jscpd:ignore-end */ diff --git a/src/shared/db/migrations/schema-sync.ts b/src/shared/db/migrations/schema-sync.ts index 9c28c48f98..6ecc01e6a7 100644 --- a/src/shared/db/migrations/schema-sync.ts +++ b/src/shared/db/migrations/schema-sync.ts @@ -201,46 +201,14 @@ export const rebuildTableWithColumns = async ( await executeBatch(noArgStatements(rebuildStatements(params))); }; -const LISTING_AGGREGATE_TRIGGER_DEPENDENCIES = [ - "attendees", - "listings", - "listing_attendees", -] as const; - -const LISTING_AGGREGATE_TRIGGER_COLUMN_DEPENDENCIES = [ - ["attendees", ["kind"]], - ["listings", ["booked_quantity", "tickets_count"]], - ["listing_attendees", ["attendee_id", "listing_id", "quantity"]], -] as const; - -const isListingAggregateTrigger = (triggerName: string): boolean => - triggerName.startsWith("trg_listing_attendees_aggregates_"); - -const triggerDependencies = (triggerName: string, table: string): string[] => - isListingAggregateTrigger(triggerName) - ? [...LISTING_AGGREGATE_TRIGGER_DEPENDENCIES] - : [table]; - -const triggerColumnDependencies = ( - triggerName: string, - table: string, -): readonly (readonly [string, readonly string[]])[] => - isListingAggregateTrigger(triggerName) - ? LISTING_AGGREGATE_TRIGGER_COLUMN_DEPENDENCIES - : [[table, []]]; - -const requiredTriggerColumns = ( - triggerName: string, - table: string, - dependency: string, -): readonly string[] => - triggerColumnDependencies(triggerName, table).find( - ([dependencyTable]) => dependencyTable === dependency, - )![1]; +const triggerDependencies = ( + trigger: (typeof TRIGGERS)[number], +): Record => + trigger.dependencies ?? { [trigger.table]: [] }; const triggersDependingOn = (tableName: string) => TRIGGERS.filter((trigger) => - triggerDependencies(trigger.name, trigger.table).includes(tableName), + Object.hasOwn(triggerDependencies(trigger), tableName), ); const liveColumnsForTriggerDependency = ( @@ -253,24 +221,23 @@ const liveColumnsForTriggerDependency = ( : liveTables.get(dependency); const canCreateTrigger = ( - triggerName: string, - table: string, + trigger: (typeof TRIGGERS)[number], liveTables: LiveTables, rebuildingTable?: string, ): boolean => - triggerDependencies(triggerName, table).every((dependency) => { - const columns = liveColumnsForTriggerDependency( - dependency, - liveTables, - rebuildingTable, - ); - return ( - columns !== undefined && - requiredTriggerColumns(triggerName, table, dependency).every((column) => - columns.has(column), - ) - ); - }); + Object.entries(triggerDependencies(trigger)).every( + ([dependency, requiredColumns]) => { + const columns = liveColumnsForTriggerDependency( + dependency, + liveTables, + rebuildingTable, + ); + return ( + columns !== undefined && + requiredColumns.every((column) => columns.has(column)) + ); + }, + ); /** * Recreate a table from its SCHEMA definition, preserving data for matching @@ -327,7 +294,7 @@ export const recreateTable = async (tableName: string): Promise => { ...TRIGGERS.filter( (trg) => (trg.table === tableName || liveDependentTriggerNames.has(trg.name)) && - canCreateTrigger(trg.name, trg.table, live.tables, tableName), + canCreateTrigger(trg, live.tables, tableName), ).map((trg) => trg.sql), ]; await withTransaction(async (tx) => { @@ -548,10 +515,7 @@ export const syncTriggers: () => Promise = fromLiveSchema( async (live) => { const declaredNames = new Set(TRIGGERS.map((t) => t.name)); for (const trg of TRIGGERS) { - if ( - !live.triggers.has(trg.name) && - canCreateTrigger(trg.name, trg.table, live.tables) - ) { + if (!live.triggers.has(trg.name) && canCreateTrigger(trg, live.tables)) { await runMigration(trg.sql); } } diff --git a/src/shared/db/migrations/schema/checkout-stage-triggers.ts b/src/shared/db/migrations/schema/checkout-stage-triggers.ts new file mode 100644 index 0000000000..d75392602a --- /dev/null +++ b/src/shared/db/migrations/schema/checkout-stage-triggers.ts @@ -0,0 +1,91 @@ +import { OPEN_CHECKOUT_STAGE_SQL } from "#shared/db/checkout-stage-state.ts"; +import type { Trigger } from "./types.ts"; + +const STAGE_COLUMNS = ["payment_session_id", "attendee_id", "state"] as const; + +const REVISION_DEPENDENCIES = { + checkout_stage_revisions: ["id", "revision"], + checkout_stages: STAGE_COLUMNS, +}; + +const revisionTrigger = (action: "INSERT" | "UPDATE" | "DELETE"): Trigger => ({ + dependencies: REVISION_DEPENDENCIES, + name: `trg_checkout_stages_revision_${action.toLowerCase()}`, + sql: `CREATE TRIGGER IF NOT EXISTS trg_checkout_stages_revision_${action.toLowerCase()} +AFTER ${action} ON checkout_stages +FOR EACH ROW +BEGIN + INSERT INTO checkout_stage_revisions (id, revision) VALUES (1, 1) + ON CONFLICT(id) DO UPDATE SET revision = revision + 1; +END`, + table: "checkout_stages", +}); + +const PAYMENT_DEPENDENCIES = { + checkout_stages: STAGE_COLUMNS, + processed_payments: [ + "payment_session_id", + "attendee_id", + "checkout_stage_attendee_id", + ], +}; + +/** Stable snapshots and rollback safety both depend on database-level fences: + * old code cannot reserve around a staged attendee, and no payment can finish + * an open stage onto a different attendee. */ +export const CHECKOUT_STAGE_TRIGGERS: Trigger[] = [ + revisionTrigger("INSERT"), + revisionTrigger("UPDATE"), + revisionTrigger("DELETE"), + { + dependencies: PAYMENT_DEPENDENCIES, + name: "trg_processed_payments_checkout_stage_claim_insert", + sql: `CREATE TRIGGER IF NOT EXISTS trg_processed_payments_checkout_stage_claim_insert +BEFORE INSERT ON processed_payments +FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM checkout_stages AS stage + WHERE stage.payment_session_id = NEW.payment_session_id + AND (NEW.checkout_stage_attendee_id IS NULL + OR NEW.checkout_stage_attendee_id != stage.attendee_id) +) +BEGIN + SELECT RAISE(ABORT, 'checkout stage claim does not match'); +END`, + table: "processed_payments", + }, + { + dependencies: PAYMENT_DEPENDENCIES, + name: "trg_processed_payments_checkout_stage_finalize_insert", + sql: `CREATE TRIGGER IF NOT EXISTS trg_processed_payments_checkout_stage_finalize_insert +BEFORE INSERT ON processed_payments +FOR EACH ROW +WHEN NEW.attendee_id IS NOT NULL AND EXISTS ( + SELECT 1 FROM checkout_stages AS stage + WHERE stage.payment_session_id = NEW.payment_session_id + AND stage.state ${OPEN_CHECKOUT_STAGE_SQL} + AND stage.attendee_id != NEW.attendee_id +) +BEGIN + SELECT RAISE(ABORT, 'open checkout stage belongs to another attendee'); +END`, + table: "processed_payments", + }, + { + dependencies: PAYMENT_DEPENDENCIES, + name: "trg_processed_payments_checkout_stage_finalize_update", + sql: `CREATE TRIGGER IF NOT EXISTS trg_processed_payments_checkout_stage_finalize_update +BEFORE UPDATE OF attendee_id ON processed_payments +FOR EACH ROW +WHEN NEW.attendee_id IS NOT NULL AND EXISTS ( + SELECT 1 FROM checkout_stages AS stage + WHERE stage.payment_session_id = NEW.payment_session_id + AND stage.state ${OPEN_CHECKOUT_STAGE_SQL} + AND stage.attendee_id != NEW.attendee_id +) +BEGIN + SELECT RAISE(ABORT, 'open checkout stage belongs to another attendee'); +END`, + table: "processed_payments", + }, +]; diff --git a/src/shared/db/migrations/schema/tables-attendees.ts b/src/shared/db/migrations/schema/tables-attendees.ts index a28cbc5ade..e91e7c3440 100644 --- a/src/shared/db/migrations/schema/tables-attendees.ts +++ b/src/shared/db/migrations/schema/tables-attendees.ts @@ -160,12 +160,48 @@ export const attendeeTables: [name: string, table: Table][] = [ }, ], + [ + "checkout_stage_revisions", + { + columns: [ + ["id", "INTEGER PRIMARY KEY CHECK (id = 1)"], + ["revision", "INTEGER NOT NULL"], + ], + }, + ], + + [ + "checkout_stages", + { + columns: [ + ["payment_session_id", "TEXT PRIMARY KEY"], + ["attendee_id", "INTEGER NOT NULL"], + ["provider", "TEXT NOT NULL"], + ["ticket_tokens", "TEXT NOT NULL"], + ["state", "TEXT NOT NULL"], + ["created_at", "TEXT NOT NULL"], + ], + indexes: [ + { + columns: ["attendee_id"], + name: "idx_checkout_stages_attendee_id", + unique: true, + }, + { + columns: ["state", "created_at"], + name: "idx_checkout_stages_state_created_at", + }, + ], + }, + ], + [ "processed_payments", { columns: [ ["payment_session_id", "TEXT PRIMARY KEY"], ["attendee_id", "INTEGER"], + ["checkout_stage_attendee_id", "INTEGER"], ["processed_at", "TEXT NOT NULL"], ["ticket_tokens", "TEXT NOT NULL DEFAULT ''"], ["failure_data", "TEXT NOT NULL DEFAULT ''"], diff --git a/src/shared/db/migrations/schema/triggers.ts b/src/shared/db/migrations/schema/triggers.ts index 7b300f8b21..9df829a873 100644 --- a/src/shared/db/migrations/schema/triggers.ts +++ b/src/shared/db/migrations/schema/triggers.ts @@ -1,5 +1,6 @@ /** Triggers that keep precomputed aggregates in step with their source tables. */ +import { CHECKOUT_STAGE_TRIGGERS } from "./checkout-stage-triggers.ts"; import { LISTING_AGGREGATE_WRITE_COLUMNS, ticketCountPredicateFor, @@ -37,6 +38,12 @@ const ticketCountTriggerDelta = (row: "NEW" | "OLD"): string => * nothing); tickets_count counts only quantity > 0 rows, so the sentinel keeps * its attendee↔listing link without inflating the ticket total. */ +const LISTING_AGGREGATE_DEPENDENCIES = { + attendees: ["kind"], + listing_attendees: ["attendee_id", "listing_id", "quantity"], + listings: ["booked_quantity", "tickets_count"], +}; + const LISTING_AGGREGATE_TRIGGERS: Trigger[] = [ { name: "trg_listing_attendees_aggregates_insert", @@ -83,7 +90,10 @@ BEGIN END`, table: "listing_attendees", }, -]; +].map((trigger) => ({ + ...trigger, + dependencies: LISTING_AGGREGATE_DEPENDENCIES, +})); /** * Modifier aggregate triggers keep modifiers.total_uses and modifiers.usage_count @@ -267,4 +277,5 @@ export const TRIGGERS: Trigger[] = [ ...ANSWER_AGGREGATE_TRIGGERS, ...ATTENDEE_ANSWER_VALIDATION_TRIGGERS, ...STRING_AGGREGATE_TRIGGERS, + ...CHECKOUT_STAGE_TRIGGERS, ]; diff --git a/src/shared/db/migrations/schema/types.ts b/src/shared/db/migrations/schema/types.ts index faa2cba267..29ec2d584c 100644 --- a/src/shared/db/migrations/schema/types.ts +++ b/src/shared/db/migrations/schema/types.ts @@ -22,6 +22,10 @@ export type Table = { * `sql` is the full idempotent `CREATE TRIGGER IF NOT EXISTS …` statement. */ export type Trigger = { + /** Every table/column the trigger reads or writes. Trigger sync waits until + * all are live, so an earlier migration cannot install a trigger whose body + * refers to a table or column a later migration has not added yet. */ + dependencies?: Record; name: string; table: string; sql: string; diff --git a/src/shared/db/migrations/schema/version.ts b/src/shared/db/migrations/schema/version.ts index bee03413bb..df07e69813 100644 --- a/src/shared/db/migrations/schema/version.ts +++ b/src/shared/db/migrations/schema/version.ts @@ -1,6 +1,6 @@ /** Schema version label and the migrations bookkeeping table name. */ export const LATEST_UPDATE = - "Delete image records whose stored filename is an encrypted empty string."; + "Stage paid orders at quantity zero before payment."; export const SCHEMA_MIGRATIONS_TABLE = "schema_migrations"; diff --git a/src/shared/db/modifier-usage.ts b/src/shared/db/modifier-usage.ts index ae4ddc30b9..0605368164 100644 --- a/src/shared/db/modifier-usage.ts +++ b/src/shared/db/modifier-usage.ts @@ -9,8 +9,8 @@ */ import type { InValue } from "@libsql/client"; -import type { SqlStatement } from "#shared/db/client.ts"; -import { columnMapByIds, mapByIds } from "#shared/db/query.ts"; +import { queryOnePrimary, type SqlStatement } from "#shared/db/client.ts"; +import { mapByIds } from "#shared/db/query.ts"; import { nowIso } from "#shared/now.ts"; /** One modifier consumed by an order: the modifier, how many, and the amount @@ -97,6 +97,28 @@ export const usageInsert = ( WHERE ${guard.sql}`, }); +/** Insert every chosen modifier use in one statement for a guarded booking + * batch. Stock was checked by the batch verdict under the same write lock. */ +export const usageBatchInsert = ( + usages: ModifierUsage[], + attendeeId: number, +): SqlStatement => { + if (usages.length === 0) { + throw new Error("Cannot build a modifier usage insert with no rows"); + } + return { + args: [attendeeId, nowIso(), JSON.stringify(usages)], + sql: `INSERT INTO modifier_usages + (modifier_id, attendee_id, quantity, amount_applied, created) + SELECT CAST(json_extract(usage.value, '$.modifierId') AS INTEGER), + ?, + CAST(json_extract(usage.value, '$.quantity') AS INTEGER), + CAST(json_extract(usage.value, '$.amountApplied') AS INTEGER), + ? + FROM json_each(?) AS usage`, + }; +}; + /** * Whether any of these modifiers no longer has stock for its quantity — the * post-failure probe that tells a booking that failed to land *why*: a sold-out @@ -106,18 +128,21 @@ export const usageInsert = ( export const anyModifierSoldOut = async ( usages: ModifierUsage[], ): Promise => { - const ids = usages.map((u) => u.modifierId); - if (ids.length === 0) return false; - const used = await modifierUsedQuantities(ids); - const stockById = await columnMapByIds( - "modifiers", - "modifier", - "stock", - ids, - ); - return usages.some((u) => { - const stock = stockById.get(u.modifierId); - if (stock === null || stock === undefined) return false; - return stock - (used.get(u.modifierId) ?? 0) < u.quantity; - }); + if (usages.length === 0) return false; + const row = (await queryOnePrimary<{ sold_out: number }>( + `SELECT EXISTS ( + SELECT 1 + FROM json_each(?) AS requestedUsage + JOIN modifiers AS modifier + ON modifier.id = json_extract(requestedUsage.value, '$.modifierId') + WHERE modifier.stock IS NOT NULL + AND modifier.stock - COALESCE(( + SELECT SUM(storedUsage.quantity) + FROM modifier_usages AS storedUsage + WHERE storedUsage.modifier_id = modifier.id + ), 0) < CAST(json_extract(requestedUsage.value, '$.quantity') AS INTEGER) + ) AS sold_out`, + [JSON.stringify(usages)], + ))!; + return row.sold_out === 1; }; diff --git a/src/shared/db/orphan-attendees.ts b/src/shared/db/orphan-attendees.ts index 55e7131d5a..9c4cce2204 100644 --- a/src/shared/db/orphan-attendees.ts +++ b/src/shared/db/orphan-attendees.ts @@ -50,9 +50,10 @@ export const countOrphanedAttendees = async ( /** * Delete orphaned attendees whose `created` is before `cutoffIso`, along with - * their dependent rows, in a single atomic batch. Dependents go first (they - * reference the attendee), then the attendees themselves. Returns how many - * attendee rows were removed. + * their dependent rows, in a single atomic batch — built on the one shared + * purge mechanism (delete.ts), so this is exactly "deleteAttendee applied to + * every orphan". Dependents go first (they reference the attendee), then the + * attendees themselves. Returns how many attendee rows were removed. */ export const purgeOrphanedAttendees = async ( cutoffIso: string, diff --git a/src/shared/db/payment-finalize.ts b/src/shared/db/payment-finalize.ts index 5f3629ccd9..c9e99c3e34 100644 --- a/src/shared/db/payment-finalize.ts +++ b/src/shared/db/payment-finalize.ts @@ -2,7 +2,10 @@ import type { InValue } from "@libsql/client"; import { attendeeOwedSubquery } from "#shared/accounting/projection-sql.ts"; import type { SqlStatement } from "#shared/db/client.ts"; import { encryptPaymentReference } from "#shared/db/payment-references.ts"; -import { UNRESOLVED_RESERVATION } from "#shared/db/processed-payments.ts"; +import { + encryptTicketTokens, + UNRESOLVED_RESERVATION, +} from "#shared/db/processed-payments.ts"; const buildFinalizeStatement = async ( attendeeId: number, @@ -30,14 +33,16 @@ export const batchFinalizeStatement = async ( attendeeIdArg: InValue, guard: SqlStatement, paymentReference: string, + ticketToken: string, ): Promise => ({ args: [ attendeeIdArg, + await encryptTicketTokens([ticketToken]), await encryptPaymentReference(paymentReference), sessionId, ...guard.args, ], - sql: `UPDATE processed_payments SET attendee_id = ${attendeeIdSql}, ticket_tokens = '', payment_reference = ? + sql: `UPDATE processed_payments SET attendee_id = ${attendeeIdSql}, ticket_tokens = ?, payment_reference = ? WHERE payment_session_id = ? AND ${UNRESOLVED_RESERVATION} AND ${guard.sql}`, }); diff --git a/src/shared/db/processed-payments.ts b/src/shared/db/processed-payments.ts index 414bb24489..7768173bc8 100644 --- a/src/shared/db/processed-payments.ts +++ b/src/shared/db/processed-payments.ts @@ -4,9 +4,8 @@ * Uses a two-phase locking pattern to prevent duplicate attendee creation: * 1. reserveSession() - Claims the session with NULL attendee_id * 2. createBookingAtomic() with batchFinalizeStatement() inside the same batch - * - Creates the attendee and sets attendee_id atomically, closing the crash - * window between creation and a separate finalize call. - * 3. (webhook only) setSessionTicketTokens() - Persists replay tokens. + * - Creates the attendee and stores its id and token atomically, closing the + * crash window before a separate finalize call. * * If reserveSession fails (session already claimed), we check if it's: * - Finalized (attendee_id set) → return success with existing attendee @@ -20,7 +19,7 @@ import type { EnvKeyEncrypted, OwnerKeyEncrypted, } from "#shared/crypto/sealed.ts"; -import { execute, insert, queryOne } from "#shared/db/client.ts"; +import { execute, queryOne } from "#shared/db/client.ts"; import { encryptPaymentReference } from "#shared/db/payment-references.ts"; import { STALE_RESERVATION_MS } from "#shared/limits.ts"; import { nowIso, nowMs } from "#shared/now.ts"; @@ -42,6 +41,9 @@ export const UNRESOLVED_RESERVATION = export type ProcessedPayment = { payment_session_id: string; attendee_id: number | null; + /** The staged attendee this reservation claimed, or null for sessions that + * never had a checkout stage (balance payments and pre-stage bookings). */ + checkout_stage_attendee_id: number | null; processed_at: string; /** Encrypted "+"-joined ticket tokens; "" while none are stored. */ ticket_tokens: EnvKeyEncrypted | ""; @@ -87,7 +89,7 @@ export const isSessionProcessed = ( sessionId: string, ): Promise => queryOne( - "SELECT payment_session_id, attendee_id, processed_at, ticket_tokens, failure_data, payment_reference, provider_refunded_at " + + "SELECT payment_session_id, attendee_id, checkout_stage_attendee_id, processed_at, ticket_tokens, failure_data, payment_reference, provider_refunded_at " + "FROM processed_payments WHERE payment_session_id = ?", [sessionId], ); @@ -163,12 +165,18 @@ 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); + // The stage lookup and reservation insert are one statement: an old script + // cannot race between reading the stage and claiming the session, and the + // insert trigger rejects legacy code that omits this claim. + await execute( + `INSERT INTO processed_payments + (payment_session_id, attendee_id, checkout_stage_attendee_id, processed_at) + VALUES (?, NULL, ( + SELECT stage.attendee_id FROM checkout_stages AS stage + WHERE stage.payment_session_id = ? + ), ?)`, + [sessionId, sessionId, nowIso()], + ); return { reserved: true }; } catch (e) { const errorMsg = String(e); @@ -202,30 +210,10 @@ export const reserveSession = async ( }; /** Encrypt a list of ticket tokens for storage, joining with "+". */ -const encryptTicketTokens = ( +export const encryptTicketTokens = ( ticketTokens: string[], ): Promise => encrypt(ticketTokens.join("+")); -/** - * Finalize a reserved session with the created attendee ID (second phase) - */ -export const finalizeSession = async ( - sessionId: string, - attendeeId: number, - ticketTokens: string[], - paymentReference: string, -): Promise => { - await execute( - "UPDATE processed_payments SET attendee_id = ?, ticket_tokens = ?, payment_reference = ? WHERE payment_session_id = ?", - [ - attendeeId, - await encryptTicketTokens(ticketTokens), - await encryptPaymentReference(paymentReference), - sessionId, - ], - ); -}; - /** * Heal a still-unresolved reservation by stamping `attendee_id`, leaving * `ticket_tokens` untouched. The ledger-replay path uses this: when a late @@ -244,7 +232,7 @@ export const finalizeSession = async ( export const finalizeSessionIfUnresolved = async ( sessionId: string, attendeeId: number, - paymentReference = "", + paymentReference: string, ): Promise => { const refClause = paymentReference ? ", payment_reference = ?" : ""; const refParams = paymentReference @@ -298,22 +286,6 @@ export const parseSessionFailure = async ( } }; -/** - * Store encrypted ticket tokens on an already-finalized session so later - * webhook replays can return them. Separated from batchFinalizeStatement so - * token encryption never holds the write lock open. No-op if the session was - * pruned. - */ -export const setSessionTicketTokens = async ( - sessionId: string, - ticketTokens: string[], -): Promise => { - await execute( - "UPDATE processed_payments SET ticket_tokens = ? WHERE payment_session_id = ?", - [await encryptTicketTokens(ticketTokens), sessionId], - ); -}; - /** * Decrypt the ticket_tokens field from a processed payment record. * Returns the plaintext token string (e.g. "tok1+tok2") or empty string. @@ -332,14 +304,3 @@ export const clearSessionTokens: (sessionId: string) => Promise = sessionIdWrite( "UPDATE processed_payments SET ticket_tokens = '' WHERE payment_session_id = ?", ); - -/** - * Get the attendee ID for an already-processed session - * Used to return success for idempotent webhook retries - */ -export const getProcessedAttendeeId = async ( - sessionId: string, -): Promise => { - const result = await isSessionProcessed(sessionId); - return result?.attendee_id ?? null; -}; diff --git a/src/shared/db/prune.ts b/src/shared/db/prune.ts index 2b1b1b967b..dddbc6fa37 100644 --- a/src/shared/db/prune.ts +++ b/src/shared/db/prune.ts @@ -3,6 +3,9 @@ * but whose contents are only useful for a short window. * * Tables pruned: + * - checkout_stages: abandoned pending attendees are removed after stale claims + * are released atomically; booked/failed replay guards are retained for the + * payment replay window, then removed without deleting the attendee. * - processed_payments: payment-session replay records. Failed rows and rows * that no longer hold a useful refund reference are pruned after the retry * window; unrefunded attendees keep their stored charge references. @@ -34,6 +37,7 @@ * pruned only when PRUNE_INTERVAL_MS has elapsed since its last run. */ +import { pruneCheckoutStageRows } from "#shared/db/checkout-stage-cleanup.ts"; import { execute } from "#shared/db/client.ts"; import { purgeOrphanedAttendees } from "#shared/db/orphan-attendees.ts"; import { writeRawBatch } from "#shared/db/settings/raw-writes.ts"; @@ -43,6 +47,7 @@ import { pruneExpiredInvites } from "#shared/db/users.ts"; import { taskIsDue } from "#shared/interval-gate.ts"; import { ADDRESS_CACHE_MS, + PRUNE_CHECKOUT_STAGES_RETENTION_MS, PRUNE_CONTACTS_RETENTION_MS, PRUNE_INTERVAL_MS, PRUNE_LOGINS_RETENTION_MS, @@ -51,6 +56,7 @@ import { PRUNE_SUMUP_RETENTION_MS, PRUNE_TOKENS_RETENTION_MS, PRUNE_UNUSED_STRINGS_RETENTION_MS, + STALE_RESERVATION_MS, } from "#shared/limits.ts"; import { logDebug } from "#shared/logger.ts"; import { nowMs } from "#shared/now.ts"; @@ -110,6 +116,16 @@ export const pruneSumupCheckouts = isoAgePruner( PRUNE_SUMUP_RETENTION_MS, ); +/** Delete old unpaid checkout attendees after atomically releasing only stale + * unresolved claims. Terminal stage replay guards use the processed-payment + * retention window, which outlives provider retries. */ +export const pruneCheckoutStages = (): Promise => + pruneCheckoutStageRows( + new Date(nowMs() - PRUNE_CHECKOUT_STAGES_RETENTION_MS).toISOString(), + new Date(nowMs() - STALE_RESERVATION_MS).toISOString(), + new Date(nowMs() - PRUNE_PAYMENTS_RETENTION_MS).toISOString(), + ); + /** Delete unreferenced encrypted free-text strings older than retention. */ export const pruneUnusedStrings = isoAgePruner( "DELETE FROM strings WHERE used_count = 0 AND created < ?", @@ -194,6 +210,13 @@ type PruneTask = { }; const PRUNE_TASKS = (): PruneTask[] => [ + { + field: "last_pruned_checkout_stages", + key: CONFIG_KEYS.LAST_PRUNED_CHECKOUT_STAGES, + lastRaw: settings.lastPrunedCheckoutStages, + name: "checkout_stages", + run: pruneCheckoutStages, + }, { field: "last_pruned_payments", key: CONFIG_KEYS.LAST_PRUNED_PAYMENTS, diff --git a/src/shared/db/settings.ts b/src/shared/db/settings.ts index e18c8bcbea..f4e3fe6a87 100644 --- a/src/shared/db/settings.ts +++ b/src/shared/db/settings.ts @@ -315,6 +315,9 @@ const settingsBase = { get webhookEndpointId(): string { return snap("stripe_webhook_endpoint_id"); }, + get webhookEventsVersion(): string { + return snap("stripe_webhook_events_version"); + }, get webhookSecret(): string { return snap("stripe_webhook_secret"); }, @@ -470,6 +473,9 @@ const settingsBase = { data.stripe_webhook_secret = config.secret; data.stripe_webhook_endpoint_id = config.endpointId; }, + webhookEventsVersion: plaintextUpdate( + CONFIG_KEYS.STRIPE_WEBHOOK_EVENTS_VERSION, + ), }, // --- SumUp writes --- sumup: { diff --git a/src/shared/limits.ts b/src/shared/limits.ts index 50770885b3..bb0ebba4ea 100644 --- a/src/shared/limits.ts +++ b/src/shared/limits.ts @@ -329,9 +329,10 @@ export const assertPaymentsRetentionSafe = (days: number): number => { return days; }; -/** Retention (days) for resolved processed_payments rows (default: 90). Floored - * at WEBHOOK_RETRY_WINDOW_DAYS so payment replay rows always outlive the provider - * webhook-retry window (a too-short value throws at startup). */ +/** Retention (days) for payment replay state (default: 90): resolved + * processed_payments rows and booked/failed checkout-stage guards share this + * window. Floored at WEBHOOK_RETRY_WINDOW_DAYS so both replay rails outlive the + * provider webhook-retry window (a too-short value throws at startup). */ export const PRUNE_PAYMENTS_RETENTION_DAYS = computedLimit( assertPaymentsRetentionSafe(readLimit("PRUNE_PAYMENTS_RETENTION_DAYS", 90)), 90, @@ -383,6 +384,55 @@ export const PRUNE_SUMUP_RETENTION_HOURS = limit( "hours", ); +/** Retention (days) for unpaid quantity-zero checkout stages (default: 7). + * Provider callbacks after cleanup use the normal no-stage booking path. + * Stages only need to outlive the longest completable checkout — Stripe + * sessions expire after CHECKOUT_SESSION_EXPIRY_MINUTES and SumUp's after 30 + * minutes, so a Stripe/SumUp-only deployment can safely lower this to 1 day. + * Square payment links never expire, so the default stays a conservative 7. + * Keep PRUNE_UNUSED_STRINGS_RETENTION_DAYS at least this long too, or a very + * late completion books fine but loses its free-text answers. */ +export const PRUNE_CHECKOUT_STAGES_RETENTION_DAYS = limit( + "PRUNE_CHECKOUT_STAGES_RETENTION_DAYS", + 7, + "Prune: pending checkout stage retention", + "days", +); + +/** + * Stripe rejects a checkout expiry outside 30 minutes–24 hours, judged by + * STRIPE'S clock when the request arrives — so the configurable range keeps a + * five-minute buffer inside both ends, or clock skew plus request latency + * could push an exact-boundary value out of range and fail every checkout. + * Checked at startup rather than on the first customer's checkout. + */ +export const assertCheckoutExpiryValid = (minutes: number): number => { + if (minutes < 35 || minutes > 24 * 60 - 5) { + throw new Error( + `CHECKOUT_SESSION_EXPIRY_MINUTES=${minutes} must be between 35 and 1435 ` + + "(Stripe allows 30 minutes to 24 hours, and a five-minute buffer " + + "inside each end absorbs clock skew).", + ); + } + return minutes; +}; + +/** + * How long a hosted Stripe checkout stays payable (minutes, default 60), + * applied as the session's expiry when it is created. After expiry the hosted + * page cannot complete and Stripe sends checkout.session.expired, which + * discards the staged details straight away. SumUp checkouts expire themselves + * at 30 minutes; Square payment links take no expiry and rely on the stage + * pruner alone. + */ +export const CHECKOUT_SESSION_EXPIRY_MINUTES = computedLimit( + assertCheckoutExpiryValid(readLimit("CHECKOUT_SESSION_EXPIRY_MINUTES", 60)), + 60, + "CHECKOUT_SESSION_EXPIRY_MINUTES", + "Checkout: Stripe session expiry", + "minutes", +); + /** * Retention (days) for encrypted string rows that have not been attached to an * attendee answer (default: 7). These are usually abandoned paid checkouts: @@ -489,6 +539,8 @@ export const PRUNE_LOGINS_RETENTION_MS = PRUNE_LOGINS_RETENTION_DAYS * DAY_MS; export const PRUNE_TOKENS_RETENTION_MS = PRUNE_TOKENS_RETENTION_DAYS * DAY_MS; export const PRUNE_SUMUP_RETENTION_MS = PRUNE_SUMUP_RETENTION_HOURS * 60 * 60 * 1000; +export const PRUNE_CHECKOUT_STAGES_RETENTION_MS = + PRUNE_CHECKOUT_STAGES_RETENTION_DAYS * DAY_MS; export const PRUNE_UNUSED_STRINGS_RETENTION_MS = PRUNE_UNUSED_STRINGS_RETENTION_DAYS * DAY_MS; export const PRUNE_CONTACTS_RETENTION_MS = diff --git a/src/shared/listings-actions.ts b/src/shared/listings-actions.ts index 4c3c041ddb..fd14c7cbc1 100644 --- a/src/shared/listings-actions.ts +++ b/src/shared/listings-actions.ts @@ -8,6 +8,8 @@ import { t } from "#i18n"; import { formatCurrency } from "#shared/currency.ts"; import { logActivity } from "#shared/db/activityLog.ts"; +import { listingHoldsUnreturnedCash } from "#shared/db/attendees/queries.ts"; +import { listingHasPendingCheckout } from "#shared/db/checkout-stages.ts"; import { getGroupIdsByListingIds, getGroupsById, @@ -475,6 +477,27 @@ export const deleteOrphanedAddOnError = ( ): Promise => deactivationOrphanedAddOnError(new Set([listingId])); +/** Why a listing can't be deleted right now, or null when it's safe. Blocks a + * delete that would orphan a child-scoped add-on (the shared reachability + * guard), one that would strand a booking still mid-payment (deleting a + * listing removes its booking rows but leaves the attendee, so a pending + * checkout's claim would find nothing and its payment could never land — + * the checkout clears itself when it finishes or expires, so that block is + * brief), or one whose attendee still holds unreturned conflict cash (the + * in-app refund needs the booking line the delete would cascade, so the held + * charge must be refunded first). */ +export const listingDeleteError = async ( + listingId: number, +): Promise => { + if (await listingHasPendingCheckout(listingId)) { + return t("admin.listing.pending_checkout_delete"); + } + if (await listingHoldsUnreturnedCash(listingId)) { + return t("admin.listing.held_cash_delete"); + } + return deleteOrphanedAddOnError(listingId); +}; + /** * Delete a listing: clean up its attachment, remove DB links, log activity. * Returns the listing that was deleted (for response formatting). diff --git a/src/shared/payments.ts b/src/shared/payments.ts index dce7159669..272b8143f3 100644 --- a/src/shared/payments.ts +++ b/src/shared/payments.ts @@ -330,6 +330,11 @@ export interface PaymentProvider { /** The webhook event type name that indicates a completed checkout */ readonly checkoutCompletedEventType: string; + /** The webhook event type for a checkout that expired unpaid, for providers + * whose hosted sessions have a deadline (Stripe). Absent means the provider + * never reports expiry and abandoned checkouts wait for the stage pruner. */ + readonly checkoutExpiredEventType?: string; + /** * Create a checkout session for one or more listings. * Returns a session ID and hosted checkout URL, or null on failure. @@ -339,6 +344,11 @@ export interface PaymentProvider { baseUrl: string, ): Promise; + /** Close a hosted checkout early so an old tab cannot pay after its staged + * details were discarded (the cancel page). Best-effort; absent when the + * provider has no way to close a session. */ + expireCheckoutSession?(sessionId: string): Promise; + /** * Check if a payment has been refunded via the provider API. * Used to refresh refund status from the edit attendee page. diff --git a/src/shared/refund-ledger.ts b/src/shared/refund-ledger.ts index 1047f3c1c4..8702e019fe 100644 --- a/src/shared/refund-ledger.ts +++ b/src/shared/refund-ledger.ts @@ -129,11 +129,19 @@ const computeAttendeeRefund = async ( } const orders = await coveredRefundGroups(legs, references); if (orders.length === 0) return { groups: [], posted: false }; - // Only auto-reverse a fully-paid account. If the attendee still owes (an - // unpaid reservation) or holds credit, a full provider refund can't map cleanly - // onto the ledger: reversing the account while a balance remains would strand - // receivables or over-refund it. Such cases go to a manual adjustment instead. - if (balanceOf(account)(legs) !== 0) return { groups: [], posted: false }; + // Only auto-reverse an account the reversal leaves at zero. Fully paid is + // the normal booking shape. The other clean shape is pure held cash — every + // covered leg is a provider payment (a staged conflict's held charge, never + // matched by a sale), so reversing returns exactly what we hold. Anything + // else — still owing, or credit mixed with sales — can't map cleanly: + // reversing would strand receivables or over-refund, so those go to a + // manual adjustment instead. + const pureHeldCash = orders.every((group) => + group.every(isProviderPaymentLeg), + ); + if (!pureHeldCash && balanceOf(account)(legs) !== 0) { + return { groups: [], posted: false }; + } const occurredAt = nowIso(); return { groups: await Promise.all( diff --git a/src/shared/seeds.ts b/src/shared/seeds.ts index fd0d161770..4428f7009d 100644 --- a/src/shared/seeds.ts +++ b/src/shared/seeds.ts @@ -6,6 +6,7 @@ import { map, sum } from "#fp"; import { encrypt } from "#shared/crypto/encryption.ts"; import { hmacHash } from "#shared/crypto/hashing.ts"; +import { generateTicketToken } from "#shared/crypto/utils.ts"; import { VALID_DAY_NAMES } from "#shared/day-names.ts"; import { buildAttendeeInsert } from "#shared/db/attendees/create.ts"; import { encryptAttendeeFields } from "#shared/db/attendees/pii.ts"; @@ -131,15 +132,17 @@ const prepareAttendee = async ( const pricePaid = unitPrice * quantity; const paymentId = unitPrice > 0 ? `seed_${listingId}_${quantity}_${pricePaid}` : ""; - const enc = (await encryptAttendeeFields({ - address: randomChoice(DEMO_ADDRESSES), - email: randomChoice(DEMO_EMAILS), - name: randomChoice(DEMO_NAMES), - paymentId, - phone: randomChoice(DEMO_PHONES), - pricePaid, - special_instructions: randomChoice(DEMO_SPECIAL_INSTRUCTIONS), - }))!; + const enc = (await encryptAttendeeFields( + { + address: randomChoice(DEMO_ADDRESSES), + email: randomChoice(DEMO_EMAILS), + name: randomChoice(DEMO_NAMES), + paymentId, + phone: randomChoice(DEMO_PHONES), + special_instructions: randomChoice(DEMO_SPECIAL_INSTRUCTIONS), + }, + generateTicketToken(), + ))!; return [ buildAttendeeInsert(enc, { remainingBalance: 0, statusId: null }), diff --git a/src/shared/settings/keys.ts b/src/shared/settings/keys.ts index 1537fd9c31..14b10fe53d 100644 --- a/src/shared/settings/keys.ts +++ b/src/shared/settings/keys.ts @@ -42,6 +42,7 @@ export const CONFIG_KEY_NAMES = [ "LAST_ACTIVITY_LOG_BACKFILL", "LAST_PRUNED_ADDRESSES", "LAST_PRUNED_CONTACTS", + "LAST_PRUNED_CHECKOUT_STAGES", "LAST_PRUNED_INVITES", "LAST_PRUNED_LOGINS", "LAST_PRUNED_ORPHANS", @@ -74,6 +75,7 @@ export const CONFIG_KEY_NAMES = [ "SQUARE_WEBHOOK_SIGNATURE_KEY", "STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_ENDPOINT_ID", + "STRIPE_WEBHOOK_EVENTS_VERSION", "STRIPE_WEBHOOK_SECRET", "SUMUP_API_KEY", "SUMUP_MERCHANT_CODE", diff --git a/src/shared/settings/registry.ts b/src/shared/settings/registry.ts index 5d5410cfc9..6e2a02e3fe 100644 --- a/src/shared/settings/registry.ts +++ b/src/shared/settings/registry.ts @@ -102,6 +102,10 @@ export const STRING_SETTING_DEFINITIONS = [ key: CONFIG_KEYS.STRIPE_WEBHOOK_ENDPOINT_ID, storage: "plaintext", }), + setting({ + key: CONFIG_KEYS.STRIPE_WEBHOOK_EVENTS_VERSION, + storage: "plaintext", + }), setting({ key: CONFIG_KEYS.SUMUP_MERCHANT_CODE, storage: "plaintext" }), setting({ accessor: { name: "latestScriptVersion" }, @@ -139,6 +143,12 @@ export const STRING_SETTING_DEFINITIONS = [ key: CONFIG_KEYS.LAST_PRUNED_SESSIONS, storage: "plaintext", }), + setting({ + accessor: { name: "lastPrunedCheckoutStages" }, + key: CONFIG_KEYS.LAST_PRUNED_CHECKOUT_STAGES, + storage: "plaintext", + tags: ["prune"], + }), setting({ accessor: { name: "lastPrunedSumup" }, key: CONFIG_KEYS.LAST_PRUNED_SUMUP, diff --git a/src/shared/stripe-provider.ts b/src/shared/stripe-provider.ts index 58ef9c52f0..557711d605 100644 --- a/src/shared/stripe-provider.ts +++ b/src/shared/stripe-provider.ts @@ -21,6 +21,7 @@ import { } from "#shared/payments.ts"; import { createCheckoutSession, + expireCheckoutSession, isoFromUnixSeconds, retrieveCheckoutSession, retrievePaymentIntent, @@ -43,8 +44,14 @@ const createStripeCheckoutSession = makeCreateCheckoutSession( /** Stripe payment provider implementation */ export const stripePaymentProvider: PaymentProvider = { checkoutCompletedEventType: "checkout.session.completed", + + checkoutExpiredEventType: "checkout.session.expired", + createCheckoutSession: createStripeCheckoutSession, + expireCheckoutSession: (sessionId: string) => + expireCheckoutSession(sessionId), + async isPaymentRefunded(paymentReference: string): Promise { const intent = await retrievePaymentIntent(paymentReference); return intent?.latest_charge?.refunded ?? false; diff --git a/src/shared/stripe-webhook-events.ts b/src/shared/stripe-webhook-events.ts new file mode 100644 index 0000000000..924b327cc6 --- /dev/null +++ b/src/shared/stripe-webhook-events.ts @@ -0,0 +1,9 @@ +/** Required Stripe webhook events and the stored version proving they were + * reconciled. Kept dependency-free so every request can compare the marker + * without loading Stripe integration code. */ +export const REQUIRED_STRIPE_WEBHOOK_EVENTS = [ + "checkout.session.completed", + "checkout.session.expired", +] as const; + +export const STRIPE_WEBHOOK_EVENTS_VERSION = "checkout-session-expired-v1"; diff --git a/src/shared/stripe-webhook-reconcile.ts b/src/shared/stripe-webhook-reconcile.ts new file mode 100644 index 0000000000..51d98e7083 --- /dev/null +++ b/src/shared/stripe-webhook-reconcile.ts @@ -0,0 +1,168 @@ +/* jscpd:ignore-start */ +import type Stripe from "stripe"; +import { unique } from "#fp"; +import { executeWithoutCacheInvalidation } from "#shared/db/client.ts"; +import { settings } from "#shared/db/settings.ts"; +import { ErrorCode, logError } from "#shared/logger.ts"; +import { CONFIG_KEYS } from "#shared/settings/keys.ts"; +import { + createStripeWebhookEndpoint, + getStripeClient, + sanitizeErrorDetail, +} from "#shared/stripe.ts"; +import { + REQUIRED_STRIPE_WEBHOOK_EVENTS, + STRIPE_WEBHOOK_EVENTS_VERSION, +} from "#shared/stripe-webhook-events.ts"; + +/* jscpd:ignore-end */ + +const RECONCILE_LOCK_PREFIX = "pending:"; +const RECONCILE_LOCK_TTL_MS = 2 * 60 * 1000; + +const acquireReconcileLock = async (): Promise => { + const now = new Date(); + const stamp = `${RECONCILE_LOCK_PREFIX}${now.toISOString()}`; + const cutoff = new Date(now.getTime() - RECONCILE_LOCK_TTL_MS).toISOString(); + const result = await executeWithoutCacheInvalidation( + `INSERT INTO settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = ? + WHERE settings.value != ? + AND (settings.value NOT LIKE ? OR SUBSTR(settings.value, ?) < ?)`, + [ + CONFIG_KEYS.STRIPE_WEBHOOK_EVENTS_VERSION, + stamp, + stamp, + STRIPE_WEBHOOK_EVENTS_VERSION, + `${RECONCILE_LOCK_PREFIX}%`, + RECONCILE_LOCK_PREFIX.length + 1, + cutoff, + ], + ); + return result.rowsAffected === 1 ? stamp : null; +}; + +const releaseReconcileLock = async (stamp: string): Promise => { + await executeWithoutCacheInvalidation( + "DELETE FROM settings WHERE key = ? AND value = ?", + [CONFIG_KEYS.STRIPE_WEBHOOK_EVENTS_VERSION, stamp], + ); +}; + +const isMissingEndpoint = (error: unknown): boolean => + error instanceof Error && + "statusCode" in error && + error.statusCode === 404 && + "code" in error && + error.code === "resource_missing"; + +const getEndpointOrNull = async ( + client: Stripe, + endpointId: string, +): Promise => { + try { + return await client.webhookEndpoints.retrieve(endpointId); + } catch (error) { + if (isMissingEndpoint(error)) return null; + throw error; + } +}; + +const hasRequiredEvents = (endpoint: Stripe.WebhookEndpoint): boolean => + endpoint.enabled_events.includes("*") || + REQUIRED_STRIPE_WEBHOOK_EVENTS.every((event) => + endpoint.enabled_events.includes(event), + ); + +const updateExistingEndpoint = async ( + client: Stripe, + endpoint: Stripe.WebhookEndpoint, + webhookUrl: string, +): Promise => { + if (endpoint.url === webhookUrl && hasRequiredEvents(endpoint)) return; + const enabledEvents = endpoint.enabled_events.includes("*") + ? endpoint.enabled_events + : unique([...endpoint.enabled_events, ...REQUIRED_STRIPE_WEBHOOK_EVENTS]); + await client.webhookEndpoints.update(endpoint.id, { + // Stripe types its response as string[] but accepts the same documented + // values through a narrower request union. Preserve every existing event. + enabled_events: + enabledEvents as Stripe.WebhookEndpointUpdateParams.EnabledEvent[], + url: webhookUrl, + }); +}; + +const replaceEndpoint = async ( + client: Stripe, + webhookUrl: string, + staleEndpointId: string, +): Promise => { + if (staleEndpointId) { + try { + await client.webhookEndpoints.del(staleEndpointId); + } catch (error) { + if (!isMissingEndpoint(error)) throw error; + } + } + const endpoint = await createStripeWebhookEndpoint(client, webhookUrl); + await settings.update.stripe.webhookConfig({ + endpointId: endpoint.id, + secret: endpoint.secret, + }); +}; + +/** Reconcile one stored Stripe install after an event-set upgrade. A valid + * endpoint is updated in place, which preserves its signing secret. Replacement + * happens only when the recorded endpoint or its signing secret is gone. */ +export const reconcileStoredStripeWebhook = async ( + webhookUrl: string, +): Promise => { + if ( + settings.paymentProvider !== "stripe" || + settings.stripe.webhookEventsVersion === STRIPE_WEBHOOK_EVENTS_VERSION + ) { + return; + } + const lock = await acquireReconcileLock(); + if (!lock) return; + + try { + await settings.loadKeys([ + CONFIG_KEYS.STRIPE_SECRET_KEY, + CONFIG_KEYS.STRIPE_WEBHOOK_ENDPOINT_ID, + CONFIG_KEYS.STRIPE_WEBHOOK_SECRET, + ]); + const client = await getStripeClient(); + if (!client) { + await settings.update.stripe.webhookEventsVersion( + STRIPE_WEBHOOK_EVENTS_VERSION, + ); + return; + } + + const endpointId = settings.stripe.webhookEndpointId; + const endpoint = + endpointId && settings.stripe.webhookSecret + ? await getEndpointOrNull(client, endpointId) + : null; + if (endpoint) { + await updateExistingEndpoint(client, endpoint, webhookUrl); + } else { + await replaceEndpoint( + client, + webhookUrl, + settings.stripe.webhookSecret ? "" : endpointId, + ); + } + await settings.update.stripe.webhookEventsVersion( + STRIPE_WEBHOOK_EVENTS_VERSION, + ); + } catch (error) { + await releaseReconcileLock(lock); + logError({ + code: ErrorCode.STRIPE_WEBHOOK_SETUP, + detail: `reconcile ${sanitizeErrorDetail(error)}`, + error, + }); + } +}; diff --git a/src/shared/stripe.ts b/src/shared/stripe.ts index 2bfa48bcd8..0c8dcd595d 100644 --- a/src/shared/stripe.ts +++ b/src/shared/stripe.ts @@ -8,6 +8,7 @@ import { lazyRef, once } from "#fp"; import { priceCheckout } from "#shared/checkout-pricing.ts"; import { settings } from "#shared/db/settings.ts"; import { getEnv } from "#shared/env.ts"; +import { CHECKOUT_SESSION_EXPIRY_MINUTES } from "#shared/limits.ts"; import { ErrorCode, logDebug, logError } from "#shared/logger.ts"; import { nowSeconds } from "#shared/now.ts"; import { hmacSha256Hex, secureCompare } from "#shared/payment-crypto.ts"; @@ -28,6 +29,7 @@ import type { WebhookSetupResult, WebhookVerifyResult, } from "#shared/payments.ts"; +import { REQUIRED_STRIPE_WEBHOOK_EVENTS } from "#shared/stripe-webhook-events.ts"; import { finishWebhookVerification } from "#shared/webhook-verification.ts"; /** Lazy-load Stripe SDK only when needed */ @@ -185,6 +187,20 @@ type StripeCheckoutLineItem = NonNullable< Stripe.Checkout.SessionCreateParams["line_items"] >[number]; +/** Create the one webhook shape every Stripe setup uses and require its + * one-time signing secret before the endpoint can be stored. */ +export const createStripeWebhookEndpoint = async ( + client: Stripe, + webhookUrl: string, +): Promise => { + const endpoint = await client.webhookEndpoints.create({ + enabled_events: [...REQUIRED_STRIPE_WEBHOOK_EVENTS], + url: webhookUrl, + }); + if (!endpoint.secret) throw new Error("Stripe did not return webhook secret"); + return { ...endpoint, secret: endpoint.secret }; +}; + /** * Fetch the site's webhook endpoint records from Stripe (one page, max 100). */ @@ -232,16 +248,6 @@ const deleteEndpointsBestEffort = async ( } }; -/** Create the checkout-completion webhook endpoint at the given URL. */ -const createCheckoutWebhook = ( - client: Stripe, - webhookUrl: string, -): Promise => - client.webhookEndpoints.create({ - enabled_events: ["checkout.session.completed"], - url: webhookUrl, - }); - /** Check if a Stripe error looks like the webhook endpoint cap was reached. * The Stripe SDK always wraps fetch errors in an Error subclass, so `err` * is always an Error with a `.message` string. */ @@ -276,9 +282,9 @@ const setupWebhookEndpointImpl: SetupWebhookEndpoint = async ( try { const client = await createStripeClient(secretKey); - let endpoint: Stripe.WebhookEndpoint; + let endpoint: Stripe.WebhookEndpoint & { secret: string }; try { - endpoint = await createCheckoutWebhook(client, webhookUrl); + endpoint = await createStripeWebhookEndpoint(client, webhookUrl); } catch (err) { if (!isEndpointLimitError(err)) throw err; @@ -291,11 +297,7 @@ const setupWebhookEndpointImpl: SetupWebhookEndpoint = async ( if (existingEndpointId) { await deleteEndpointsBestEffort(client, [existingEndpointId]); } - endpoint = await createCheckoutWebhook(client, webhookUrl); - } - - if (!endpoint.secret) { - return { error: "Stripe did not return webhook secret", success: false }; + endpoint = await createStripeWebhookEndpoint(client, webhookUrl); } return { @@ -342,6 +344,7 @@ const cleanupOldWebhookEndpointsImpl = async ( * Production code uses stripeApi.method() to enable test mocking */ export const stripeApi: { + expireCheckoutSession: (sessionId: string) => Promise; cleanupOldWebhookEndpoints: ( secretKey: string, webhookUrl: string, @@ -413,6 +416,11 @@ export const stripeApi: { const params: Stripe.Checkout.SessionCreateParams = { cancel_url: `${baseUrl}/payment/cancel?session_id={CHECKOUT_SESSION_ID}`, + // A hard deadline on the hosted page: after this Stripe refuses payment + // and sends checkout.session.expired, which discards the staged details + // — so no money can arrive for a checkout we no longer hold. + expires_at: + Math.floor(Date.now() / 1000) + CHECKOUT_SESSION_EXPIRY_MINUTES * 60, line_items: lineItems, mode: "payment", payment_method_types: ["card"], @@ -434,6 +442,18 @@ export const stripeApi: { ); return session; }, + /** Close a hosted checkout early (the cancel page discarded its staged + * details). withClient already logs and swallows a session that cannot be + * expired — already completed or already expired — which is fine: this is + * best-effort, and a late payment on a session we failed to close simply + * books fresh. */ + expireCheckoutSession: async (sessionId: string): Promise => { + await withClient( + (s) => s.checkout.sessions.expire(sessionId), + ErrorCode.STRIPE_SESSION, + ); + }, + /** Get or create Stripe client */ getStripeClient: getClientImpl, @@ -553,6 +573,8 @@ export const retrieveCheckoutSession = (id: string) => export const retrievePaymentIntent = (id: string) => stripeApi.retrievePaymentIntent(id); export const refundPayment = (id: string) => stripeApi.refundPayment(id); +export const expireCheckoutSession = (id: string): Promise => + stripeApi.expireCheckoutSession(id); export const createCheckoutSession = (i: CheckoutIntent, b: string) => stripeApi.createCheckoutSession(i, b); export const testStripeConnection = () => stripeApi.testStripeConnection(); diff --git a/src/shared/types.ts b/src/shared/types.ts index d4b492b8eb..f7eeb94c91 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -190,8 +190,8 @@ export const MAX_DURATION_DAYS = 90; * agree by construction. Idempotent, so applying it to an already-normalized * value (e.g. a column-clamped `listing.duration_days`) is a safe no-op. */ -export const normalizeDurationDays = (value: number): number => - Number.isFinite(value) +export const normalizeDurationDays = (value: number | undefined): number => + typeof value === "number" && Number.isFinite(value) ? Math.max(1, Math.min(MAX_DURATION_DAYS, Math.floor(value))) : 1; @@ -508,6 +508,11 @@ export interface Attendee extends ContactInfo { * package by this persisted id. */ package_group_id: number; payment_id: string; + /** 0/1 SQL flag: 1 while this record's checkout is staged and the customer + * may still be paying. Projected per row from `checkout_stages` (a core + * column in `select.ts`), so every table and export can say "payment in + * progress" instead of the "No quantity" sentinel wording. */ + pending_checkout: number; /** Owner-key-encrypted PII blob as stored; "" only on a just-created * in-memory echo (see buildAttendeeResult), never in the database. */ pii_blob: OwnerKeyEncrypted | ""; @@ -850,6 +855,8 @@ export type AdminListing = Omit; /** One listing shown in an attendee row's Listings cell */ export type AttendeeRowListing = { + /** A deleted listing is shown as plain text because its detail route is gone. */ + deleted?: true; id: number; name: string; }; @@ -872,6 +879,7 @@ export type DisplayAttendee = Pick< | "kind" | "listing_id" | "name" + | "pending_checkout" | "phone" | "quantity" | "refunded" diff --git a/src/ui/templates/admin/attendee-detail.tsx b/src/ui/templates/admin/attendee-detail.tsx index 4f87e226ac..6fe4665caf 100644 --- a/src/ui/templates/admin/attendee-detail.tsx +++ b/src/ui/templates/admin/attendee-detail.tsx @@ -129,10 +129,18 @@ export const AttendeeBookingsTable = ({ } rows={bookings.map((booking) => [ <> - - {booking.listingName} - - + {booking.listingDeleted ? ( + // A deleted listing's page would 404 — never render a dead link, + // and "Deleted listing" already says why there's no name. + {booking.listingName} + ) : ( + <> + + {booking.listingName} + + + + )} {booking.parentListingId > 0 ? (
{t("attendee_detail.addon_under", { diff --git a/src/ui/templates/admin/attendee-form.tsx b/src/ui/templates/admin/attendee-form.tsx index c33eee8deb..b00979edb2 100644 --- a/src/ui/templates/admin/attendee-form.tsx +++ b/src/ui/templates/admin/attendee-form.tsx @@ -167,6 +167,76 @@ const lineRowClass = (line: AttendeeFormLine, booked: boolean): string => { : "attendee-line attendee-line-empty"; }; +/** One editor line plus its position in the rendered editor (the index every + * per-line form field is keyed by). */ +type LinePosition = { line: AttendeeFormLine; index: number }; + +/** Check-in / refunded badges from the line's stored row (none when new). */ +const lineBadges = (line: AttendeeFormLine): JSX.Element | null => + BookingStatusBadges({ + checkedIn: Boolean(line.existingBooking?.checked_in), + refunded: Boolean(line.existingBooking?.refunded), + }); + +/** The stored row's saved date range, or null when it has none. */ +const StoredRangeLabel = ({ + line, +}: { + line: AttendeeFormLine; +}): JSX.Element | null => + line.existingBooking?.start_at ? ( +
+ {formatDateRangeLabel( + line.existingBooking.start_at, + line.existingBooking.end_at, + )} +
+ ) : null; + +/** The hidden fields that identify a line on submit: its listing id, its + * existing row's key, and (for a new package-path line) the package it books + * through. */ +const LineIdentityInputs = ({ line, index }: LinePosition): JSX.Element => ( + <> + + + {line.packageGroupId > 0 ? ( + + ) : null} + +); + +/** A locked row for a stored booking whose listing no longer exists — a + * delete racing a mid-payment checkout keeps the rows. It submits itself + * unchanged (its identity fields plus a hidden no-quantity tick) and offers + * no controls: a deleted listing can't be re-booked, and a save deletes any + * row its form leaves out, so the row must always be submitted, locked. */ +const DeletedListingRow = ({ line, index }: LinePosition): JSX.Element => ( + + + {t("attendee_form.deleted_listing")} + {lineBadges(line)} + + + + {t("attendee_form.no_quantity")} + + + + + + + +); + /** One row of the listing editor — one booking path and its quantity box. */ const ListingRow = ({ line, @@ -179,7 +249,10 @@ const ListingRow = ({ warnings: string[]; data: AttendeeFormTemplateData; }): JSX.Element => { - const listing = line.listing!; + if (line.listing === null) { + return ; + } + const listing = line.listing; const booked = isRetainedLine(line) || Boolean(line.existingBooking); const isDaily = listing.listing_type === "daily"; const label = pathLabel(line, data); @@ -192,10 +265,7 @@ const ListingRow = ({ {label ? {label} : null} - {BookingStatusBadges({ - checkedIn: Boolean(line.existingBooking?.checked_in), - refunded: Boolean(line.existingBooking?.refunded), - })} + {lineBadges(line)} @@ -231,33 +301,10 @@ const ListingRow = ({ /> {t("attendee_form.no_quantity")} - - - {line.packageGroupId > 0 ? ( - - ) : null} + - {line.existingBooking?.start_at ? ( -
- {formatDateRangeLabel( - line.existingBooking.start_at, - line.existingBooking.end_at, - )} -
- ) : null} + {line.error ? {line.error} : null} {warnings.map((w) => ( )} + {pendingCheckout && ( + + )} ); diff --git a/src/ui/templates/admin/attendees.tsx b/src/ui/templates/admin/attendees.tsx index 1358d20a9e..9bdfd2c979 100644 --- a/src/ui/templates/admin/attendees.tsx +++ b/src/ui/templates/admin/attendees.tsx @@ -212,7 +212,7 @@ const attendeeConfirmPage = ( const attendeeRouteConfirm = (segment: string, config: Omit) => ( - { attendee }: { listing: ListingWithCount; attendee: Attendee }, + { attendee }: { attendee: Attendee }, session: AdminSession, returnUrl?: string, error?: string, diff --git a/src/ui/templates/attendee-table.tsx b/src/ui/templates/attendee-table.tsx index 68909a7487..0c5e8ee2ae 100644 --- a/src/ui/templates/attendee-table.tsx +++ b/src/ui/templates/attendee-table.tsx @@ -226,11 +226,15 @@ const createStatusRenderer = ); } // A no-quantity sentinel row stays visible but isn't checkable — show the - // indicator instead of a check-in button (updateCheckedIn refuses it). + // indicator instead of a check-in button (updateCheckedIn refuses it). A + // mid-payment staged row says so instead: "No quantity" invites an operator + // edit, and edits are blocked while the customer may still be paying. if (!hasTicketQuantity(row.attendee)) { return String( - {t("admin.attendee_table.no_quantity")} + {row.attendee.pending_checkout + ? t("admin.attendee_table.payment_in_progress") + : t("admin.attendee_table.no_quantity")} , ); } diff --git a/test/features/admin/attendee-form-deleted-listing.test.ts b/test/features/admin/attendee-form-deleted-listing.test.ts new file mode 100644 index 0000000000..8fda389202 --- /dev/null +++ b/test/features/admin/attendee-form-deleted-listing.test.ts @@ -0,0 +1,107 @@ +/** + * Unit tests for the deleted-listing locked line: a stored booking row can + * outlive its listing (a delete racing a mid-payment checkout keeps the + * rows), and the editor keeps that row as a permanent no-quantity line — + * retained on save, its stored range untouched, and impossible to un-lock. + */ + +import { expect } from "@std/expect"; +import { describe, it as test } from "@std/testing/bdd"; +import { + isBookedLine, + isNoQuantityLine, + isRetainedLine, + toDesiredLines, + validateParsedForm, +} from "#routes/admin/attendee-form-model.ts"; +import { bookingRow, line, parsedBase } from "./attendee-form-fixtures.ts"; + +/** A locked editor line: a stored row whose listing no longer resolves. */ +const deletedListingLine = ( + overrides: Parameters[0] = {}, +): ReturnType => + line({ + existingBooking: bookingRow({ listing_id: 9, quantity: 0 }), + key: "9||0|0", + listing: null, + listingId: 9, + noQuantity: true, + quantity: 0, + ...overrides, + }); + +describe("deleted-listing locked line", () => { + test("a deleted listing's stored row is retained as a no-quantity line", () => { + const kept = deletedListingLine(); + expect(isBookedLine(kept)).toBe(false); + expect(isNoQuantityLine(kept)).toBe(true); + expect(isRetainedLine(kept)).toBe(true); + }); + + test("keeps its stored date range instead of the shared range", () => { + const desired = toDesiredLines( + parsedBase({ + dayCount: 3, + lines: [ + deletedListingLine({ + existingBooking: bookingRow({ + end_at: "2026-03-03T00:00:00.000Z", + listing_id: 9, + quantity: 0, + start_at: "2026-03-01T00:00:00.000Z", + }), + key: "9|2026-03-01T00:00:00.000Z|0|0", + }), + ], + startDate: "2026-05-05", + }), + ); + // The shared date fields don't touch a listing that no longer exists — + // the desired line re-states the row's own stored range, so the atomic + // edit sees an unchanged preserve. + expect(desired).toEqual([ + { + date: "2026-03-01", + durationDays: 2, + exists: true, + key: "9|2026-03-01T00:00:00.000Z|0|0", + listingId: 9, + packageGroupId: 0, + parentListingId: 0, + quantity: 0, + }, + ]); + }); + + test("a range-less stored row stays range-less", () => { + const desired = toDesiredLines( + parsedBase({ + lines: [deletedListingLine()], + startDate: "2026-05-05", + }), + ); + expect(desired[0]!.date).toBeNull(); + expect(desired[0]!.durationDays).toBe(1); + }); + + test("validation rejects un-ticking no-quantity on a deleted listing's row", () => { + const result = validateParsedForm( + parsedBase({ + lines: [deletedListingLine({ noQuantity: false, quantity: 1 })], + }), + ); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.formError).toBe( + "This record has a booking for a deleted listing. That line is locked and cannot be changed.", + ); + } + }); + + test("validation accepts the locked row when its no-quantity tick is kept", () => { + const result = validateParsedForm( + parsedBase({ lines: [deletedListingLine()] }), + ); + expect(result.valid).toBe(true); + }); +}); diff --git a/test/features/admin/attendee-form-fixtures.ts b/test/features/admin/attendee-form-fixtures.ts new file mode 100644 index 0000000000..b3ad848690 --- /dev/null +++ b/test/features/admin/attendee-form-fixtures.ts @@ -0,0 +1,62 @@ +/** + * Shared fixtures for the attendee form-model unit tests: one editor line, + * one stored booking row, and one parsed form, each with sensible defaults + * and per-test overrides. + */ + +import type { + AttendeeFormLine, + ParsedAttendeeForm, +} from "#routes/admin/attendee-form-model.ts"; +import type { ListingAttendeeRow } from "#shared/db/attendee-types.ts"; +import { testListingWithCount } from "#test-utils/factories.ts"; + +export const line = ( + overrides: Partial = {}, +): AttendeeFormLine => ({ + error: null, + existingBooking: null, + key: "", + listing: testListingWithCount({ id: 1, max_quantity: 5 }), + listingId: 1, + noQuantity: false, + packageGroupId: 0, + packagePrice: null, + parentListingId: 0, + quantity: 1, + ...overrides, +}); + +export const bookingRow = ( + overrides: Partial = {}, +): ListingAttendeeRow => ({ + attachment_downloads: 0, + checked_in: 0, + end_at: null, + ledger_event_group: "", + listing_id: 1, + order_token: "", + package_group_id: 0, + parent_listing_id: 0, + price_paid: 0, + quantity: 1, + refunded: 0, + start_at: null, + ...overrides, +}); + +export const parsedBase = ( + overrides: Partial = {}, +): ParsedAttendeeForm => ({ + address: "", + dayCount: 1, + email: "", + lines: [], + name: "Test", + phone: "", + returnUrl: "", + special_instructions: "", + startDate: "", + statusId: null, + ...overrides, +}); diff --git a/test/features/admin/attendee-form-model.test.ts b/test/features/admin/attendee-form-model.test.ts index fcdf12c1ff..9d58d5a45e 100644 --- a/test/features/admin/attendee-form-model.test.ts +++ b/test/features/admin/attendee-form-model.test.ts @@ -1,14 +1,12 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; import { - type AttendeeFormLine, attendeeBalanceNotice, attendeeBookingsFromLines, bookingDurationDays, isBookedLine, isNoQuantityLine, isRetainedLine, - type ParsedAttendeeForm, parseAttendeeForm, resolveSharedDates, resolveStatusId, @@ -20,58 +18,11 @@ import type { AttendeeStatus } from "#shared/db/attendee-statuses.ts"; import type { ListingAttendeeRow } from "#shared/db/attendee-types.ts"; import { FormParams } from "#shared/form-data.ts"; import { testListingWithCount } from "#test-utils/factories.ts"; +import { bookingRow, line, parsedBase } from "./attendee-form-fixtures.ts"; const makeForm = (data: Record): FormParams => new FormParams(new URLSearchParams(data)); -const line = (overrides: Partial = {}): AttendeeFormLine => ({ - error: null, - existingBooking: null, - key: "", - listing: testListingWithCount({ id: 1, max_quantity: 5 }), - listingId: 1, - noQuantity: false, - packageGroupId: 0, - packagePrice: null, - parentListingId: 0, - quantity: 1, - ...overrides, -}); - -const bookingRow = ( - overrides: Partial = {}, -): ListingAttendeeRow => ({ - attachment_downloads: 0, - checked_in: 0, - end_at: null, - ledger_event_group: "", - listing_id: 1, - order_token: "", - package_group_id: 0, - parent_listing_id: 0, - price_paid: 0, - quantity: 1, - refunded: 0, - start_at: null, - ...overrides, -}); - -const parsedBase = ( - overrides: Partial = {}, -): ParsedAttendeeForm => ({ - address: "", - dayCount: 1, - email: "", - lines: [], - name: "Test", - phone: "", - returnUrl: "", - special_instructions: "", - startDate: "", - statusId: null, - ...overrides, -}); - describe("attendeeBookingsFromLines", () => { test("projects a booked line's stored booking onto a summary row", () => { const bookings = attendeeBookingsFromLines([ @@ -94,6 +45,7 @@ describe("attendeeBookingsFromLines", () => { checkedIn: true, endAt: "2026-06-03T00:00:00Z", listingActive: false, + listingDeleted: false, listingId: 7, listingName: "Kayak", parentListingId: 0, @@ -127,9 +79,11 @@ describe("attendeeBookingsFromLines", () => { expect(bookings.map((b) => b.listingName)).toEqual(["Booked"]); }); - test("drops a booking whose listing no longer resolves", () => { - // A hand-crafted POST can pair a real booking key with an unknown listing - // id; that bogus line is dropped rather than rendered with a null listing. + test("shows a booking whose listing no longer resolves as a placeholder", () => { + // A saved row can outlive its listing (a delete racing a mid-payment + // checkout keeps the staged rows). The operator must still see what the + // customer booked — a read-only "Deleted listing" placeholder, flagged so + // the table never links to the listing's 404. const bookings = attendeeBookingsFromLines([ line({ existingBooking: bookingRow({ listing_id: 99, quantity: 1 }), @@ -137,7 +91,20 @@ describe("attendeeBookingsFromLines", () => { listingId: 99, }), ]); - expect(bookings).toEqual([]); + expect(bookings).toEqual([ + { + checkedIn: false, + endAt: null, + listingActive: false, + listingDeleted: true, + listingId: 99, + listingName: "Deleted listing", + parentListingId: 0, + quantity: 1, + refunded: false, + startAt: null, + }, + ]); }); }); diff --git a/test/features/admin/attendees-csv.test.ts b/test/features/admin/attendees-csv.test.ts index 131672dd08..708c6eb3a4 100644 --- a/test/features/admin/attendees-csv.test.ts +++ b/test/features/admin/attendees-csv.test.ts @@ -46,6 +46,24 @@ describe("generateAttendeesCsv", () => { expect(lines[2]!.endsWith("ghost-tok,")).toBe(true); }); + test("adds a Checkout pending column only when some row is mid-payment", () => { + const paid = testAttendee({ quantity: 1 }); + const pending = testAttendee({ + id: 2, + pending_checkout: 1, + quantity: 0, + ticket_token: "pending-tok", + }); + const lines = generateAttendeesCsv([paid, pending]).split("\n"); + // The extra column appears, blank for the paid row and Yes for the + // mid-payment one — so a pending row never reads as a plain zero booking. + expect(lines[0]!.endsWith(",Checkout pending")).toBe(true); + expect(lines[1]!.endsWith(",")).toBe(true); + expect(lines[2]!.endsWith(",Yes")).toBe(true); + // An export with no pending row keeps the standard columns untouched. + expect(generateAttendeesCsv([paid])).not.toContain("Checkout pending"); + }); + test("escapes values with commas", () => { const attendees = [testAttendee({ name: "Doe, John" })]; const csv = generateAttendeesCsv(attendees); diff --git a/test/features/public/ticket-payment.test.ts b/test/features/public/ticket-payment.test.ts index 538e13ae70..7915f20e49 100644 --- a/test/features/public/ticket-payment.test.ts +++ b/test/features/public/ticket-payment.test.ts @@ -21,17 +21,7 @@ import { import type { PricedLine, PricedOrder } from "#shared/checkout-pricing.ts"; import { addDays } from "#shared/dates.ts"; import { createAttendeeAtomic } from "#shared/db/attendees/api.ts"; -import { - ensureAllBookings, - reverseOrderActivity, -} from "#shared/db/attendees/create.ts"; import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; -import { getDb } from "#shared/db/client.ts"; -import { - getContactRecord, - getVisits, - hashEmail, -} from "#shared/db/contact-preferences.ts"; import { getListingWithCount } from "#shared/db/listings/records.ts"; import { modifiersTable } from "#shared/db/modifiers.ts"; import { FormParams } from "#shared/form-data.ts"; @@ -105,100 +95,6 @@ describeWithEnv("routes > public > ticket-payment", { db: true }, () => { }); }); - describe("ensureAllBookings", () => { - test("ok when every booking in the cart succeeded", async () => { - const e1 = await createTestListing({ maxAttendees: 10, name: "ok-a" }); - const e2 = await createTestListing({ maxAttendees: 10, name: "ok-b" }); - const result = await createAttendeeAtomic({ - bookings: [ - { listingId: e1.id, quantity: 1 }, - { listingId: e2.id, quantity: 1 }, - ], - email: contact.email, - name: contact.name, - }); - const check = await ensureAllBookings(result, 2, "public"); - expect(check.ok).toBe(true); - expect((await getAttendeesRaw(e1.id)).length).toBe(1); - expect((await getAttendeesRaw(e2.id)).length).toBe(1); - // A kept order leaves the recorded public booking in place. - const { getTestPrivateKey } = await import("#test-utils/crypto.ts"); - const record = await getContactRecord( - await hashEmail(contact.email), - await getTestPrivateKey(), - ); - expect(record.publicBookingCount).toBe(1); - }); - - test("rolls back a partially-fulfilled cart and reports capacity_exceeded", async () => { - // Group cap 3 forces the second line to fail; createAttendeeAtomic books - // the first greedily, leaving a partial attendee. ensureAllBookings must - // delete it so the customer is never left with half a cart. - const group = await createTestGroup({ - maxAttendees: 3, - name: "rollback", - slug: "rollback", - }); - const e1 = await createTestListing({ - groupId: group.id, - maxAttendees: 10, - name: "rollback-a", - }); - const e2 = await createTestListing({ - groupId: group.id, - maxAttendees: 10, - name: "rollback-b", - }); - const result = await createAttendeeAtomic({ - bookings: [ - { listingId: e1.id, quantity: 2 }, - { listingId: e2.id, quantity: 2 }, - ], - email: contact.email, - name: contact.name, - }); - // Sanity: the atomic layer fulfilled only the first line. - expect(result.success).toBe(true); - if (result.success) expect(result.attendees.length).toBe(1); - - const check = await ensureAllBookings(result, 2, "public"); - expect(check.ok).toBe(false); - if (!check.ok) expect(check.reason).toBe("capacity_exceeded"); - // Full rollback: even the first line's row is gone. - expect((await getAttendeesRaw(e1.id)).length).toBe(0); - expect((await getAttendeesRaw(e2.id)).length).toBe(0); - // ...and the visit + booking the greedy create recorded are undone, so a - // rolled-back order leaves no phantom history on the contact. - const emailHash = await hashEmail(contact.email); - expect(await getVisits(emailHash)).toBe(0); - const { getTestPrivateKey } = await import("#test-utils/crypto.ts"); - const record = await getContactRecord( - emailHash, - await getTestPrivateKey(), - ); - expect(record.publicBookingCount).toBe(0); - }); - - test("propagates the failure reason when the whole cart failed", async () => { - const failure = { - reason: "encryption_error" as const, - success: false as const, - }; - const check = await ensureAllBookings(failure, 1, "public"); - expect(check).toEqual({ ok: false, reason: "encryption_error" }); - }); - - test("reverseOrderActivity is a no-op for a contact with no email or phone", async () => { - // An order with neither identity yields no contact hashes, so the - // compensation loop never runs and nothing is written or thrown. - await reverseOrderActivity("", "", "public"); - const { rows } = await getDb().execute( - "SELECT COUNT(*) AS c FROM contact_preferences", - ); - expect(Number(rows[0]!.c)).toBe(0); - }); - }); - describe("createFreeReservation (all-or-nothing)", () => { test("rejects the whole cart and persists nothing when a group cap is partially exceeded", async () => { const group = await createTestGroup({ diff --git a/test/integration/servicing/capacity.test.ts b/test/integration/servicing/capacity.test.ts index 22613e2192..1bd6e1b694 100644 --- a/test/integration/servicing/capacity.test.ts +++ b/test/integration/servicing/capacity.test.ts @@ -25,9 +25,11 @@ // jscpd:ignore-start import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; +import type { CanonicalBooking } from "#shared/booking-lines.ts"; import { addDays } from "#shared/dates.ts"; import { buildCapacityCheckedInsert } from "#shared/db/attendees/capacity/checks.ts"; import { + bookingStartAt, expandDailyRange, overlapsDay, } from "#shared/db/attendees/capacity/range.ts"; @@ -35,6 +37,30 @@ import { dateToRange } from "#shared/db/capacity.ts"; // jscpd:ignore-end +describe("servicing §0 — canonical booking start timestamps", () => { + test("returns the exact start timestamp for a dated canonical row", () => { + const booking: CanonicalBooking = { + date: "2026-06-24", + durationDays: 3, + listingId: 1, + quantity: 2, + }; + + expect(bookingStartAt(booking)).toBe("2026-06-24T00:00:00Z"); + }); + + test("returns null for a dateless canonical row", () => { + const booking: CanonicalBooking = { + date: null, + durationDays: 1, + listingId: 1, + quantity: 2, + }; + + expect(bookingStartAt(booking)).toBeNull(); + }); +}); + describe("servicing §0 — capacity overlap predicate is half-open", () => { // `overlapsDay(day)` returns a predicate over rows carrying string // `start_at`/`end_at`; comparisons mirror SQLite TEXT byte-for-byte, so the diff --git a/test/lib/attendee-table.test.ts b/test/lib/attendee-table.test.ts index 5ff18ed87d..c1cdd9c776 100644 --- a/test/lib/attendee-table.test.ts +++ b/test/lib/attendee-table.test.ts @@ -484,6 +484,28 @@ describe("AttendeeTable", () => { // The ticket column shows the indicator, not a live /t link. expect(html).not.toContain("/t/ghost-token"); }); + + test("a mid-payment staged row says the payment is in progress", () => { + const html = AttendeeTable( + makeOpts({ + rows: [ + makeRow({ + attendee: testAttendee({ + pending_checkout: 1, + quantity: 0, + ticket_token: "staged-token", + }), + }), + ], + }), + ); + // Both indicator cells (status + ticket) explain the payment is in + // flight — never the "No quantity" wording, which invites an edit. + expect(html).toContain("Payment in progress"); + expect(html).not.toContain("No quantity"); + expect(html).not.toContain("/t/staged-token"); + expect(html).not.toContain("/attendee/1/checkin"); + }); }); describe("presorted option", () => { diff --git a/test/lib/code-quality.test.ts b/test/lib/code-quality.test.ts index ef23109b8d..3e91644994 100644 --- a/test/lib/code-quality.test.ts +++ b/test/lib/code-quality.test.ts @@ -213,11 +213,6 @@ const ALLOWED_TEST_HOOKS: string[] = [ "shared/square.ts:resetSquareClient", // Test helper for creating signed Square webhook payloads "shared/square.ts:constructTestWebhookEvent", - // Convenience wrapper for idempotency checks (production uses isSessionProcessed directly) - "shared/db/processed-payments.ts:getProcessedAttendeeId", - // Test setup helper for creating finalized sessions; production now uses - // finalizeSessionStatement inside the attendee-creation transaction instead. - "shared/db/processed-payments.ts:finalizeSession", // Raw attendee fetch for testing encrypted data (production uses batched getListingWithAttendeesRaw) "shared/db/attendees/queries.ts:getAttendeesRaw", // Single attendee fetch for tests (production uses batched getListingWithAttendeeRaw) diff --git a/test/lib/db/attendees/availability-consistency.test.ts b/test/lib/db/attendees/availability-consistency.test.ts index 44a19f9ffc..10bcec0ab4 100644 --- a/test/lib/db/attendees/availability-consistency.test.ts +++ b/test/lib/db/attendees/availability-consistency.test.ts @@ -8,7 +8,6 @@ import { checkBatchAvailability, createAttendeeAtomic, } from "#shared/db/attendees/api.ts"; -import { queryAll } from "#shared/db/client.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { bookAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; import { createTestGroup } from "#test-utils/db-helpers/groups.ts"; @@ -25,12 +24,8 @@ import { * capacity rules; this test pins them together so a future change to one that * diverges from the other fails CI rather than silently over/under-offering. * - * The oracle is the real write: run the cart through `createAttendeeAtomic` - * and check whether EVERY line landed (the unpaid batch path commits whatever - * fits and reports success on a partial cart, so "all lines committed" — not - * the success flag — is the fit question the preflight answers). The - * fully-booked outcome must equal the preflight verdict for the same cart - * against the same pre-write state. + * The oracle is the real all-or-nothing write. Its result must equal the + * preflight verdict for the same cart against the same pre-write state. */ describeWithEnv( "db > attendees > availability preflight matches the write", @@ -53,18 +48,7 @@ describeWithEnv( email: "x@example.com", name: "X", }); - // "Fully booked" — every cart line landed a row — is the fit question. - // The unpaid batch path commits whatever fits and still reports success - // on a partial cart, so the success flag alone would over-report. - let fullyBooked = false; - if (write.success) { - const rows = await queryAll<{ c: number }>( - "SELECT COUNT(*) AS c FROM listing_attendees WHERE attendee_id = ?", - [write.attendees[0]!.id], - ); - fullyBooked = rows[0]!.c === bookings.length; - } - expect(fullyBooked).toBe(preflight); + expect(write.success).toBe(preflight); return preflight; }; diff --git a/test/lib/db/attendees/create-attendee-atomic.test.ts b/test/lib/db/attendees/create-attendee-atomic.test.ts index 3419e19209..634f524c0c 100644 --- a/test/lib/db/attendees/create-attendee-atomic.test.ts +++ b/test/lib/db/attendees/create-attendee-atomic.test.ts @@ -43,6 +43,20 @@ const expectCartRows = async ( } }; +const expectCartRejected = async ( + result: Awaited>, + listingIds: number[], +): Promise => { + expect(result).toEqual({ reason: "capacity_exceeded", success: false }); + for (const listingId of listingIds) { + const rows = await getDb().execute({ + args: [listingId], + sql: "SELECT quantity FROM listing_attendees WHERE listing_id = ?", + }); + expect(rows.rows).toEqual([]); + } +}; + const setupBookedOutListing = async () => { const listing = await createTestListing({ maxAttendees: 1 }); await updateListingAggregateValues(listing.id, { @@ -527,10 +541,8 @@ describeWithEnv("db > attendees > createAttendeeAtomic", { db: true }, () => { test("intra-cart group cap: a sibling insert earlier in the same batch counts (no oversell)", async () => { // Two listings share a group capped at 3. A single cart asks for 2 + 2 = 4. // The second INSERT's capacity check must see the first INSERT from the - // same atomic batch, so it is refused — booking the first line (2) and - // declining the second rather than overselling the group to 4. The - // all-or-nothing policy lives one layer up (ensureAllBookings); this layer - // fulfils greedily but must never exceed the cap. + // same atomic batch. The missed second row aborts the batch, so neither the + // attendee nor the first booking ever commits. const group = await createTestGroup({ maxAttendees: 3, name: "cart-accum", @@ -554,10 +566,7 @@ describeWithEnv("db > attendees > createAttendeeAtomic", { db: true }, () => { email: "cart@example.com", name: "Cart", }); - expect(result.success).toBe(true); - if (result.success) expect(result.attendees.length).toBe(1); - expect((await getAttendeesRaw(e1.id))[0]!.quantity).toBe(2); - expect((await getAttendeesRaw(e2.id)).length).toBe(0); + await expectCartRejected(result, [e1.id, e2.id]); }); test("intra-cart group cap: a cart that exactly fills the group across listings succeeds", async () => { @@ -614,11 +623,8 @@ describeWithEnv("db > attendees > createAttendeeAtomic", { db: true }, () => { email: "daily-cart@example.com", name: "DailyCart", }); - // 2 + 2 = 4 on the same date > cap 3: first fits, second refused. - expect(result.success).toBe(true); - if (result.success) expect(result.attendees.length).toBe(1); - expect((await getAttendeesRaw(e1.id)).length).toBe(1); - expect((await getAttendeesRaw(e2.id)).length).toBe(0); + // 2 + 2 = 4 on the same date > cap 3: the whole order is refused. + await expectCartRejected(result, [e1.id, e2.id]); }); test("intra-cart daily group cap is independent across different dates", async () => { diff --git a/test/lib/db/attendees/delete-attendee.test.ts b/test/lib/db/attendees/delete-attendee.test.ts index ec0eac485e..e86bedabde 100644 --- a/test/lib/db/attendees/delete-attendee.test.ts +++ b/test/lib/db/attendees/delete-attendee.test.ts @@ -2,6 +2,7 @@ import { expect } from "@std/expect"; import { it as test } from "@std/testing/bdd"; import { deleteAttendee } from "#shared/db/attendees/delete.ts"; import { getAttendee } from "#shared/db/attendees/queries.ts"; +import { getCheckoutStageOrNull } from "#shared/db/checkout-stages.ts"; import { getDb, queryOne } from "#shared/db/client.ts"; import { getListingWithCount, @@ -10,7 +11,6 @@ import { import { modifierUsedQuantities } from "#shared/db/modifier-usage.ts"; import { getAllModifiers, modifiersTable } from "#shared/db/modifiers.ts"; import { - finalizeSession as finalizePaymentSession, isSessionProcessed, reserveSession, } from "#shared/db/processed-payments.ts"; @@ -20,6 +20,10 @@ import { describeWithEnv } from "#test-utils/db.ts"; import { createPaidTestAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; import { createTestAttendee } from "#test-utils/db-helpers/attendees.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { + finalizeTestPaymentSession as finalizePaymentSession, + stageTestCheckout, +} from "#test-utils/db-helpers/processed-payments.ts"; import { consumeModifierStock } from "#test-utils/modifiers.ts"; describeWithEnv("db > attendees > deleteAttendee", { db: true }, () => { @@ -68,6 +72,16 @@ describeWithEnv("db > attendees > deleteAttendee", { db: true }, () => { expect(processed).toBeNull(); }); + test("removes checkout stage records", async () => { + const listing = await createTestListing({ maxAttendees: 50 }); + const sessionId = "sess_stage_attendee_delete"; + const stage = await stageTestCheckout(sessionId, listing); + + await deleteAttendee(stage.attendeeId); + + expect(await getCheckoutStageOrNull(sessionId)).toBeNull(); + }); + test("releases listing aggregate totals by default", async () => { const listing = await createTestListing({ maxAttendees: 50 }); const attendee = await createPaidTestAttendee( diff --git a/test/lib/db/attendees/select.test.ts b/test/lib/db/attendees/select.test.ts index 7c6a8e9508..3a97dedd24 100644 --- a/test/lib/db/attendees/select.test.ts +++ b/test/lib/db/attendees/select.test.ts @@ -253,13 +253,13 @@ describe("attendee SELECT — exact generated SQL", () => { test("the full field set produces the exact column list (all three ledger subqueries inline)", () => { expect(attendeeColumns("inner", ATTENDEE_FIELDS)).toBe( - "attendee.id, attendee.created, attendee.kind, attendee.ticket_token_index, attendee.pii_blob, attendee.status_id, attendee.split_logistics_agents, listingAttendee.listing_id, SUBSTR(listingAttendee.start_at, 1, 10) as date, listingAttendee.quantity, listingAttendee.checked_in, -(SELECT COALESCE(SUM(CASE WHEN dest_type = 'attendee' AND dest_id = CAST(attendee.id AS TEXT) THEN amount WHEN source_type = 'attendee' AND source_id = CAST(attendee.id AS TEXT) THEN -amount ELSE 0 END), 0) FROM transfers WHERE dest_type = 'attendee' AND dest_id = CAST(attendee.id AS TEXT) OR source_type = 'attendee' AND source_id = CAST(attendee.id AS TEXT)) AS remaining_balance, (SELECT EXISTS(SELECT 1 FROM transfers WHERE kind = 'refund_cash' AND source_type = 'attendee' AND source_id = CAST(listingAttendee.attendee_id AS TEXT))) AS refunded, COALESCE(CAST((SELECT COALESCE(SUM(amount), 0) FROM transfers WHERE kind = 'sale' AND source_type = 'attendee' AND source_id = CAST(listingAttendee.attendee_id AS TEXT) AND dest_type = 'revenue' AND dest_id = CAST(listingAttendee.listing_id AS TEXT) AND event_group = listingAttendee.ledger_event_group) * (SELECT COALESCE(SUM(sibling.quantity), 0) FROM listing_attendees AS sibling WHERE sibling.attendee_id = listingAttendee.attendee_id AND sibling.listing_id = listingAttendee.listing_id AND sibling.ledger_event_group = listingAttendee.ledger_event_group AND sibling.id <= listingAttendee.id) / NULLIF((SELECT COALESCE(SUM(sibling.quantity), 0) FROM listing_attendees AS sibling WHERE sibling.attendee_id = listingAttendee.attendee_id AND sibling.listing_id = listingAttendee.listing_id AND sibling.ledger_event_group = listingAttendee.ledger_event_group), 0) AS INTEGER) - CAST((SELECT COALESCE(SUM(amount), 0) FROM transfers WHERE kind = 'sale' AND source_type = 'attendee' AND source_id = CAST(listingAttendee.attendee_id AS TEXT) AND dest_type = 'revenue' AND dest_id = CAST(listingAttendee.listing_id AS TEXT) AND event_group = listingAttendee.ledger_event_group) * (SELECT COALESCE(SUM(sibling.quantity), 0) FROM listing_attendees AS sibling WHERE sibling.attendee_id = listingAttendee.attendee_id AND sibling.listing_id = listingAttendee.listing_id AND sibling.ledger_event_group = listingAttendee.ledger_event_group AND sibling.id < listingAttendee.id) / NULLIF((SELECT COALESCE(SUM(sibling.quantity), 0) FROM listing_attendees AS sibling WHERE sibling.attendee_id = listingAttendee.attendee_id AND sibling.listing_id = listingAttendee.listing_id AND sibling.ledger_event_group = listingAttendee.ledger_event_group), 0) AS INTEGER), 0) AS price_paid, SUBSTR(listingAttendee.end_at, 1, 10) as end_date, listingAttendee.attachment_downloads, listingAttendee.package_group_id", + "attendee.id, attendee.created, attendee.kind, attendee.ticket_token_index, attendee.pii_blob, attendee.status_id, attendee.split_logistics_agents, (SELECT EXISTS(SELECT 1 FROM checkout_stages AS stage WHERE stage.attendee_id = attendee.id AND stage.state = 'pending')) AS pending_checkout, listingAttendee.listing_id, SUBSTR(listingAttendee.start_at, 1, 10) as date, listingAttendee.quantity, listingAttendee.checked_in, -(SELECT COALESCE(SUM(CASE WHEN dest_type = 'attendee' AND dest_id = CAST(attendee.id AS TEXT) THEN amount WHEN source_type = 'attendee' AND source_id = CAST(attendee.id AS TEXT) THEN -amount ELSE 0 END), 0) FROM transfers WHERE dest_type = 'attendee' AND dest_id = CAST(attendee.id AS TEXT) OR source_type = 'attendee' AND source_id = CAST(attendee.id AS TEXT)) AS remaining_balance, (SELECT EXISTS(SELECT 1 FROM transfers WHERE kind = 'refund_cash' AND source_type = 'attendee' AND source_id = CAST(listingAttendee.attendee_id AS TEXT))) AS refunded, COALESCE(CAST((SELECT COALESCE(SUM(amount), 0) FROM transfers WHERE kind = 'sale' AND source_type = 'attendee' AND source_id = CAST(listingAttendee.attendee_id AS TEXT) AND dest_type = 'revenue' AND dest_id = CAST(listingAttendee.listing_id AS TEXT) AND event_group = listingAttendee.ledger_event_group) * (SELECT COALESCE(SUM(sibling.quantity), 0) FROM listing_attendees AS sibling WHERE sibling.attendee_id = listingAttendee.attendee_id AND sibling.listing_id = listingAttendee.listing_id AND sibling.ledger_event_group = listingAttendee.ledger_event_group AND sibling.id <= listingAttendee.id) / NULLIF((SELECT COALESCE(SUM(sibling.quantity), 0) FROM listing_attendees AS sibling WHERE sibling.attendee_id = listingAttendee.attendee_id AND sibling.listing_id = listingAttendee.listing_id AND sibling.ledger_event_group = listingAttendee.ledger_event_group), 0) AS INTEGER) - CAST((SELECT COALESCE(SUM(amount), 0) FROM transfers WHERE kind = 'sale' AND source_type = 'attendee' AND source_id = CAST(listingAttendee.attendee_id AS TEXT) AND dest_type = 'revenue' AND dest_id = CAST(listingAttendee.listing_id AS TEXT) AND event_group = listingAttendee.ledger_event_group) * (SELECT COALESCE(SUM(sibling.quantity), 0) FROM listing_attendees AS sibling WHERE sibling.attendee_id = listingAttendee.attendee_id AND sibling.listing_id = listingAttendee.listing_id AND sibling.ledger_event_group = listingAttendee.ledger_event_group AND sibling.id < listingAttendee.id) / NULLIF((SELECT COALESCE(SUM(sibling.quantity), 0) FROM listing_attendees AS sibling WHERE sibling.attendee_id = listingAttendee.attendee_id AND sibling.listing_id = listingAttendee.listing_id AND sibling.ledger_event_group = listingAttendee.ledger_event_group), 0) AS INTEGER), 0) AS price_paid, SUBSTR(listingAttendee.end_at, 1, 10) as end_date, listingAttendee.attachment_downloads, listingAttendee.package_group_id", ); }); test("an empty field set under a LEFT join produces the exact core columns", () => { expect(attendeeColumns("left", [])).toBe( - "attendee.id, attendee.created, attendee.kind, attendee.ticket_token_index, attendee.pii_blob, attendee.status_id, attendee.split_logistics_agents, COALESCE(listingAttendee.listing_id, 0) as listing_id, SUBSTR(listingAttendee.start_at, 1, 10) as date, COALESCE(listingAttendee.quantity, 0) as quantity, COALESCE(listingAttendee.checked_in, 0) as checked_in", + "attendee.id, attendee.created, attendee.kind, attendee.ticket_token_index, attendee.pii_blob, attendee.status_id, attendee.split_logistics_agents, (SELECT EXISTS(SELECT 1 FROM checkout_stages AS stage WHERE stage.attendee_id = attendee.id AND stage.state = 'pending')) AS pending_checkout, COALESCE(listingAttendee.listing_id, 0) as listing_id, SUBSTR(listingAttendee.start_at, 1, 10) as date, COALESCE(listingAttendee.quantity, 0) as quantity, COALESCE(listingAttendee.checked_in, 0) as checked_in", ); }); diff --git a/test/lib/db/migration-schema-guard.test.ts b/test/lib/db/migration-schema-guard.test.ts index 884caad9e5..92731a6d2e 100644 --- a/test/lib/db/migration-schema-guard.test.ts +++ b/test/lib/db/migration-schema-guard.test.ts @@ -84,8 +84,9 @@ describe("db > migrations > schema change guard", () => { "2026-07-09_listing_attributes", "2026-07-10_processed_payments_attendee_index", "2026-07-12_remove_broken_image_records", + "2026-07-12_checkout_stages", ], - schemaHash: "n2axyb", + schemaHash: "17w72en", }); }); }); diff --git a/test/lib/payment-processing/stage-gone.test.ts b/test/lib/payment-processing/stage-gone.test.ts new file mode 100644 index 0000000000..ebcd5c0ae1 --- /dev/null +++ b/test/lib/payment-processing/stage-gone.test.ts @@ -0,0 +1,101 @@ +/** + * A staged attendee losing its booking rows before activation claims them is an + * IMPOSSIBLE state: a pending stage's rows are only removed with the stage + * itself, and admin/listing deletes are blocked while pending. If it is ever + * observed it is a missed cascade, so activation throws loudly rather than + * silently re-running the create with the customer already gone. This drives + * createAttendeeForSession directly with a stale stage snapshot whose attendee + * was removed, composing its inputs with the same production functions the + * route uses. + */ + +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { createAttendeeForSession } from "#routes/api/payment-processing/create.ts"; +import { validateAllItems } from "#routes/api/payment-processing/items.ts"; +import { extractIntent } from "#routes/api/payment-processing/metadata.ts"; +import { checkoutIntentForSession } from "#routes/api/payment-processing/pricing.ts"; +import { priceCheckout } from "#shared/checkout-pricing.ts"; +import { deleteAttendee } from "#shared/db/attendees/delete.ts"; +import { stageCheckout } from "#shared/db/checkout-stages.ts"; +import { reserveSession } from "#shared/db/processed-payments.ts"; +import { + assembleCheckoutMetadata, + extractSessionMetadata, +} from "#shared/payment-helpers.ts"; +import type { + SessionMetadata, + ValidatedPaymentSession, +} from "#shared/payments.ts"; +import { checkoutIntent, checkoutItem } from "#test-utils/checkout.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; +import { createTestListing } from "#test-utils/db-helpers/listings.ts"; + +describeWithEnv( + "paid checkout > staged rows gone at claim", + { db: true }, + () => { + test("throws on the impossible gone-stage state instead of booking fresh", async () => { + const listing = await createTestListing({ unitPrice: 1000 }); + const captured = checkoutIntent({ + items: [ + checkoutItem({ + listingId: listing.id, + name: listing.name, + slug: listing.slug, + }), + ], + }); + // The payment machine read this stage snapshot... + const staleStage = await stageCheckout( + "cs_stage_gone", + "stripe", + captured, + ); + // ...then the operator deleted the record before activation claimed it. + await deleteAttendee(staleStage.attendeeId); + + // The same raw-metadata → session shape every provider builds: assemble + // the signed metadata, then normalize it the way the providers do. + const session: ValidatedPaymentSession = { + amountTotal: 1000, + createdAt: new Date().toISOString(), + id: "cs_stage_gone", + metadata: extractSessionMetadata( + (await assembleCheckoutMetadata( + "stripe", + captured, + 1000, + )) as SessionMetadata, + ), + paymentReference: "pi_cs_stage_gone", + paymentStatus: "paid", + }; + const intent = extractIntent(session); + if (!intent) throw new Error("Expected a parsable intent"); + const validated = await validateAllItems(session, intent); + if ("success" in validated) throw new Error("Expected valid items"); + const pricingIntent = checkoutIntentForSession( + intent, + validated.items, + [], + ); + await reserveSession("cs_stage_gone"); + + // The staged rows vanished out from under the claim — an impossible state + // in production (admin/listing deletes are blocked while pending), so the + // activation surfaces it loudly instead of quietly re-running the create. + await expect( + createAttendeeForSession( + session, + intent, + validated.items, + pricingIntent, + priceCheckout(pricingIntent), + staleStage.ticketToken, + staleStage, + ), + ).rejects.toThrow("has no booking rows at activation"); + }); + }, +); diff --git a/test/lib/processed-payments/locking.test.ts b/test/lib/processed-payments/locking.test.ts index feaffd60db..a45b5af5e4 100644 --- a/test/lib/processed-payments/locking.test.ts +++ b/test/lib/processed-payments/locking.test.ts @@ -3,9 +3,6 @@ import { describe, it as test } from "@std/testing/bdd"; import { getDb, insert } from "#shared/db/client.ts"; import { clearSessionTokens, - decryptSessionTokens, - finalizeSession, - getProcessedAttendeeId, isSessionProcessed, reserveSession, STALE_RESERVATION_MS, @@ -14,6 +11,7 @@ import { describeWithEnv } from "#test-utils/db.ts"; import { useProcessedPaymentsAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; import { createTestAttendee } from "#test-utils/db-helpers/attendees.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { finalizeTestPaymentSession as finalizeSession } from "#test-utils/db-helpers/processed-payments.ts"; /** Perform the full two-phase reserve+finalize as production code does */ const processSession = async ( @@ -57,6 +55,28 @@ describeWithEnv("processed-payments / locking", { db: true }, () => { expect(result?.payment_session_id).toBe("cs_reserved_123"); expect(result?.attendee_id).toBeNull(); }); + + test("the finalize fixture refuses an already-resolved session", async () => { + await reserveSession("cs_finalize_twice"); + await finalizeSession( + "cs_finalize_twice", + ctx.attendeeId, + ["tok-test"], + "pi_cs_finalize_twice", + ); + + // The fixture writes through the real guarded finalize, so a second + // finalize — a state production can never write — fails loudly instead + // of silently overwriting the resolved row. + await expect( + finalizeSession( + "cs_finalize_twice", + ctx.attendeeId, + ["tok-test"], + "pi_cs_finalize_twice", + ), + ).rejects.toThrow("not an unresolved reservation"); + }); }); describe("reserveSession", () => { @@ -126,37 +146,6 @@ describeWithEnv("processed-payments / locking", { db: true }, () => { }); }); - describe("finalizeSession", () => { - test("sets attendee_id on reserved session", async () => { - await reserveSession("cs_to_finalize"); - await finalizeSession( - "cs_to_finalize", - ctx.attendeeId, - ["tok-test"], - "pi_cs_to_finalize", - ); - - const record = await isSessionProcessed("cs_to_finalize"); - expect(record?.attendee_id).toBe(ctx.attendeeId); - }); - - test("stores ticket tokens encrypted when provided", async () => { - await reserveSession("cs_with_tokens"); - await finalizeSession( - "cs_with_tokens", - ctx.attendeeId, - ["tok_abc", "tok_def"], - "pi_cs_with_tokens", - ); - - const record = await isSessionProcessed("cs_with_tokens"); - expect(record?.ticket_tokens).toMatch(/^enc:1:/); - expect(await decryptSessionTokens(record!.ticket_tokens)).toBe( - "tok_abc+tok_def", - ); - }); - }); - describe("clearSessionTokens", () => { test("clears stored tokens while preserving attendee_id", async () => { await reserveSession("cs_clear_test"); @@ -189,30 +178,6 @@ describeWithEnv("processed-payments / locking", { db: true }, () => { }); }); - describe("getProcessedAttendeeId", () => { - test("returns null for unprocessed session", async () => { - expect(await getProcessedAttendeeId("cs_never_processed")).toBeNull(); - }); - - test("returns null for reserved-but-not-finalized session", async () => { - await reserveSession("cs_reserved_only"); - expect(await getProcessedAttendeeId("cs_reserved_only")).toBeNull(); - }); - - test("returns attendee ID after finalization", async () => { - await reserveSession("cs_finalized_attendee"); - await finalizeSession( - "cs_finalized_attendee", - ctx.attendeeId, - ["tok-test"], - "pi_cs_finalized_attendee", - ); - expect(await getProcessedAttendeeId("cs_finalized_attendee")).toBe( - ctx.attendeeId, - ); - }); - }); - describe("idempotency", () => { test("concurrent processing attempts only create one record", async () => { const listing = await createTestListing(); diff --git a/test/lib/server-attendee-form/quantity.test.ts b/test/lib/server-attendee-form/quantity.test.ts index 4a811565fa..76ecf4441b 100644 --- a/test/lib/server-attendee-form/quantity.test.ts +++ b/test/lib/server-attendee-form/quantity.test.ts @@ -12,7 +12,7 @@ import { createTestAttendee, } from "#test-utils/db-helpers/attendees.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; -import { postListingSale } from "#test-utils/ledger.ts"; +import { postHeldPayment, postListingSale } from "#test-utils/ledger.ts"; import { adminFormPost, adminGet } from "#test-utils/session.ts"; describeWithEnv( @@ -55,6 +55,21 @@ describeWithEnv( return response; }; + /** POST the edit form and assert it was refused in place (200) with the + * shared "refund first" message — a paid line, or held conflict cash the + * save would strand. */ + const postEditExpectRefundFirst = async ( + attendeeId: number, + form: Record, + ): Promise => { + const { response } = await adminFormPost( + `/admin/attendees/${attendeeId}`, + form, + ); + const html = await expectHtmlResponse(response, 200); + expect(html).toContain("Refund this booking's payment"); + }; + /** Book a paid attendee on `listingId` and recognise a £15 sale leg in the * ledger, so hasPaidLine's DB guard sees a gross sale (not a price_paid * column). Returns the new attendee id. */ @@ -246,16 +261,127 @@ describeWithEnv( ], name: "Stale", }); + await postEditExpectRefundFirst(attendeeId, form); + // The paid line is untouched (not dropped/replaced by a ghost). + expect(await readLine(attendeeId, listing.id)).toMatchObject({ + price_paid: 1500, + quantity: 1, + }); + }); + + test("blocks no-quantity while the attendee holds unreturned conflict cash", async () => { + const listing = await createTestListing({ maxAttendees: 50 }); + const attendee = await createTestAttendee( + listing.id, + listing.slug, + "Held", + "held@example.com", + ); + // A stage_active conflict's held payment: cash received, NO sale leg, so + // price_paid is 0 and hasPaidLine sees nothing — yet the money is ours to + // return. Marking the only line no-quantity would remove the refund path. + await postHeldPayment({ + amount: 1000, + attendeeId: attendee.id, + listingId: listing.id, + }); + + const response = await markNoQuantity(attendee.id, listing.id, "Held"); + + const html = await expectHtmlResponse(response, 200); + expect(html).toContain("Refund this booking's payment"); + // The line is untouched, so the in-app refund path (which needs an + // active booking line) still reaches the held cash. + expect(await readLine(attendee.id, listing.id)).toMatchObject({ + quantity: 1, + }); + }); + + /** Book an attendee on two listings (both quantity 1) and give it a held + * conflict payment (positive account balance, no sale). The first listing + * is the home listing the refund route keys on. Returns the ids + a key + * lookup for the edit form. */ + const heldCashOnTwoListings = async (email: string, name: string) => { + const home = await createTestListing({ maxAttendees: 50 }); + const other = await createTestListing({ maxAttendees: 50 }); + const created = await createAttendeeAtomic({ + bookings: [ + { listingId: home.id, quantity: 1 }, + { listingId: other.id, quantity: 1 }, + ], + email, + name, + }); + if (!created.success) throw new Error("setup"); + const attendeeId = created.attendees[0]!.id; + await postHeldPayment({ amount: 1000, attendeeId, listingId: home.id }); + const { loadExistingLines } = await import( + "#shared/db/attendees/atomic-update.ts" + ); + const lines = await loadExistingLines(attendeeId); + const keyOf = (listingId: number) => + lines.find((e) => e.booking.listing_id === listingId)!.key; + return { attendeeId, home, keyOf, other }; + }; + + test("allows a no-quantity edit that keeps the active home line while cash is held", async () => { + const { attendeeId, home, keyOf, other } = await heldCashOnTwoListings( + "held-keep@example.com", + "Held Keep", + ); + // Keep the home line active; mark only the OTHER line no-quantity. The + // refund route (an active home line) survives, so the save is allowed — + // the operator can still fix the quantities the conflict note asks about. + const form = await buildAttendeeEditForm(attendeeId, { + lines: [ + { eventId: home.id, key: keyOf(home.id), quantity: 1 }, + { + eventId: other.id, + key: keyOf(other.id), + noQuantity: true, + quantity: 1, + }, + ], + name: "Held Keep", + }); const { response } = await adminFormPost( `/admin/attendees/${attendeeId}`, form, ); - const html = await expectHtmlResponse(response, 200); - expect(html).toContain("Refund this booking's payment"); - // The paid line is untouched (not dropped/replaced by a ghost). - expect(await readLine(attendeeId, listing.id)).toMatchObject({ - price_paid: 1500, + expect(response.status).toBe(302); + // The home line stays active (refund path intact); the other becomes a + // quantity-0 sentinel. + expect(await readLine(attendeeId, home.id)).toMatchObject({ + quantity: 1, + }); + expect(await readLine(attendeeId, other.id)).toMatchObject({ + quantity: 0, + }); + }); + + test("still blocks removing the active home line while cash is held, even if another line stays", async () => { + const { attendeeId, home, keyOf, other } = await heldCashOnTwoListings( + "held-home@example.com", + "Held Home", + ); + // Mark the HOME line no-quantity but keep the other active. The refund + // route is home-scoped, so this would strand the held cash — blocked. + const form = await buildAttendeeEditForm(attendeeId, { + lines: [ + { + eventId: home.id, + key: keyOf(home.id), + noQuantity: true, + quantity: 1, + }, + { eventId: other.id, key: keyOf(other.id), quantity: 1 }, + ], + name: "Held Home", + }); + await postEditExpectRefundFirst(attendeeId, form); + // The home line is untouched, so the in-app refund still reaches. + expect(await readLine(attendeeId, home.id)).toMatchObject({ quantity: 1, }); }); diff --git a/test/lib/server-attendee-logistics-tab.test.ts b/test/lib/server-attendee-logistics-tab.test.ts index 793af64664..0138c4f048 100644 --- a/test/lib/server-attendee-logistics-tab.test.ts +++ b/test/lib/server-attendee-logistics-tab.test.ts @@ -24,6 +24,7 @@ import { settings } from "#shared/db/settings.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 { stageTestCheckout } from "#test-utils/db-helpers/processed-payments.ts"; import { deliveredListingSetup, logisticsTabHtml, @@ -93,6 +94,33 @@ describeWithEnv("attendee Logistics tab (POST)", { db: true }, () => { expect(saved.ticket_token).toBe(created.ticket_token); }); + test("refuses to save while the attendee's checkout is mid-payment", async () => { + const listing = await createTestListing({ maxAttendees: 10 }); + const stage = await stageTestCheckout("cs_logistics_pending", listing); + + // The payment claims and rewrites the stored details when it lands, so a + // Logistics save now would be silently lost — it fails closed instead. + const response = await postLogistics(stage.attendeeId, { + address: "1 Lost Change Lane", + lat: "51.5", + lng: "-0.1", + }); + expect(response.status).toBe(302); + // The Logistics tab is hidden while pending (its URL 404s), so the refusal + // lands on the always-visible overview, where the banner explains the lock. + const location = response.headers.get("location") ?? ""; + expect(location).toContain(`/admin/attendees/${stage.attendeeId}`); + expect(location).not.toContain("/logistics"); + + const kept = (await getAttendee( + stage.attendeeId, + await getTestPrivateKey(), + ))!; + expect(kept.address).not.toBe("1 Lost Change Lane"); + expect(kept.lat).toBe(""); + expect(kept.lng).toBe(""); + }); + test("a pinned attendee renders the pin values and a visible map", async () => { const listing = await createTestListing({ maxAttendees: 10 }); const id = await makeAttendee("Visible Map", [{ listingId: listing.id }]); diff --git a/test/lib/server-attendee-refresh-payment.test.ts b/test/lib/server-attendee-refresh-payment.test.ts index c3279bf5de..74dc073f1f 100644 --- a/test/lib/server-attendee-refresh-payment.test.ts +++ b/test/lib/server-attendee-refresh-payment.test.ts @@ -6,16 +6,14 @@ import { mapBooking } from "#shared/accounting/mappers.ts"; import { postTransfers } from "#shared/accounting/store.ts"; import { balanceEventGroup } from "#shared/db/attendees/balance.ts"; import { execute } from "#shared/db/client.ts"; -import { - finalizeSession, - reserveSession, -} from "#shared/db/processed-payments.ts"; +import { reserveSession } from "#shared/db/processed-payments.ts"; import type { Attendee } from "#shared/types.ts"; import { expectErrorFlash, expectFlash } from "#test-utils/assertions.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { createPaidAttendeeWithoutLedger } from "#test-utils/db-helpers/attendee-payments.ts"; import { bookTestAttendee } from "#test-utils/db-helpers/attendees.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { finalizeTestPaymentSession as finalizeSession } from "#test-utils/db-helpers/processed-payments.ts"; import { withRefreshPaymentProbe } from "#test-utils/refund-routes.ts"; import { adminFormPost } from "#test-utils/session.ts"; diff --git a/test/lib/server-attendees-list.test.ts b/test/lib/server-attendees-list.test.ts index 58449d5135..254f3e8bc2 100644 --- a/test/lib/server-attendees-list.test.ts +++ b/test/lib/server-attendees-list.test.ts @@ -63,6 +63,33 @@ describeWithEnv("server (admin attendees list)", { db: true }, () => { expect(html).not.toContain("

Attendees

"); }); + test("shows a mid-payment staged row as payment in progress", async () => { + const listing = await makeListing("Staged Gala"); + const { stageCheckout } = await import("#shared/db/checkout-stages.ts"); + const { checkoutIntent, checkoutItem } = await import( + "#test-utils/checkout.ts" + ); + await stageCheckout( + "cs_attendees_list_pending", + "stripe", + checkoutIntent({ + items: [ + checkoutItem({ + listingId: listing.id, + name: listing.name, + slug: listing.slug, + }), + ], + }), + ); + + // The SQL projection reaches the browsing table end-to-end: the staged + // quantity-0 row reads as mid-payment, never as an editable "No quantity". + const html = await (await adminGet("/admin/attendees")).text(); + expect(html).toContain("Payment in progress"); + expect(html).not.toContain("No quantity"); + }); + test("lists the newest registration first by default", async () => { const listing = await makeListing("Gala Night"); await createTestAttendeeDirect(listing.id, "Alice", "alice@example.com"); diff --git a/test/lib/server-attendees/attendee-detail.test.ts b/test/lib/server-attendees/attendee-detail.test.ts index e36cd0bca7..73edb7a576 100644 --- a/test/lib/server-attendees/attendee-detail.test.ts +++ b/test/lib/server-attendees/attendee-detail.test.ts @@ -10,10 +10,11 @@ import { bookAttendee } 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 { setTestEnv } from "#test-utils/env.ts"; +import { postHeldPayment } from "#test-utils/ledger.ts"; import { adminGet } from "#test-utils/session.ts"; // jscpd:ignore-end -import { setupListingAndAttendee } from "./helpers.ts"; +import { setupListingAndAttendee, stageMidPaymentAttendee } from "./helpers.ts"; describeWithEnv( "server (admin attendees) > attendee detail", @@ -31,6 +32,106 @@ describeWithEnv( expect(response.status).toBe(404); }); + test("shows a mid-payment record as payment in progress", async () => { + const listing = await createTestListing({ unitPrice: 1000 }); + const stage = await stageMidPaymentAttendee(listing, "cs_detail_mid"); + const response = await adminGet(`/admin/attendees/${stage.attendeeId}`); + expect(response.status).toBe(200); + const html = await response.text(); + // The ticket cell explains the payment is still in flight — never the + // "No quantity" sentinel wording, which invites an operator edit. + expect(html).toContain("Payment in progress"); + expect(html).not.toContain("No quantity"); + // The banner says why the write controls are gone. + expect(html).toContain("Checkout pending"); + }); + + test("hides the write tabs and actions while a checkout is pending", async () => { + const listing = await createTestListing({ unitPrice: 1000 }); + const stage = await stageMidPaymentAttendee(listing, "cs_detail_tabs"); + const base = `/admin/attendees/${stage.attendeeId}`; + + const response = await adminGet(base); + const html = await expectHtmlResponse(response, 200); + // No tab link may render for a page the operator can't use: every + // mutation is blocked server-side while the payment is in flight. + expect(html).not.toContain(`href="${base}/edit"`); + expect(html).not.toContain(`href="${base}/logistics"`); + expect(html).not.toContain(`href="${base}/actions"`); + // The Ledger tab embeds manual charge/payment forms, so it hides too: + // a manual leg posted mid-payment would combine with activation's own + // legs and show a fully-paid ticket with a surprise balance. + expect(html).not.toContain(`href="${base}/ledger"`); + + // A hidden tab's URL is a 404, so `visible` IS the authorization. + expect((await adminGet(`${base}/edit`)).status).toBe(404); + expect((await adminGet(`${base}/actions`)).status).toBe(404); + expect((await adminGet(`${base}/delete`)).status).toBe(404); + expect((await adminGet(`${base}/ledger`)).status).toBe(404); + }); + + test("shows a raced-delete staged order as a deleted-listing line", async () => { + const listing = await createTestListing({ unitPrice: 1000 }); + const stage = await stageMidPaymentAttendee( + listing, + "cs_detail_deleted", + ); + // Simulate the delete/stage race: a delete that slipped past the + // preflight guard. The pending rows survive (deleteListing keeps them), + // but the listing itself is gone. + const { deleteListing } = await import("#shared/db/listings/delete.ts"); + await deleteListing(listing.id); + + const response = await adminGet(`/admin/attendees/${stage.attendeeId}`); + const html = await expectHtmlResponse(response, 200); + // The operator still sees what the customer paid for — a placeholder + // line, never a vanished booking or a link to the listing's 404. + expect(html).toContain("Deleted listing"); + expect(html).not.toContain(`href="/admin/listing/${listing.id}"`); + }); + + test("does not offer Delete while the attendee holds unreturned cash", async () => { + const listing = await createTestListing({ maxAttendees: 100 }); + const attendee = await createTestAttendee( + listing.id, + listing.slug, + "Held Actions", + "held-actions@example.com", + ); + await postHeldPayment({ + amount: 1000, + attendeeId: attendee.id, + listingId: listing.id, + }); + const base = `/admin/attendees/${attendee.id}`; + + const html = await expectHtmlResponse( + await adminGet(`${base}/actions`), + 200, + ); + expect(html).not.toContain(`href="${base}/delete"`); + expect((await adminGet(`${base}/delete`)).status).toBe(404); + }); + + test("shows the write tabs again once the checkout resolves", async () => { + const listing = await createTestListing({ unitPrice: 1000 }); + const stage = await stageMidPaymentAttendee( + listing, + "cs_detail_tabs_resolved", + ); + const { markCheckoutStage } = await import( + "#shared/db/checkout-stages.ts" + ); + await markCheckoutStage("cs_detail_tabs_resolved", "failed"); + const base = `/admin/attendees/${stage.attendeeId}`; + + const html = await expectHtmlResponse(await adminGet(base), 200); + expect(html).toContain(`href="${base}/edit"`); + expect(html).toContain(`href="${base}/actions"`); + expect(html).toContain(`href="${base}/ledger"`); + expect(html).not.toContain("Checkout pending"); + }); + // Regression: looking at one attendee wrongly rendered the Attendees // section's "Add" sub-nav (the page passed the section landing route as its // nav-active value, which re-opened the create sub-nav). A single-attendee diff --git a/test/lib/server-attendees/attendee-edit.test.ts b/test/lib/server-attendees/attendee-edit.test.ts index 5796e5013d..d5e8edea82 100644 --- a/test/lib/server-attendees/attendee-edit.test.ts +++ b/test/lib/server-attendees/attendee-edit.test.ts @@ -11,7 +11,10 @@ import { } from "#test-utils/assertions.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { bookAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; -import { createTestAttendeeDirect } from "#test-utils/db-helpers/attendees.ts"; +import { + createTestAttendeeDirect, + getAttendeeQuantities, +} from "#test-utils/db-helpers/attendees.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; import { mockFormRequest } from "#test-utils/mocks.ts"; import { @@ -28,6 +31,7 @@ import { expectFlashPage, firstAttendee, setupListingAndAttendee, + stageMidPaymentAttendee, submitAttendeeEdit, } from "./helpers.ts"; @@ -115,6 +119,33 @@ describeWithEnv( expect(html).toContain(returnUrl); }); + test("refuses edits while the record is mid-payment", async () => { + const listing = await createTestListing({ + maxAttendees: 10, + unitPrice: 1000, + }); + const stage = await stageMidPaymentAttendee(listing, "cs_edit_guard"); + const response = await submitAttendeeEdit(stage.attendeeId, { + name: "Edited Anyway", + }); + // The Edit tab is hidden while pending (it would 404 as a re-render + // target), so the refusal redirects to the always-visible overview + // with the block message as the flash; nothing saves. + expect(response.status).toBe(302); + expect(response.headers.get("location")).toContain( + `/admin/attendees/${stage.attendeeId}`, + ); + expectFlash( + response, + expect.stringContaining("This booking is mid-payment"), + false, + ); + // The staged rows the payment will claim are untouched. + expect(await getAttendeeQuantities(stage.attendeeId)).toEqual([ + { quantity: 0 }, + ]); + }); + test("rejects whitespace-only name", async () => { const { attendee } = await setupListingAndAttendee(); const response = await submitAttendeeEdit(attendee.id, { diff --git a/test/lib/server-attendees/delete-incomplete.test.ts b/test/lib/server-attendees/delete-incomplete.test.ts index 5acf9fbbab..82298a5607 100644 --- a/test/lib/server-attendees/delete-incomplete.test.ts +++ b/test/lib/server-attendees/delete-incomplete.test.ts @@ -2,10 +2,7 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; import { getListingWithCount } from "#shared/db/listings/records.ts"; -import { - finalizeSession, - reserveSession, -} from "#shared/db/processed-payments.ts"; +import { reserveSession } from "#shared/db/processed-payments.ts"; import { expectFlashRedirect, testRequiresAuth, @@ -16,6 +13,7 @@ import { createTestAttendee, getAttendeesRaw, } from "#test-utils/db-helpers/attendees.ts"; +import { finalizeTestPaymentSession as finalizeSession } from "#test-utils/db-helpers/processed-payments.ts"; import { setupListingAndLogin } from "#test-utils/session.ts"; // jscpd:ignore-end diff --git a/test/lib/server-attendees/delete.test.ts b/test/lib/server-attendees/delete.test.ts index dd5a54f660..a710511e8f 100644 --- a/test/lib/server-attendees/delete.test.ts +++ b/test/lib/server-attendees/delete.test.ts @@ -13,6 +13,7 @@ import { describeWithEnv } from "#test-utils/db.ts"; import { createPaidTestAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; import { createTestAttendee } from "#test-utils/db-helpers/attendees.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { postHeldPayment } from "#test-utils/ledger.ts"; import { mockFormRequest } from "#test-utils/mocks.ts"; import { adminAttendeeAction, @@ -25,7 +26,7 @@ import { } from "#test-utils/session.ts"; // jscpd:ignore-end -import { setupListingAndAttendee } from "./helpers.ts"; +import { setupListingAndAttendee, stageMidPaymentAttendee } from "./helpers.ts"; /** A listing plus "John Doe" attendee, with the thank-you URL set — the * shared setup for the delete GET/POST/DELETE auth and 404 tests. */ @@ -211,6 +212,62 @@ describeWithEnv("server (admin attendees) > delete", { db: true }, () => { )(response); }); + test("refuses to delete a mid-payment staged attendee", async () => { + const { listing } = await setupListingAndLogin({ + maxAttendees: 100, + unitPrice: 1000, + }); + // A staged quantity-0 attendee whose payment can still land and claim its + // exact rows: confirming by name gets past the confirmation step, but the + // pending guard blocks the delete until the checkout finishes or expires. + const stage = await stageMidPaymentAttendee( + listing, + "cs_attendee_delete_guard", + ); + + const { response } = await adminFormPost( + `/admin/attendees/${stage.attendeeId}/delete`, + { confirm_identifier: "Buyer" }, + ); + expect(response.status).toBe(302); + expectFlash(response, expect.stringContaining("mid-payment"), false); + // The staged rows survive the refused delete. + const { getAttendeeRaw } = await import( + "#shared/db/attendees/queries.ts" + ); + expect(await getAttendeeRaw(stage.attendeeId)).not.toBeNull(); + }); + + test("refuses to delete a record holding unreturned conflict cash", async () => { + const { listing } = await setupListingAndLogin({ maxAttendees: 100 }); + const attendee = await createTestAttendee( + listing.id, + listing.slug, + "Held Buyer", + "held-delete@example.com", + ); + // A stage_active conflict resolves its stage but leaves a held provider + // payment (no sale) the operator must still refund; deleting would orphan + // that ledger cash. Confirming by name gets past the confirmation step. + await postHeldPayment({ + amount: 1000, + attendeeId: attendee.id, + listingId: listing.id, + }); + + const { response } = await adminFormPost( + `/admin/attendees/${attendee.id}/delete`, + { confirm_identifier: "Held Buyer" }, + ); + expect(response.status).toBe(302); + expectFlash(response, expect.stringContaining("not refunded"), false); + // The record survives so the operator can still refund the held cash. + const { getAttendeeRaw } = await import( + "#shared/db/attendees/queries.ts" + ); + expect(await getAttendeeRaw(attendee.id)).not.toBeNull(); + }); + test("can delete attendee without releasing bookings", async () => { const listing = await createTestListing({ maxAttendees: 50 }); const attendee = await createPaidTestAttendee( diff --git a/test/lib/server-attendees/deleted-listing.test.ts b/test/lib/server-attendees/deleted-listing.test.ts new file mode 100644 index 0000000000..165d61c6e0 --- /dev/null +++ b/test/lib/server-attendees/deleted-listing.test.ts @@ -0,0 +1,189 @@ +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { loadExistingLines } from "#shared/db/attendees/atomic-update.ts"; +import { getAttendeeRaw } from "#shared/db/attendees/queries.ts"; +import { getCheckoutStageOrNull } from "#shared/db/checkout-stages.ts"; +import { createSystemNote, getNoteRows } from "#shared/db/system-notes.ts"; +import { + expectFlashRedirect, + expectHtmlResponse, +} from "#test-utils/assertions.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; +import { getAttendeeQuantities } from "#test-utils/db-helpers/attendees.ts"; +import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { adminFormPost, adminGet } from "#test-utils/session.ts"; +import { + resolvedDeletedListingAttendee, + submitAttendeeEdit, +} from "./helpers.ts"; + +describeWithEnv( + "server (admin attendees) > deleted listing", + { db: true }, + () => { + test("exports a retained booking with a deleted-listing placeholder", async () => { + await resolvedDeletedListingAttendee("cs_csv_deleted_listing"); + + const response = await adminGet("/admin/attendees/csv"); + expect(response.status).toBe(200); + const csv = await response.text(); + expect(csv).toContain("Buyer"); + expect(csv).toContain("Deleted listing"); + }); + + test("shows an attendee whose only booking belongs to a deleted listing", async () => { + const { listingId } = await resolvedDeletedListingAttendee( + "cs_attendees_list_deleted_only", + ); + + const html = await (await adminGet("/admin/attendees")).text(); + expect(html).toContain("Buyer"); + expect(html).toContain("Deleted listing"); + expect(html).not.toContain(`href="/admin/listing/${listingId}"`); + }); + + test("shows deleted and live bookings together without a dead link", async () => { + const live = await createTestListing({ + maxAttendees: 100, + name: "Still Here", + }); + const { listingId } = await resolvedDeletedListingAttendee( + "cs_attendees_list_deleted_mixed", + [live], + ); + + const html = await (await adminGet("/admin/attendees")).text(); + expect(html).toContain("Buyer"); + expect(html).toContain("Deleted listing"); + expect(html).toContain(`href="/admin/listing/${live.id}"`); + expect(html).not.toContain(`href="/admin/listing/${listingId}"`); + }); + + test("offers only valid actions after the home listing was deleted", async () => { + const { attendeeId } = await resolvedDeletedListingAttendee( + "cs_detail_deleted_actions", + ); + const base = `/admin/attendees/${attendeeId}`; + + const overview = await expectHtmlResponse(await adminGet(base), 200); + expect(overview).toContain(`href="${base}/edit"`); + expect(overview).toContain(`href="${base}/actions"`); + const actions = await expectHtmlResponse( + await adminGet(`${base}/actions`), + 200, + ); + expect(actions).toContain(`href="${base}/delete"`); + expect(actions).not.toContain(`href="${base}/resend-notification`); + expect(actions).not.toContain("/admin/sms?"); + }); + + test("renders the deleted booking as a locked edit line", async () => { + const { attendeeId } = await resolvedDeletedListingAttendee( + "cs_edit_deleted_render", + ); + + const html = await expectHtmlResponse( + await adminGet(`/admin/attendees/${attendeeId}/edit`), + 200, + ); + expect(html).toContain("Deleted listing"); + expect(html).toContain('name="noqty_0" type="hidden"'); + expect(html).not.toContain('name="qty_0"'); + }); + + test("keeps the locked row when saving an unrelated edit", async () => { + const { attendeeId, key, listingId } = + await resolvedDeletedListingAttendee("cs_edit_deleted_keep"); + + const response = await submitAttendeeEdit(attendeeId, { + lines: [{ eventId: listingId, key, noQuantity: true, quantity: 0 }], + name: "Renamed Keeper", + }); + + expect(response.status).toBe(302); + expect(await getAttendeeQuantities(attendeeId)).toEqual([ + { quantity: 0 }, + ]); + }); + + test("refuses changing a locked deleted-listing line", async () => { + const { attendeeId, key, listingId } = + await resolvedDeletedListingAttendee("cs_edit_deleted_unlock"); + + const response = await submitAttendeeEdit(attendeeId, { + lines: [{ eventId: listingId, key, quantity: 1 }], + name: "Hand Crafted", + }); + + expect(response.status).toBe(200); + expect(await response.text()).toContain("That line is locked"); + expect(await getAttendeeQuantities(attendeeId)).toEqual([ + { quantity: 0 }, + ]); + }); + + test("refuses omitting a locked row while another booking remains", async () => { + const live = await createTestListing({ + maxAttendees: 100, + name: "Live companion", + }); + const { attendeeId } = await resolvedDeletedListingAttendee( + "cs_edit_deleted_omitted", + [live], + ); + const before = await loadExistingLines(attendeeId); + const liveLine = before.find( + ({ booking }) => booking.listing_id === live.id, + )!; + + const response = await submitAttendeeEdit(attendeeId, { + lines: [ + { + eventId: live.id, + key: liveLine.key, + noQuantity: true, + quantity: 0, + }, + ], + name: "Crafted Omission", + }); + + expect(response.status).toBe(200); + expect(await response.text()).toContain("That line is locked"); + expect(await loadExistingLines(attendeeId)).toEqual(before); + }); + + test("shows the delete confirmation without a live home listing", async () => { + const { attendeeId } = await resolvedDeletedListingAttendee( + "cs_delete_deleted_get", + ); + + await expectHtmlResponse( + await adminGet(`/admin/attendees/${attendeeId}/delete`), + 200, + "Delete Attendee", + "Buyer", + ); + }); + + test("deletes the resolved record and its dependants", async () => { + const sessionId = "cs_delete_deleted_post"; + const { attendeeId } = await resolvedDeletedListingAttendee(sessionId); + await createSystemNote(attendeeId, "Delete this note with its attendee."); + + const { response } = await adminFormPost( + `/admin/attendees/${attendeeId}/delete`, + { confirm_identifier: "Buyer", release_bookings: "1" }, + ); + + await expectFlashRedirect( + "/admin/attendees", + "Attendee deleted", + )(response); + expect(await getAttendeeRaw(attendeeId)).toBeNull(); + expect(await loadExistingLines(attendeeId)).toEqual([]); + expect(await getCheckoutStageOrNull(sessionId)).toBeNull(); + expect(await getNoteRows([attendeeId])).toEqual([]); + }); + }, +); diff --git a/test/lib/server-attendees/helpers.ts b/test/lib/server-attendees/helpers.ts index 0d989b7d08..74c0910bc9 100644 --- a/test/lib/server-attendees/helpers.ts +++ b/test/lib/server-attendees/helpers.ts @@ -1,5 +1,6 @@ import { expect } from "@std/expect"; import { handleRequest } from "#routes"; +import { stageCheckout } from "#shared/db/checkout-stages.ts"; import type { Answer, Question } from "#shared/db/question-types.ts"; import { getAttendeeAnswersBatch } from "#shared/db/questions/attendee-answers/reads.ts"; import { saveAttendeeAnswers } from "#shared/db/questions/attendee-answers/save.ts"; @@ -12,6 +13,7 @@ import { FLASH_TEST_ID, flashCookieHeader, } from "#test-utils/assertions.ts"; +import { checkoutIntent, checkoutItem } from "#test-utils/checkout.ts"; import { buildAttendeeEditForm, createTestAttendee, @@ -22,6 +24,58 @@ import { createTestListing } from "#test-utils/db-helpers/listings.ts"; import { awaitTestRequest, mockFormRequest } from "#test-utils/mocks.ts"; import { adminFormPost } from "#test-utils/session.ts"; +/** Stage a paid checkout for `listing` (a quantity-0 attendee plus a pending + * checkout stage), as if the customer were on the provider's payment page. + * The admin-mutation guard tests all build their mid-payment record here. */ +export const stageMidPaymentAttendee = ( + listing: { id: number; name: string; slug: string }, + sessionId: string, + otherListings: Array<{ id: number; name: string; slug: string }> = [], +): ReturnType => + stageCheckout( + sessionId, + "stripe", + checkoutIntent({ + items: [listing, ...otherListings].map((item) => + checkoutItem({ + listingId: item.id, + name: item.name, + slug: item.slug, + }), + ), + }), + ); + +/** A kept record whose listing was deleted mid-payment and whose checkout has + * since resolved: stage a checkout, delete the listing (the staged rows + * survive the race), then mark the stage failed. Returns the attendee id, + * the deleted listing's id, and the surviving row's line key — the state the + * deleted-listing editor tests all start from. */ +export const resolvedDeletedListingAttendee = async ( + sessionId: string, + otherListings: Array<{ id: number; name: string; slug: string }> = [], +): Promise<{ attendeeId: number; listingId: number; key: string }> => { + const listing = await createTestListing({ unitPrice: 1000 }); + const stage = await stageMidPaymentAttendee( + listing, + sessionId, + otherListings, + ); + const { deleteListing } = await import("#shared/db/listings/delete.ts"); + await deleteListing(listing.id); + const { markCheckoutStage } = await import("#shared/db/checkout-stages.ts"); + await markCheckoutStage(sessionId, "failed"); + const { loadExistingLines } = await import( + "#shared/db/attendees/atomic-update.ts" + ); + const lines = await loadExistingLines(stage.attendeeId); + return { + attendeeId: stage.attendeeId, + key: lines[0]!.key, + listingId: listing.id, + }; +}; + /** A listing (100 spots by default) plus one attendee booked onto it ("John * Doe" by default). The single most repeated setup across the attendee admin * tests — pulled here so every delete/checkin/edit/resend test shares it. */ diff --git a/test/lib/server-attendees/merge-panel.test.ts b/test/lib/server-attendees/merge-panel.test.ts index 7498ced22c..46e99cc165 100644 --- a/test/lib/server-attendees/merge-panel.test.ts +++ b/test/lib/server-attendees/merge-panel.test.ts @@ -11,7 +11,10 @@ import { bookAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; import { createTestAttendeeDirect } from "#test-utils/db-helpers/attendees.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; import { adminGet } from "#test-utils/session.ts"; -import { setupListingAndDirectAttendee } from "./helpers.ts"; +import { + setupListingAndDirectAttendee, + stageMidPaymentAttendee, +} from "./helpers.ts"; // jscpd:ignore-end import { mergePair } from "./merge.ts"; @@ -54,6 +57,19 @@ describeWithEnv("server (admin attendees) > merge panel", { db: true }, () => { ); }); + test("shows the mid-payment block instead of the decision form", async () => { + const { attendee, listing } = await setupListingAndDirectAttendee({ + listing: { maxAttendees: 10 }, + }); + const stage = await stageMidPaymentAttendee(listing, "cs_merge_panel"); + const response = await mergeActionsPage(attendee.id, stage.ticketToken); + expect(response.status).toBe(200); + const html = await response.text(); + expect(html).toContain("mid-payment"); + // No decision form renders — there is nothing the operator may submit. + expect(html).not.toContain("merge_version"); + }); + test("shows error when token not found", async () => { const { attendee } = await setupListingAndDirectAttendee({ listing: { maxAttendees: 10 }, diff --git a/test/lib/server-attendees/merge-post.test.ts b/test/lib/server-attendees/merge-post.test.ts index 287fb73080..7171fd3b4b 100644 --- a/test/lib/server-attendees/merge-post.test.ts +++ b/test/lib/server-attendees/merge-post.test.ts @@ -12,10 +12,17 @@ import { } from "#test-utils/assertions.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { createPaidTestAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; -import { createTestAttendeeDirect } from "#test-utils/db-helpers/attendees.ts"; +import { + createTestAttendeeDirect, + getAttendeeQuantities, +} from "#test-utils/db-helpers/attendees.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { postHeldPayment } from "#test-utils/ledger.ts"; import { adminFormPost, adminGet } from "#test-utils/session.ts"; -import { setupListingAndDirectAttendee } from "./helpers.ts"; +import { + setupListingAndDirectAttendee, + stageMidPaymentAttendee, +} from "./helpers.ts"; // jscpd:ignore-end import { getMergeVersion, mergePair, submitMerge } from "./merge.ts"; @@ -62,6 +69,27 @@ const runMoneyDecisionMerge = async ( expect(log?.message).toBe(expectedLog); }; +/** Post a merge and assert it was refused because a mid-payment record was + * involved: a redirect, a "mid-payment" flash, and the staged attendee left + * with its single untouched quantity-0 line. Shared by the source-side and + * target-side mid-payment guard tests, which differ only in which record is + * staged. */ +const expectMidPaymentMergeRefused = async ( + mergeIntoId: number, + sourceToken: string, + stagedAttendeeId: number, +): Promise => { + const { response } = await adminFormPost( + `/admin/attendees/${mergeIntoId}/merge`, + { source_token: sourceToken }, + ); + expect(response.status).toBe(302); + expectFlash(response, expect.stringContaining("mid-payment"), false); + expect(await getAttendeeQuantities(stagedAttendeeId)).toEqual([ + { quantity: 0 }, + ]); +}; + describeWithEnv("server (admin attendees) > merge post", { db: true }, () => { describe("POST /admin/attendees/:attendeeId/merge", () => { testRequiresAuth("/admin/attendees/1/merge", { @@ -126,6 +154,66 @@ describeWithEnv("server (admin attendees) > merge post", { db: true }, () => { ); }); + test("refuses a mid-payment source", async () => { + const listing = await createTestListing({ maxAttendees: 10 }); + const { attendee: target } = await createTestAttendeeDirect( + listing.id, + "Jane Doe", + "jane@example.com", + ); + const stage = await stageMidPaymentAttendee(listing, "cs_merge_source"); + // The staged record is the merge SOURCE (merged from). + await expectMidPaymentMergeRefused( + target.id, + stage.ticketToken, + stage.attendeeId, + ); + }); + + test("refuses a mid-payment target", async () => { + const listing = await createTestListing({ maxAttendees: 10 }); + const stage = await stageMidPaymentAttendee(listing, "cs_merge_target"); + const { token: sourceToken } = await setupListingAndDirectAttendee({ + listing: { maxAttendees: 10 }, + }); + // The staged record is the merge TARGET (merged into). + await expectMidPaymentMergeRefused( + stage.attendeeId, + sourceToken, + stage.attendeeId, + ); + }); + + test("refuses a merge when a record holds unreturned conflict cash", async () => { + const listing = await createTestListing({ maxAttendees: 10 }); + const { attendee: target } = await createTestAttendeeDirect( + listing.id, + "Jane Doe", + "jane@example.com", + ); + const { attendee: source, token: sourceToken } = + await createTestAttendeeDirect( + listing.id, + "John Smith", + "john@example.com", + ); + // A stage_active conflict leaves the source holding a provider payment + // (no sale) that must be refunded; merging would repoint that held cash + // onto a mixed account the refund ledger can't reconcile. + await postHeldPayment({ + amount: 500, + attendeeId: source.id, + listingId: listing.id, + }); + + const { response } = await adminFormPost( + `/admin/attendees/${target.id}/merge`, + { source_token: sourceToken }, + ); + expect(response.status).toBe(302); + expectFlash(response, expect.stringContaining("not refunded"), false); + }); + test("a merge that adopts the source's email re-homes Previous bookings", async () => { const listing = await createTestListing({ maxAttendees: 10, diff --git a/test/lib/server-backup.test.ts b/test/lib/server-backup.test.ts index 9c1425f108..3c46df0594 100644 --- a/test/lib/server-backup.test.ts +++ b/test/lib/server-backup.test.ts @@ -3,7 +3,8 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; import { zipSync } from "fflate"; import { handleRequest } from "#routes"; -import { backupDir, createBackupZip } from "#shared/db/backup.ts"; +import { createBackupZip } from "#shared/db/backup.ts"; +import { backupDir } from "#shared/db/backup-storage.ts"; import { getDb } from "#shared/db/client.ts"; import { invalidateInitDbCache } from "#shared/db/migrations.ts"; import { downloadRaw, uploadRaw } from "#shared/storage.ts"; diff --git a/test/lib/server-balance-payment-replay.test.ts b/test/lib/server-balance-payment-replay.test.ts index 23d37e02a0..d1059c5d48 100644 --- a/test/lib/server-balance-payment-replay.test.ts +++ b/test/lib/server-balance-payment-replay.test.ts @@ -96,5 +96,8 @@ describeWithEnv("server (balance payment replay)", { db: true }, () => { expect(references.map((reference) => reference.reference)).toContain( `pi_${sessionId}`, ); + expect( + references.filter(({ reference }) => reference === `pi_${sessionId}`), + ).toHaveLength(1); }); }); diff --git a/test/lib/server-balance-webhook.test.ts b/test/lib/server-balance-webhook.test.ts index 26eeb3874a..18ed2c40ab 100644 --- a/test/lib/server-balance-webhook.test.ts +++ b/test/lib/server-balance-webhook.test.ts @@ -9,6 +9,7 @@ import { } from "#shared/accounting/queries.ts"; import { getAttendeeBalanceState } from "#shared/db/attendees/balance.ts"; import { execute } from "#shared/db/client.ts"; +import { isSessionProcessed } from "#shared/db/processed-payments.ts"; import { prunePayments } from "#shared/db/prune.ts"; import { resetStripeClient, stripeApi } from "#shared/stripe.ts"; import { describeWithEnv } from "#test-utils/db.ts"; @@ -108,6 +109,9 @@ describeWithEnv("server (public balance page) > webhook", { db: true }, () => { expect( (await getAttendeeBalanceState(attendeeId))?.remainingBalance, ).toBe(0); + expect((await isSessionProcessed("cs_balance_replay"))?.attendee_id).toBe( + attendeeId, + ); } finally { second.restore(); refund.restore(); diff --git a/test/lib/server-built-sites-update.test.ts b/test/lib/server-built-sites-update.test.ts index 76d8688a98..273f39afbd 100644 --- a/test/lib/server-built-sites-update.test.ts +++ b/test/lib/server-built-sites-update.test.ts @@ -3,7 +3,11 @@ import { afterEach, beforeEach, it as test } from "@std/testing/bdd"; import { stub } from "@std/testing/mock"; import { handleRequest } from "#routes"; import { bunnyCdnApi } from "#shared/bunny-cdn.ts"; -import { backupKey, backupTimestamp, dbName } from "#shared/db/backup.ts"; +import { + backupKey, + backupTimestamp, + dbName, +} from "#shared/db/backup-storage.ts"; import { ALL_SETTINGS_KEYS, settings } from "#shared/db/settings.ts"; import { denoDeployApi } from "#shared/deno-deploy-api.ts"; import { uploadRaw } from "#shared/storage.ts"; diff --git a/test/lib/server-bulk-email/notes-and-history.test.ts b/test/lib/server-bulk-email/notes-and-history.test.ts index daee949f3e..aa5251991c 100644 --- a/test/lib/server-bulk-email/notes-and-history.test.ts +++ b/test/lib/server-bulk-email/notes-and-history.test.ts @@ -10,7 +10,7 @@ import { saveContactRecord, toContactHashParam, } from "#shared/db/contact-preferences.ts"; -import { recordBooking } from "#shared/db/contact-tokens.ts"; +import { recordBookingActivity } from "#shared/db/contact-tokens.ts"; import { settings } from "#shared/db/settings.ts"; import { expectHtmlResponse, expectRedirect } from "#test-utils/assertions.ts"; import { getTestPrivateKey } from "#test-utils/crypto.ts"; @@ -163,8 +163,8 @@ describeWithEnv("server bulk email > notes and history", { db: true }, () => { // ...and we seed split booking counts plus a private markdown note on each // contact record (preserving the counts already recorded for the email). - await recordBooking(emailHash, "public", "tok-bulk-pub"); - await recordBooking(emailHash, "admin", "tok-bulk-adm"); + await recordBookingActivity(emailHash, "public", "tok-bulk-pub"); + await recordBookingActivity(emailHash, "admin", "tok-bulk-adm"); await saveContactRecord(emailHash, { ...(await getContactRecord(emailHash, pk)), adminNotes: "**Email VIP** customer", diff --git a/test/lib/server-ledger/add-entry.test.ts b/test/lib/server-ledger/add-entry.test.ts index 1a343fe48c..121c5e2aa2 100644 --- a/test/lib/server-ledger/add-entry.test.ts +++ b/test/lib/server-ledger/add-entry.test.ts @@ -12,7 +12,7 @@ import { import { allTransfers } from "#shared/accounting/queries.ts"; import { modifiersTable } from "#shared/db/modifiers.ts"; import { account } from "#shared/ledger/account.ts"; -import { expectFlashRedirect } from "#test-utils/assertions.ts"; +import { expectFlash, expectFlashRedirect } from "#test-utils/assertions.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; import { adminFormPost, adminGet } from "#test-utils/session.ts"; @@ -94,6 +94,31 @@ describeWithEnv("server (admin ledger add entry)", { db: true }, () => { expect(entry?.destination).toEqual(account("attendee", attendeeId)); }); + test("refuses a manual attendee entry while a checkout is pending", async () => { + const listing = await createTestListing({ + maxAttendees: 10, + unitPrice: 1000, + }); + const { stageMidPaymentAttendee } = await import( + "../server-attendees/helpers.ts" + ); + const stage = await stageMidPaymentAttendee(listing, "cs_ledger_add"); + // The checkout's activation posts its own sale/payment legs when the + // payment lands; a manual entry added mid-payment would combine with + // them into a surprise balance, so the write is refused. + const { response } = await adminFormPost( + `/admin/ledger/attendee/${stage.attendeeId}/add`, + { + amount: "12.34", + entry_type: MANUAL_ATTENDEE_PAYMENT, + occurred_at: "2026-06-22T09:30", + }, + ); + expect(response.status).toBe(302); + expectFlash(response, expect.stringContaining("mid-payment"), false); + expect(await allTransfers()).toEqual([]); + }); + test("posts a listing cost against the listing revenue account", async () => { const listing = await createTestListing({ maxAttendees: 10, diff --git a/test/lib/server-listings/delete.test.ts b/test/lib/server-listings/delete.test.ts index a864b56888..67928a46d2 100644 --- a/test/lib/server-listings/delete.test.ts +++ b/test/lib/server-listings/delete.test.ts @@ -25,6 +25,7 @@ import { } from "#test-utils/session.ts"; // jscpd:ignore-end +import { stageMidPaymentAttendee } from "../server-attendees/helpers.ts"; describeWithEnv("server listings > delete", { db: true }, () => { describe("GET /admin/listing/:id/delete", () => { @@ -113,6 +114,60 @@ describeWithEnv("server listings > delete", { db: true }, () => { expectFlash(response, expect.stringContaining("does not match"), false); }); + test("refuses to delete a listing with a booking mid-payment", async () => { + const { listing } = await setupListingAndLogin({ + maxAttendees: 100, + name: "Test Listing", + unitPrice: 1000, + }); + // A staged quantity-0 checkout for this listing: its payment can still + // land and claim the rows, so deleting the listing (which removes those + // rows) is blocked until the checkout finishes or expires. + await stageMidPaymentAttendee(listing, "cs_listing_delete_guard"); + + const { response } = await adminFormPost("/admin/listing/1/delete", { + confirm_identifier: listing.name, + }); + expect(response.status).toBe(302); + expectFlash(response, expect.stringContaining("mid-payment"), false); + // The listing survives the refused delete. + expect(await getListingWithCount(listing.id)).not.toBeNull(); + }); + + test("refuses to delete a listing whose attendee holds unreturned cash", async () => { + // A free listing keeps the booking's sale at 0, so the held payment + // below projects as a positive balance (cash in, nothing owed). + const { listing } = await setupListingAndLogin({ + maxAttendees: 100, + name: "Held Cash Listing", + }); + const attendee = await createTestAttendee( + listing.id, + listing.slug, + "Held Buyer", + "held-listing-delete@example.com", + ); + // A stage_active conflict leaves a held provider payment (no sale) on + // the attendee. Deleting the listing would cascade the booking line the + // in-app refund needs, stranding that charge — so the delete is refused + // until the cash is refunded. + const { postHeldPayment } = await import("#test-utils/ledger.ts"); + await postHeldPayment({ + amount: 1000, + attendeeId: attendee.id, + listingId: listing.id, + }); + + const { response } = await adminFormPost( + `/admin/listing/${listing.id}/delete`, + { confirm_identifier: listing.name }, + ); + expect(response.status).toBe(302); + expectFlash(response, expect.stringContaining("not refunded"), false); + // The listing (and the refund path) survive the refused delete. + expect(await getListingWithCount(listing.id)).not.toBeNull(); + }); + test("displays error on confirmation page after failed attempt", async () => { await setupListingAndLogin({ maxAttendees: 100, diff --git a/test/lib/server-payment-staging-helpers.ts b/test/lib/server-payment-staging-helpers.ts new file mode 100644 index 0000000000..2908c36d7d --- /dev/null +++ b/test/lib/server-payment-staging-helpers.ts @@ -0,0 +1,167 @@ +import { assert, assertExists } from "@std/assert"; +import { expect } from "@std/expect"; +import { stub } from "@std/testing/mock"; +import { handleRequest } from "#routes"; +import { attendeeAccount, WORLD } from "#shared/accounting/accounts.ts"; +import { legReference } from "#shared/accounting/refs.ts"; +import { postTransfers } from "#shared/accounting/store.ts"; +import { decryptAttendeeFields } from "#shared/db/attendees/pii.ts"; +import { getAttendeeRaw } from "#shared/db/attendees/queries.ts"; +import { getCheckoutStageOrNull } from "#shared/db/checkout-stages.ts"; +import { getDb } from "#shared/db/client.ts"; +import { listingsTable } from "#shared/db/listings/records.ts"; +import { STALE_RESERVATION_MS } from "#shared/db/processed-payments.ts"; +import { assembleCheckoutMetadata } from "#shared/payment-helpers.ts"; +import { stripeApi } from "#shared/stripe.ts"; +import { getTestPrivateKey } from "#test-utils/crypto.ts"; +import { bookAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; +import { mockRequest } from "#test-utils/mocks.ts"; +import { stubRetrieveCheckoutSession } from "#test-utils/webhooks.ts"; + +/** Book a filler attendee so the listing's only remaining spot is consumed — + * the state that makes a paid staged order unable to activate. */ +export const fillListing = async ( + listing: Parameters[0], +): Promise => { + const filler = await bookAttendee(listing, { + email: "filler@example.com", + name: "Filler", + }); + assert(filler.success, "Expected filler booking"); +}; + +/** The checkout intent a stubbed checkout captured, or a loud failure. */ +export const requireIntent = ( + getCaptured: () => T | null | undefined, +): T => { + const intent = getCaptured(); + assertExists(intent, "Expected captured checkout intent"); + return intent; +}; + +/** Stub the provider so a refund genuinely fails: the refund call returns + * nothing AND the payment does not read as already refunded, so `tryRefund` + * reports false. Disposable — restore both stubs with `using`. */ +export const stubFailedRefund = (): Disposable => { + const refund = stub(stripeApi, "refundPayment", () => Promise.resolve(null)); + const status = stub(stripeApi, "retrievePaymentIntent", () => + Promise.resolve(null), + ); + return { + [Symbol.dispose]() { + status.restore(); + refund.restore(); + }, + }; +}; + +/** Drive the customer redirect (`/payment/success`) for a paid staged session, + * stubbing the provider's session retrieval to return the signed metadata. */ +export const paidReturn = async ( + sessionId: string, + intent: Parameters[1], + total: number, +): Promise => { + using _retrieve = stubRetrieveCheckoutSession({ + amountTotal: total, + metadata: await assembleCheckoutMetadata("stripe", intent, total), + paymentIntent: `pi_${sessionId}`, + sessionId, + }); + // `return await`, not `return`: the `using` stub is disposed when this scope + // exits, so returning the bare promise would restore the real session + // retrieval before the request runs and every call would 400 at classify. + return await handleRequest( + mockRequest(`/payment/success?session_id=${sessionId}`), + ); +}; + +/** Close the listing while the buyer is on the provider's page (written + * through the table layer — closes_at is an encrypted column). */ +export const closeListingMidPayment = (listingId: number) => + listingsTable.update(listingId, { + closesAt: new Date(Date.now() - 60000).toISOString().slice(0, 16), + }); + +/** Pre-claim the reference the session's payment leg will need, so the + * session's money cannot be written to the ledger until it is repaired. */ +export const blockSessionPaymentLeg = async (sessionId: string) => + postTransfers([ + { + amount: 100, + destination: attendeeAccount(999999), + eventGroup: `blocker-${sessionId}`, + kind: "payment", + occurredAt: new Date().toISOString(), + reference: await legReference(["booking", sessionId, "payment"]), + source: WORLD, + }, + ]); + +/** Repair the collision so the session's money can be recorded on retry. */ +export const unblockSessionPaymentLeg = (sessionId: string) => + getDb().execute("DELETE FROM transfers WHERE event_group = ?", [ + `blocker-${sessionId}`, + ]); + +/** Age the session's reservation past the stale window, the state a provider + * redelivery finds after a failed attempt. */ +export const ageReservation = (sessionId: string) => + getDb().execute( + "UPDATE processed_payments SET processed_at = ? WHERE payment_session_id = ?", + [ + new Date(Date.now() - STALE_RESERVATION_MS - 1000).toISOString(), + sessionId, + ], + ); + +/** The ledger shows the charge we received and the refund returning it — and + * nothing else: no sale leg, since the booking was never honoured. */ +export const expectRefundRoundTripLegs = async (): Promise => { + const legs = await getDb().execute("SELECT kind FROM transfers"); + expect(legs.rows.map((row) => row.kind).toSorted()).toEqual([ + "payment", + "refund_cash", + ]); +}; + +export const stubSuccessfulRefund = (refundId: string) => + stub(stripeApi, "refundPayment", () => + Promise.resolve({ id: refundId, status: "succeeded" } as unknown as Awaited< + ReturnType + >), + ); + +export const expectStage = async ( + sessionId: string, + state: string, + quantity: number, +): Promise => { + const result = await getDb().execute( + `SELECT stage.state, booking.quantity + FROM checkout_stages AS stage + JOIN listing_attendees AS booking + ON booking.attendee_id = stage.attendee_id + WHERE stage.payment_session_id = ?`, + [sessionId], + ); + expect(result.rows.map((row) => [row.state, row.quantity])).toEqual([ + [state, quantity], + ]); +}; + +/** Assert a still-open staged session's attendee carries no payment + * reference — a failed money post threw before stamping one, so the Actions tab + * has no charge to offer an in-app refund against while it stays unrecorded. */ +export const expectNoStampedPayment = async ( + sessionId: string, +): Promise => { + const stage = await getCheckoutStageOrNull(sessionId); + assertExists(stage, `Expected a pending stage for ${sessionId}`); + const attendee = await getAttendeeRaw(stage.attendeeId); + assertExists(attendee, `Expected the staged attendee for ${sessionId}`); + expect( + (await decryptAttendeeFields(attendee, await getTestPrivateKey(), true)) + .payment_id, + ).toBe(""); +}; diff --git a/test/lib/server-payment-staging-recovery.test.ts b/test/lib/server-payment-staging-recovery.test.ts new file mode 100644 index 0000000000..2ac6daa72c --- /dev/null +++ b/test/lib/server-payment-staging-recovery.test.ts @@ -0,0 +1,519 @@ +// jscpd:ignore-start +import { expect } from "@std/expect"; +import { afterEach, it as test } from "@std/testing/bdd"; +import { stub } from "@std/testing/mock"; +import { decryptAttendeeFields } from "#shared/db/attendees/pii.ts"; +import { getAttendeeRaw } from "#shared/db/attendees/queries.ts"; +import { + getCheckoutStageOrNull, + markCheckoutStage, +} from "#shared/db/checkout-stages.ts"; +import { getDb } from "#shared/db/client.ts"; +import { settings } from "#shared/db/settings.ts"; +import { getNotesForAttendee } from "#shared/db/system-notes.ts"; +import { recordPlaceholderRefund } from "#shared/refund-ledger.ts"; +import { CONFIG_KEYS } from "#shared/settings/keys.ts"; +import { resetStripeClient, stripeApi } from "#shared/stripe.ts"; +import { stubCheckout } from "#test-utils/checkout.ts"; +import { getTestPrivateKey } from "#test-utils/crypto.ts"; +import { submitTicketForm } from "#test-utils/csrf.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; +import { bookAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; +import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { setupErrorSpy } from "#test-utils/error-spy.ts"; +import { setupStripe, stubWebhookVerify } from "#test-utils/settings.ts"; +import { postWebhookAndAssert } from "#test-utils/webhooks.ts"; +import { + ageReservation, + blockSessionPaymentLeg, + closeListingMidPayment, + expectNoStampedPayment, + expectRefundRoundTripLegs, + expectStage, + paidReturn, + requireIntent, + stubFailedRefund, + stubSuccessfulRefund, + unblockSessionPaymentLeg, +} from "./server-payment-staging-helpers.ts"; + +// jscpd:ignore-end + +describeWithEnv("paid checkout staging — recovery", { db: true }, () => { + setupErrorSpy(); + afterEach(() => resetStripeClient()); + test("discards the staged details when the checkout expires", async () => { + await setupStripe(); + const listing = await createTestListing({ unitPrice: 1000 }); + const { checkout } = stubCheckout("cs_staged_expired"); + + try { + await submitTicketForm(listing.slug, { + [`quantity_${listing.id}`]: "1", + email: "expired@example.com", + name: "Expired Buyer", + }); + const staged = await getDb().execute( + "SELECT attendee_id FROM checkout_stages WHERE payment_session_id = ?", + ["cs_staged_expired"], + ); + const stagedAttendeeId = Number(staged.rows[0]!.attendee_id); + + // Stripe reports the checkout expired unpaid: nothing can pay it any + // more, so the staged details are removed right away instead of + // waiting out the seven-day prune. + const mockVerify = await stubWebhookVerify({ + data: { object: { id: "cs_staged_expired" } }, + id: "evt_staged_expired", + type: "checkout.session.expired", + }); + await postWebhookAndAssert( + () => mockVerify.restore(), + 200, + (json: { received: boolean }) => { + expect(json.received).toBe(true); + }, + ); + + const stage = await getDb().execute( + "SELECT 1 FROM checkout_stages WHERE payment_session_id = ?", + ["cs_staged_expired"], + ); + expect(stage.rows).toEqual([]); + const attendee = await getDb().execute( + "SELECT 1 FROM attendees WHERE id = ?", + [stagedAttendeeId], + ); + expect(attendee.rows).toEqual([]); + } finally { + checkout.restore(); + } + }); + + test("does not reactivate a resolved stage when its records are gone", async () => { + await setupStripe(); + const listing = await createTestListing({ unitPrice: 1000 }); + const { checkout, getCaptured } = stubCheckout("cs_staged_resolved"); + const refund = stubSuccessfulRefund("re_staged_resolved"); + + try { + await submitTicketForm(listing.slug, { + [`quantity_${listing.id}`]: "1", + email: "resolved@example.com", + name: "Resolved Buyer", + }); + // The stage resolved (its order was refunded), and the session's + // idempotency row and ledger legs are gone — pruned, or the ledger post + // was swallowed. A very late redelivery must not book a live ticket for + // money that was already refunded. + await markCheckoutStage("cs_staged_resolved", "failed"); + const intent = requireIntent(getCaptured); + + const response = await paidReturn("cs_staged_resolved", intent, 1000); + expect(response.status).toBe(200); + expect(await response.text()).toContain("already been processed"); + await expectStage("cs_staged_resolved", "failed", 0); + expect(refund.calls.length).toBe(0); + } finally { + checkout.restore(); + refund.restore(); + } + }); + + test("keeps the stage refunding when the refund attempt dies mid-path", async () => { + await setupStripe(); + const listing = await createTestListing({ + maxAttendees: 1, + unitPrice: 1000, + }); + const { checkout, getCaptured } = stubCheckout("cs_staged_refund_down"); + + try { + await submitTicketForm(listing.slug, { + [`quantity_${listing.id}`]: "1", + email: "down@example.com", + name: "Down Buyer", + }); + const filler = await bookAttendee(listing, { + email: "filler-down@example.com", + name: "Filler Down", + }); + if (!filler.success) throw new Error("Expected filler booking"); + const intent = requireIntent(getCaptured); + + // The refund transport dies mid-path. The stage must stay pending: + // resolving it before the money is recorded would make the retry answer + // "already processed" with the customer still charged and nothing on + // the record. + { + using _refund = stub(stripeApi, "refundPayment", () => + Promise.reject(new Error("refund transport down")), + ); + await expect( + paidReturn("cs_staged_refund_down", intent, 1000), + ).rejects.toThrow("refund transport down"); + } + await expectStage("cs_staged_refund_down", "refunding", 0); + + // The provider redelivers after the reservation goes stale, and this + // time the refund lands: the booking is kept, refunded, and resolved. + await ageReservation("cs_staged_refund_down"); + using refund = stubSuccessfulRefund("re_staged_refund_down"); + const retry = await paidReturn("cs_staged_refund_down", intent, 1000); + expect(await retry.text()).toContain("automatically refunded"); + await expectStage("cs_staged_refund_down", "failed", 0); + expect(refund.calls.length).toBe(1); + } finally { + checkout.restore(); + } + }); + + test("leaves the stage refunding when the closed-listing refund fails", async () => { + await setupStripe(); + const listing = await createTestListing({ unitPrice: 1000 }); + const { checkout, getCaptured } = stubCheckout( + "cs_staged_closed_unrefunded", + ); + + try { + await submitTicketForm(listing.slug, { + [`quantity_${listing.id}`]: "1", + email: "unrefunded@example.com", + name: "Unrefunded Buyer", + }); + await closeListingMidPayment(listing.id); + const intent = requireIntent(getCaptured); + + // The provider cannot refund right now, and the payment does not read + // as already refunded — the refund attempt genuinely failed. + using _refund = stubFailedRefund(); + const response = await paidReturn( + "cs_staged_closed_unrefunded", + intent, + 1000, + ); + expect(response.status).toBe(410); + // The stage stays refunding so the released reservation's retry re-runs + // the whole refund path — resolving it now would answer "already + // processed" with the customer still charged. + await expectStage("cs_staged_closed_unrefunded", "refunding", 0); + // Nothing was ledgered or noted either; the retry re-runs everything. + const legs = await getDb().execute("SELECT kind FROM transfers"); + expect(legs.rows).toEqual([]); + } finally { + checkout.restore(); + } + }); + + test("resolves a pending stage left behind when the ledger already holds the money", async () => { + await setupStripe(); + const listing = await createTestListing({ unitPrice: 1000 }); + const { checkout, getCaptured } = stubCheckout("cs_staged_wedged"); + using refund = stubSuccessfulRefund("re_staged_wedged"); + + try { + await submitTicketForm(listing.slug, { + [`quantity_${listing.id}`]: "1", + email: "wedged@example.com", + name: "Wedged Buyer", + }); + const stage = await getCheckoutStageOrNull("cs_staged_wedged"); + if (!stage) throw new Error("Expected a stage for cs_staged_wedged"); + // A crash left the money recorded but the stage unresolved: the ledger + // round-trip landed, then the process died before the stage flip. + await recordPlaceholderRefund( + { + amount: 1000, + attendeeId: stage.attendeeId, + eventId: "cs_staged_wedged", + listingId: listing.id, + occurredAt: new Date().toISOString(), + }, + "listing_closed", + true, + ); + const intent = requireIntent(getCaptured); + + // The next delivery answers off the ledger — and heals the leftover + // stage, so the kept record is editable and prunable again instead of + // reading as mid-payment forever. + const response = await paidReturn("cs_staged_wedged", intent, 1000); + expect(response.status).toBe(200); + expect(await response.text()).toContain("already been processed"); + await expectStage("cs_staged_wedged", "failed", 0); + expect(refund.calls.length).toBe(0); + // The heal also leaves the note the interrupted run never wrote, so the + // operator can see why this record's money is already in the ledger. + const notes = await getNotesForAttendee( + stage.attendeeId, + await getTestPrivateKey(), + ); + expect(notes).toHaveLength(1); + expect(notes[0]!.note).toContain("pi_cs_staged_wedged"); + // The crash lost the payment-reference stamp (the money legs post before + // stampStagedPaymentId), so the heal writes it back — without it the kept + // record's payment panel and refund path stay hidden for real money. + const kept = await getAttendeeRaw(stage.attendeeId); + if (!kept) throw new Error("Expected the healed staged attendee"); + expect( + (await decryptAttendeeFields(kept, await getTestPrivateKey(), true)) + .payment_id, + ).toBe("pi_cs_staged_wedged"); + } finally { + checkout.restore(); + } + }); + + test("retries the conflict when the held payment cannot be recorded", async () => { + await setupStripe(); + const listing = await createTestListing({ unitPrice: 1000 }); + const { checkout, getCaptured } = stubCheckout("cs_staged_blocked"); + using refund = stubSuccessfulRefund("re_staged_blocked"); + + try { + await submitTicketForm(listing.slug, { + [`quantity_${listing.id}`]: "1", + email: "blocked@example.com", + name: "Blocked Buyer", + }); + await getDb().execute( + `UPDATE listing_attendees SET quantity = 1 + WHERE attendee_id = (SELECT attendee_id FROM checkout_stages + WHERE payment_session_id = ?)`, + ["cs_staged_blocked"], + ); + const intent = requireIntent(getCaptured); + + // Another event already owns the reference the held-payment leg needs, + // so the ledger write fails. The conflict must NOT go terminal with the + // money missing from the books: it throws, the stage stays pending, and + // the provider's next delivery re-runs the whole path. + await blockSessionPaymentLeg("cs_staged_blocked"); + await expect( + paidReturn("cs_staged_blocked", intent, 1000), + ).rejects.toThrow("money in the ledger"); + await expectStage("cs_staged_blocked", "pending", 1); + expect(refund.calls.length).toBe(0); + // The failed post threw BEFORE stamping the payment reference, so the + // still-pending record exposes no charge for the Actions tab to offer an + // in-app refund against while the money is unrecorded. + await expectNoStampedPayment("cs_staged_blocked"); + + // The colliding event is repaired and the provider redelivers after the + // reservation goes stale: this time the held payment is recorded and + // the conflict resolves for the operator. + await unblockSessionPaymentLeg("cs_staged_blocked"); + await ageReservation("cs_staged_blocked"); + const retry = await paidReturn("cs_staged_blocked", intent, 1000); + expect(retry.status).toBe(200); + expect(await retry.text()).toContain( + "needs to be confirmed by the organiser", + ); + await expectStage("cs_staged_blocked", "failed", 1); + const legs = await getDb().execute("SELECT kind FROM transfers"); + expect(legs.rows.map((row) => row.kind)).toEqual(["payment"]); + expect(refund.calls.length).toBe(0); + } finally { + checkout.restore(); + } + }); + + test("retries a kept-and-refunded staged order when its ledger post fails", async () => { + await setupStripe(); + const listing = await createTestListing({ + maxAttendees: 1, + unitPrice: 1000, + }); + const { checkout, getCaptured } = stubCheckout("cs_staged_kept_blocked"); + using _refund = stubSuccessfulRefund("re_staged_kept_blocked"); + + try { + await submitTicketForm(listing.slug, { + [`quantity_${listing.id}`]: "1", + email: "kept-blocked@example.com", + name: "Kept Blocked Buyer", + }); + // Fill the one seat so the paid activation fails on capacity and routes to + // the keep-and-refund placeholder path (storeRefundedBooking). + const filler = await bookAttendee(listing, { + email: "kept-filler@example.com", + name: "Kept Filler", + }); + if (!filler.success) throw new Error("Expected filler booking"); + const intent = requireIntent(getCaptured); + + // The placeholder ledger post can't be written (its payment reference is + // pre-claimed). A staged keep-and-refund must NOT go terminal with the + // money missing from the books: it throws and the stage stays refunding. + await blockSessionPaymentLeg("cs_staged_kept_blocked"); + await expect( + paidReturn("cs_staged_kept_blocked", intent, 1000), + ).rejects.toThrow("placeholder money in the ledger"); + await expectStage("cs_staged_kept_blocked", "refunding", 0); + await expectNoStampedPayment("cs_staged_kept_blocked"); + + // The collision is repaired and the provider redelivers: the money + // round-trip is recorded and the order resolves at quantity 0. + await unblockSessionPaymentLeg("cs_staged_kept_blocked"); + await ageReservation("cs_staged_kept_blocked"); + const retry = await paidReturn("cs_staged_kept_blocked", intent, 1000); + expect(await retry.text()).toContain("automatically refunded"); + await expectStage("cs_staged_kept_blocked", "failed", 0); + await expectRefundRoundTripLegs(); + } finally { + checkout.restore(); + } + }); + + test("retries the closed-listing refund when its ledger write fails", async () => { + await setupStripe(); + const listing = await createTestListing({ unitPrice: 1000 }); + const { checkout, getCaptured } = stubCheckout("cs_staged_closed_blocked"); + using _refund = stubSuccessfulRefund("re_staged_closed_blocked"); + + try { + await submitTicketForm(listing.slug, { + [`quantity_${listing.id}`]: "1", + email: "closed-blocked@example.com", + name: "Closed Blocked Buyer", + }); + await closeListingMidPayment(listing.id); + const intent = requireIntent(getCaptured); + + // The refund settles at the provider, but its ledger round-trip cannot + // be written. The outcome must NOT go terminal with the money story + // missing: it throws and the stage stays refunding for the redelivery. + await blockSessionPaymentLeg("cs_staged_closed_blocked"); + await expect( + paidReturn("cs_staged_closed_blocked", intent, 1000), + ).rejects.toThrow("money in the ledger"); + await expectStage("cs_staged_closed_blocked", "refunding", 0); + + // The collision is repaired and the provider redelivers: the settled + // refund reads back as refunded, the round-trip is recorded, and the + // stage resolves. + await unblockSessionPaymentLeg("cs_staged_closed_blocked"); + await ageReservation("cs_staged_closed_blocked"); + const retry = await paidReturn("cs_staged_closed_blocked", intent, 1000); + expect(retry.status).toBe(200); + await expectStage("cs_staged_closed_blocked", "failed", 0); + await expectRefundRoundTripLegs(); + } finally { + checkout.restore(); + } + }); + + test("marks the stage failed when registration closes mid-payment", async () => { + await setupStripe(); + const listing = await createTestListing({ unitPrice: 1000 }); + const { checkout, getCaptured } = stubCheckout("cs_staged_closed"); + const refund = stubSuccessfulRefund("re_staged_closed"); + + try { + await submitTicketForm(listing.slug, { + [`quantity_${listing.id}`]: "1", + email: "closed@example.com", + name: "Closed Buyer", + }); + await closeListingMidPayment(listing.id); + const intent = requireIntent(getCaptured); + + const response = await paidReturn("cs_staged_closed", intent, 1000); + expect(response.status).toBe(410); + expect(await response.text()).toContain("refunded"); + expect(refund.calls.length).toBe(1); + // The stage resolves (no longer pending, so the retention promise + // holds) and the staged record explains the refund to the operator. + await expectStage("cs_staged_closed", "failed", 0); + const stage = await getCheckoutStageOrNull("cs_staged_closed"); + if (!stage) throw new Error("Expected a stage for cs_staged_closed"); + const notes = await getNotesForAttendee( + stage.attendeeId, + await getTestPrivateKey(), + ); + expect(notes).toHaveLength(1); + expect(notes[0]!.note).toContain("stopped taking bookings"); + // The provider's payment reference is stamped into the stored details, + // so the record's payment panel can find the charge and its refund. + const kept = await getAttendeeRaw(stage.attendeeId); + if (!kept) throw new Error("Expected the staged attendee to be kept"); + expect( + (await decryptAttendeeFields(kept, await getTestPrivateKey(), true)) + .payment_id, + ).toBe("pi_cs_staged_closed"); + await expectRefundRoundTripLegs(); + } finally { + checkout.restore(); + refund.restore(); + } + }); + + test("rolls activation back when payment finalization is lost", async () => { + await setupStripe(); + const listing = await createTestListing({ unitPrice: 1000 }); + const { checkout, getCaptured } = stubCheckout("cs_staged_finalize"); + + try { + await submitTicketForm(listing.slug, { + [`quantity_${listing.id}`]: "1", + email: "finalize@example.com", + name: "Finalize Buyer", + }); + await getDb().execute( + `CREATE TRIGGER lose_payment_finalize + BEFORE UPDATE OF quantity ON listing_attendees + BEGIN + DELETE FROM processed_payments + WHERE payment_session_id = 'cs_staged_finalize'; + END`, + ); + const intent = requireIntent(getCaptured); + + // Losing the finalize row mid-transaction is a race with a replacement + // reservation: the whole activation rolls back and the error propagates, + // so the provider's next redelivery retries a clean slate. + await expect( + paidReturn("cs_staged_finalize", intent, 1000), + ).rejects.toThrow("was not finalized"); + await expectStage("cs_staged_finalize", "pending", 0); + // The trigger's delete rolled back with the transaction: the reservation + // row is still there for the retry to claim. + const reservation = await getDb().execute( + "SELECT 1 FROM processed_payments WHERE payment_session_id = ?", + ["cs_staged_finalize"], + ); + expect(reservation.rows.length).toBe(1); + } finally { + checkout.restore(); + } + }); + + test("fails loudly when staged attendee encryption is unavailable", async () => { + await setupStripe(); + const listing = await createTestListing({ unitPrice: 1000 }); + const { checkout, getCaptured } = stubCheckout("cs_staged_encryption"); + + try { + await submitTicketForm(listing.slug, { + [`quantity_${listing.id}`]: "1", + email: "encryption@example.com", + name: "Encryption Buyer", + }); + await getDb().execute("DELETE FROM settings WHERE key = ?", [ + CONFIG_KEYS.PUBLIC_KEY, + ]); + settings.invalidateCache(); + const intent = requireIntent(getCaptured); + + // A missing encryption key means the whole system is down: the error + // propagates (no refund, no terminal record) so the provider's + // redelivery retries once the system is healthy again. + await expect( + paidReturn("cs_staged_encryption", intent, 1000), + ).rejects.toThrow("Could not encrypt staged attendee"); + await expectStage("cs_staged_encryption", "pending", 0); + } finally { + checkout.restore(); + } + }); +}); diff --git a/test/lib/server-payment-staging-refund-rail.test.ts b/test/lib/server-payment-staging-refund-rail.test.ts new file mode 100644 index 0000000000..97670916f8 --- /dev/null +++ b/test/lib/server-payment-staging-refund-rail.test.ts @@ -0,0 +1,87 @@ +// jscpd:ignore-start +import { expect } from "@std/expect"; +import { afterEach, it as test } from "@std/testing/bdd"; +import { stub } from "@std/testing/mock"; +import { deleteAttendee } from "#shared/db/attendees/delete.ts"; +import { getDb } from "#shared/db/client.ts"; +import { resetStripeClient, stripeApi } from "#shared/stripe.ts"; +import { stubCheckout } from "#test-utils/checkout.ts"; +import { submitTicketForm } from "#test-utils/csrf.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; +import { bookAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; +import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { setupErrorSpy } from "#test-utils/error-spy.ts"; +import { setupStripe } from "#test-utils/settings.ts"; +import { + expectRefundRoundTripLegs, + expectStage, + paidReturn, + requireIntent, + stubFailedRefund, +} from "./server-payment-staging-helpers.ts"; + +// jscpd:ignore-end + +describeWithEnv("paid checkout staging — refund rail", { db: true }, () => { + setupErrorSpy(); + afterEach(() => resetStripeClient()); + + test("never activates after a completed refund was first reported as failed", async () => { + await setupStripe(); + const listing = await createTestListing({ + maxAttendees: 1, + unitPrice: 1000, + }); + const { checkout, getCaptured } = stubCheckout("cs_staged_false_negative"); + + try { + await submitTicketForm(listing.slug, { + [`quantity_${listing.id}`]: "1", + email: "false-negative@example.com", + name: "False Negative Buyer", + }); + const filler = await bookAttendee(listing, { + email: "false-negative-filler@example.com", + name: "False Negative Filler", + }); + if (!filler.success) throw new Error("Expected filler booking"); + const intent = requireIntent(getCaptured); + + // Stripe completed the refund remotely, but both the create response and + // the immediate status read failed locally. + { + using _refund = stubFailedRefund(); + const first = await paidReturn( + "cs_staged_false_negative", + intent, + 1000, + ); + expect(first.status).toBe(200); + } + await expectStage("cs_staged_false_negative", "refunding", 0); + expect( + (await getDb().execute("SELECT kind FROM transfers")).rows, + ).toEqual([]); + + // Capacity returns before redelivery. The retry must finish the refund, + // never activate a ticket for money already returned. + await deleteAttendee(filler.attendees[0]!.id); + using _refund = stub(stripeApi, "refundPayment", () => + Promise.resolve(null), + ); + using refunded = stub(stripeApi, "retrievePaymentIntent", () => + Promise.resolve({ + id: "pi_cs_staged_false_negative", + latest_charge: { refunded: true }, + }), + ); + const retry = await paidReturn("cs_staged_false_negative", intent, 1000); + expect(await retry.text()).toContain("automatically refunded"); + await expectStage("cs_staged_false_negative", "failed", 0); + await expectRefundRoundTripLegs(); + expect(refunded.calls.length).toBe(1); + } finally { + checkout.restore(); + } + }); +}); diff --git a/test/lib/server-payment-staging-rollback.test.ts b/test/lib/server-payment-staging-rollback.test.ts new file mode 100644 index 0000000000..9012716ab7 --- /dev/null +++ b/test/lib/server-payment-staging-rollback.test.ts @@ -0,0 +1,236 @@ +/* jscpd:ignore-start */ +import { expect } from "@std/expect"; +import { afterEach, it as test } from "@std/testing/bdd"; +import { stub } from "@std/testing/mock"; +import { getCheckoutStageOrNull } from "#shared/db/checkout-stages.ts"; +import { getDb } from "#shared/db/client.ts"; +import { + markSessionFailed, + reserveSession, +} from "#shared/db/processed-payments.ts"; +import type { CheckoutIntent } from "#shared/payments.ts"; +import { flushPendingWork } from "#shared/pending-work.ts"; +import { resetStripeClient, stripeApi } from "#shared/stripe.ts"; +import { stubCheckout } from "#test-utils/checkout.ts"; +import { submitTicketForm } from "#test-utils/csrf.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; +import { bookAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; +import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { finalizeTestPaymentSession } from "#test-utils/db-helpers/processed-payments.ts"; +import { postListingSale } from "#test-utils/ledger.ts"; +import { setupStripe } from "#test-utils/settings.ts"; +import { + expectStage, + paidReturn, + requireIntent, +} from "./server-payment-staging-helpers.ts"; + +/* jscpd:ignore-end */ + +type RollbackCheckout = { + intent: CheckoutIntent; + listing: Awaited>; + stagedAttendeeId: number; +}; + +const storedStageTokens = async (sessionId: string): Promise => { + const row = await getDb().execute( + "SELECT ticket_tokens FROM checkout_stages WHERE payment_session_id = ?", + [sessionId], + ); + const tokens = row.rows[0]?.ticket_tokens; + if (typeof tokens !== "string") { + throw new Error(`Expected stored checkout stage tokens for ${sessionId}`); + } + return tokens; +}; + +const startRollbackCheckout = async ( + sessionId: string, +): Promise => { + const listing = await createTestListing({ unitPrice: 1000 }); + const { checkout, getCaptured } = stubCheckout(sessionId); + try { + await submitTicketForm(listing.slug, { + [`quantity_${listing.id}`]: "1", + email: `${sessionId}@example.com`, + name: "Staged buyer", + }); + await flushPendingWork(); + } finally { + checkout.restore(); + } + const stage = await getCheckoutStageOrNull(sessionId); + if (!stage) throw new Error(`Expected checkout stage ${sessionId}`); + return { + intent: requireIntent(getCaptured), + listing, + stagedAttendeeId: stage.attendeeId, + }; +}; + +const createRollbackOwner = async ( + sessionId: string, + listing: RollbackCheckout["listing"], +): Promise<{ attendeeId: number; ticketToken: string }> => { + const result = await bookAttendee(listing, { + email: `rollback-${sessionId}@example.com`, + name: "Rollback buyer", + paymentId: `pi_${sessionId}`, + }); + if (!result.success) throw new Error("Expected rollback booking"); + const attendeeId = result.attendees[0]!.id; + await postListingSale({ + attendeeId, + eventId: sessionId, + gross: 1000, + listingId: listing.id, + }); + return { attendeeId, ticketToken: result.attendees[0]!.ticket_token }; +}; + +const reopenHistoricalStage = async (sessionId: string): Promise => { + await getDb().execute( + "UPDATE checkout_stages SET state = 'pending', ticket_tokens = ? WHERE payment_session_id = ?", + [await storedStageTokens(sessionId), sessionId], + ); +}; + +const expectStageTokenScrubbed = async (sessionId: string): Promise => { + const row = await getDb().execute( + "SELECT ticket_tokens FROM checkout_stages WHERE payment_session_id = ?", + [sessionId], + ); + expect(row.rows[0]!.ticket_tokens).toBe(""); +}; + +describeWithEnv( + "paid checkout staging — rollback recovery", + { db: true }, + () => { + afterEach(() => resetStripeClient()); + + test("returns the rollback attendee and fails a different historical stage", async () => { + await setupStripe(); + const sessionId = "cs_rollback_processed_owner"; + const checkout = await startRollbackCheckout(sessionId); + const owner = await createRollbackOwner(sessionId, checkout.listing); + const stagedTokens = await storedStageTokens(sessionId); + await getDb().execute( + "UPDATE checkout_stages SET state = 'failed', ticket_tokens = '' WHERE payment_session_id = ?", + [sessionId], + ); + await reserveSession(sessionId); + await finalizeTestPaymentSession( + sessionId, + owner.attendeeId, + [owner.ticketToken], + "", + ); + await getDb().execute( + "UPDATE checkout_stages SET state = 'pending', ticket_tokens = ? WHERE payment_session_id = ?", + [stagedTokens, sessionId], + ); + const beforeTransfers = await getDb().execute( + "SELECT COUNT(*) AS count FROM transfers", + ); + using refund = stub(stripeApi, "refundPayment", () => + Promise.reject(new Error("must not refund historical payment")), + ); + + const response = await paidReturn(sessionId, checkout.intent, 1000); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toContain(owner.ticketToken); + await expectStage(sessionId, "failed", 0); + await expectStageTokenScrubbed(sessionId); + const bookings = await getDb().execute( + "SELECT attendee_id, quantity FROM listing_attendees ORDER BY attendee_id", + ); + expect( + bookings.rows.map((row) => [row.attendee_id, row.quantity]), + ).toEqual([ + [checkout.stagedAttendeeId, 0], + [owner.attendeeId, 1], + ]); + const afterTransfers = await getDb().execute( + "SELECT COUNT(*) AS count FROM transfers", + ); + expect(afterTransfers.rows[0]!.count).toBe( + beforeTransfers.rows[0]!.count, + ); + expect(refund.calls).toHaveLength(0); + }); + + test("heals a ledger owner before finalizing its recreated payment row", async () => { + await setupStripe(); + const sessionId = "cs_rollback_ledger_owner"; + const checkout = await startRollbackCheckout(sessionId); + const owner = await createRollbackOwner(sessionId, checkout.listing); + + const response = await paidReturn(sessionId, checkout.intent, 1000); + + expect(response.status).toBe(200); + await expectStage(sessionId, "failed", 0); + await expectStageTokenScrubbed(sessionId); + const payment = await getDb().execute( + "SELECT attendee_id FROM processed_payments WHERE payment_session_id = ?", + [sessionId], + ); + expect(payment.rows[0]!.attendee_id).toBe(owner.attendeeId); + }); + + test("books a historical open stage when the finalized owner is the same attendee", async () => { + await setupStripe(); + const sessionId = "cs_rollback_same_owner"; + const checkout = await startRollbackCheckout(sessionId); + await getDb().execute( + "UPDATE checkout_stages SET state = 'failed' WHERE payment_session_id = ?", + [sessionId], + ); + await reserveSession(sessionId); + await finalizeTestPaymentSession( + sessionId, + checkout.stagedAttendeeId, + ["same-owner-token"], + "", + ); + await reopenHistoricalStage(sessionId); + + const response = await paidReturn(sessionId, checkout.intent, 1000); + + expect(response.status).toBe(302); + await expectStage(sessionId, "booked", 0); + await expectStageTokenScrubbed(sessionId); + }); + + test("resolves an open stage when replaying a terminal payment failure", async () => { + await setupStripe(); + const sessionId = "cs_rollback_terminal_failure"; + const checkout = await startRollbackCheckout(sessionId); + await reserveSession(sessionId); + await markSessionFailed(sessionId, { + error: "Payment failed", + status: 410, + }); + + const response = await paidReturn(sessionId, checkout.intent, 1000); + + expect(response.status).toBe(410); + await expectStage(sessionId, "failed", 0); + await expectStageTokenScrubbed(sessionId); + }); + + test("keeps a fresh unresolved reservation pending", async () => { + await setupStripe(); + const sessionId = "cs_rollback_fresh_reservation"; + const checkout = await startRollbackCheckout(sessionId); + await reserveSession(sessionId); + + const response = await paidReturn(sessionId, checkout.intent, 1000); + + expect(response.status).toBe(409); + await expectStage(sessionId, "pending", 0); + }); + }, +); diff --git a/test/lib/server-payment-staging.test.ts b/test/lib/server-payment-staging.test.ts new file mode 100644 index 0000000000..8eaf1cd42b --- /dev/null +++ b/test/lib/server-payment-staging.test.ts @@ -0,0 +1,424 @@ +// jscpd:ignore-start +import { expect } from "@std/expect"; +import { afterEach, it as test } from "@std/testing/bdd"; +import { handleRequest } from "#routes"; +import { priceCheckout } from "#shared/checkout-pricing.ts"; +import { decryptAttendeeFields } from "#shared/db/attendees/pii.ts"; +import { getAttendeeRaw } from "#shared/db/attendees/queries.ts"; +import { getCheckoutStageOrNull } from "#shared/db/checkout-stages.ts"; +import { getDb } from "#shared/db/client.ts"; +import { modifiersTable } from "#shared/db/modifiers.ts"; +import { assembleCheckoutMetadata } from "#shared/payment-helpers.ts"; +import { resetStripeClient } from "#shared/stripe.ts"; +import { expectRedirect } from "#test-utils/assertions.ts"; +import { stubCheckout } from "#test-utils/checkout.ts"; +import { getTestPrivateKey } from "#test-utils/crypto.ts"; +import { submitTicketForm } from "#test-utils/csrf.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; +import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { setupErrorSpy } from "#test-utils/error-spy.ts"; +import { mockRequest } from "#test-utils/mocks.ts"; +import { setupStripe } from "#test-utils/settings.ts"; +import { stubRetrieveCheckoutSession } from "#test-utils/webhooks.ts"; +import { + expectNoStampedPayment, + expectRefundRoundTripLegs, + expectStage, + fillListing, + paidReturn, + requireIntent, + stubFailedRefund, + stubSuccessfulRefund, +} from "./server-payment-staging-helpers.ts"; + +// jscpd:ignore-end + +describeWithEnv("paid checkout staging", { db: true }, () => { + // Spy console.error for the block's other tests; the dangling-stage test now + // throws (asserted directly) rather than logging, so its return is unused. + setupErrorSpy(); + afterEach(() => resetStripeClient()); + + test("throws when a stage points at a deleted attendee", async () => { + await setupStripe(); + const listing = await createTestListing({ unitPrice: 1000 }); + const { checkout, getCaptured } = stubCheckout("cs_staged_dangling"); + + try { + await submitTicketForm(listing.slug, { + [`quantity_${listing.id}`]: "1", + email: "dangling@example.com", + name: "Dangling Buyer", + }); + // Bypass the delete cascade (which removes the stage) to manufacture the + // impossible dangling stage: it now points at a dead id. + await getDb().execute( + `DELETE FROM attendees WHERE id = + (SELECT attendee_id FROM checkout_stages WHERE payment_session_id = ?)`, + ["cs_staged_dangling"], + ); + const intent = requireIntent(getCaptured); + + // A pending stage must never outlive its attendee, so the paid session + // surfaces the impossible state loudly instead of booking fresh around a + // missed cascade. + await expect( + paidReturn("cs_staged_dangling", intent, 1000), + ).rejects.toThrow("must never outlive its attendee"); + } finally { + checkout.restore(); + } + }); + + test("claims the staged order only after Stripe confirms payment", async () => { + await setupStripe(); + const listing = await createTestListing({ + maxAttendees: 2, + maxQuantity: 2, + unitPrice: 1000, + }); + const { checkout, getCaptured } = stubCheckout("cs_staged_order"); + + try { + const response = await submitTicketForm(listing.slug, { + [`quantity_${listing.id}`]: "2", + email: "stage@example.com", + name: "Stage Buyer", + }); + expectRedirect(response, "https://stripe.example/checkout"); + + const rows = await getDb().execute({ + args: ["cs_staged_order"], + sql: `SELECT stage.attendee_id, booking.quantity + FROM checkout_stages AS stage + JOIN listing_attendees AS booking + ON booking.attendee_id = stage.attendee_id + WHERE stage.payment_session_id = ?`, + }); + expect(rows.rows.length).toBe(1); + expect(Number(rows.rows[0]!.quantity)).toBe(0); + const stagedAttendeeId = Number(rows.rows[0]!.attendee_id); + + const intent = requireIntent(getCaptured); + const retrieve = stubRetrieveCheckoutSession({ + amountTotal: 2000, + metadata: await assembleCheckoutMetadata("stripe", intent, 2000), + paymentIntent: "pi_staged_order", + sessionId: "cs_staged_order", + }); + try { + const paid = await handleRequest( + mockRequest("/payment/success?session_id=cs_staged_order"), + ); + expectRedirect(paid, /^\/payment\/success\?tokens=.+$/); + + const activated = await getDb().execute({ + args: ["cs_staged_order"], + sql: `SELECT stage.attendee_id, stage.state, booking.quantity + FROM checkout_stages AS stage + JOIN listing_attendees AS booking + ON booking.attendee_id = stage.attendee_id + WHERE stage.payment_session_id = ?`, + }); + expect( + activated.rows.map((row) => [ + row.attendee_id, + row.state, + row.quantity, + ]), + ).toEqual([[stagedAttendeeId, "booked", 2]]); + } finally { + retrieve.restore(); + } + } finally { + checkout.restore(); + } + }); + + test("keeps the same quantity-zero order when capacity is gone after payment", async () => { + await setupStripe(); + const listing = await createTestListing({ + maxAttendees: 1, + unitPrice: 1000, + }); + const { checkout, getCaptured } = stubCheckout("cs_staged_full"); + const refund = stubSuccessfulRefund("re_staged_full"); + + try { + await submitTicketForm(listing.slug, { + [`quantity_${listing.id}`]: "1", + email: "late@example.com", + name: "Late Buyer", + }); + const staged = await getDb().execute( + "SELECT attendee_id FROM checkout_stages WHERE payment_session_id = ?", + ["cs_staged_full"], + ); + const stagedAttendeeId = Number(staged.rows[0]!.attendee_id); + await fillListing(listing); + + const intent = requireIntent(getCaptured); + const retrieve = stubRetrieveCheckoutSession({ + amountTotal: 1000, + metadata: await assembleCheckoutMetadata("stripe", intent, 1000), + paymentIntent: "pi_staged_full", + sessionId: "cs_staged_full", + }); + try { + const response = await handleRequest( + mockRequest("/payment/success?session_id=cs_staged_full"), + ); + expect(await response.text()).toContain("automatically refunded"); + + const retained = await getDb().execute({ + args: ["cs_staged_full"], + sql: `SELECT stage.attendee_id, stage.state, booking.quantity + FROM checkout_stages AS stage + JOIN listing_attendees AS booking + ON booking.attendee_id = stage.attendee_id + WHERE stage.payment_session_id = ?`, + }); + expect( + retained.rows.map((row) => [ + row.attendee_id, + row.state, + row.quantity, + ]), + ).toEqual([[stagedAttendeeId, "failed", 0]]); + const failedAttendee = await getAttendeeRaw(stagedAttendeeId); + if (!failedAttendee) throw new Error("Expected failed staged attendee"); + expect( + ( + await decryptAttendeeFields( + failedAttendee, + await getTestPrivateKey(), + true, + ) + ).payment_id, + ).toBe("pi_staged_full"); + expect(refund.calls.length).toBe(1); + } finally { + retrieve.restore(); + } + } finally { + checkout.restore(); + refund.restore(); + } + }); + + test("leaves a staged capacity-loss order refunding when the refund fails, then resolves on retry", async () => { + await setupStripe(); + const listing = await createTestListing({ + maxAttendees: 1, + unitPrice: 1000, + }); + const { checkout, getCaptured } = stubCheckout("cs_staged_retry"); + + try { + await submitTicketForm(listing.slug, { + [`quantity_${listing.id}`]: "1", + email: "retry@example.com", + name: "Retry Buyer", + }); + // Consume the only spot, so activation can't honour the paid order. + await fillListing(listing); + const intent = requireIntent(getCaptured); + + // First delivery: the provider refund genuinely fails (and the payment + // does not read as already refunded). The staged record must NOT go + // terminal — posting the payment leg or resolving the stage now would make + // the ledger preflight answer "already handled" and the refund would never + // retry. + { + using _refund = stubFailedRefund(); + const response = await paidReturn("cs_staged_retry", intent, 1000); + expect(await response.text()).toContain("saved your details"); + } + // The stage stays refunding with NO ledger legs and no stamped reference, so + // the released reservation's next delivery re-runs the whole refund path. + await expectStage("cs_staged_retry", "refunding", 0); + expect( + (await getDb().execute("SELECT kind FROM transfers")).rows, + ).toEqual([]); + await expectNoStampedPayment("cs_staged_retry"); + + // Second delivery: the provider refund now settles. The same reused staged + // record resolves terminally with the full money round-trip. + { + using _refund = stubSuccessfulRefund("re_staged_retry"); + const response = await paidReturn("cs_staged_retry", intent, 1000); + expect(await response.text()).toContain("automatically refunded"); + expect(_refund.calls.length).toBe(1); + } + await expectStage("cs_staged_retry", "failed", 0); + await expectRefundRoundTripLegs(); + } finally { + checkout.restore(); + } + }); + + test("keeps the staged order at zero when an extra sells out", async () => { + await setupStripe(); + const listing = await createTestListing({ + maxAttendees: 2, + unitPrice: 1000, + }); + const modifier = await modifiersTable.insert({ + calcKind: "fixed", + calcValue: 1, + direction: "charge", + name: "Limited extra", + stock: 1, + }); + const { checkout, getCaptured } = stubCheckout("cs_staged_extra"); + const refund = stubSuccessfulRefund("re_staged_extra"); + + try { + await submitTicketForm(listing.slug, { + [`quantity_${listing.id}`]: "1", + email: "extra@example.com", + name: "Extra Buyer", + }); + await getDb().execute({ + args: [modifier.id], + sql: `INSERT INTO modifier_usages + (modifier_id, attendee_id, quantity, amount_applied, created) + VALUES (?, 999999, 1, 100, '2026-07-12T00:00:00.000Z')`, + }); + const intent = requireIntent(getCaptured); + const total = priceCheckout(intent).total; + const retrieve = stubRetrieveCheckoutSession({ + amountTotal: total, + metadata: await assembleCheckoutMetadata("stripe", intent, total), + paymentIntent: "pi_staged_extra", + sessionId: "cs_staged_extra", + }); + try { + const response = await handleRequest( + mockRequest("/payment/success?session_id=cs_staged_extra"), + ); + expect(await response.text()).toContain("automatically refunded"); + await expectStage("cs_staged_extra", "failed", 0); + expect(refund.calls.length).toBe(1); + } finally { + retrieve.restore(); + } + } finally { + checkout.restore(); + refund.restore(); + } + }); + + test("holds the money for the operator when a staged row was activated outside payment", async () => { + await setupStripe(); + const listing = await createTestListing({ unitPrice: 1000 }); + const { checkout, getCaptured } = stubCheckout("cs_staged_active"); + const refund = stubSuccessfulRefund("re_staged_active"); + + try { + await submitTicketForm(listing.slug, { + [`quantity_${listing.id}`]: "1", + email: "active@example.com", + name: "Active Buyer", + }); + await getDb().execute( + `UPDATE listing_attendees SET quantity = 1 + WHERE attendee_id = (SELECT attendee_id FROM checkout_stages + WHERE payment_session_id = ?)`, + ["cs_staged_active"], + ); + const intent = requireIntent(getCaptured); + + // The rows may be a live booking, so the money must NOT move on its own: + // no refund, no second activation — a terminal "the organiser must check + // this" outcome the operator resolves. + const response = await paidReturn("cs_staged_active", intent, 1000); + expect(response.status).toBe(200); + expect(await response.text()).toContain( + "needs to be confirmed by the organiser", + ); + // The outcome is recorded and the stage resolves, so the operator can + // now edit the record to act on the note (a pending stage blocks edits). + await expectStage("cs_staged_active", "failed", 1); + expect(refund.calls.length).toBe(0); + + // The staged attendee's record explains the conflict to the operator. + const notes = await getDb().execute( + `SELECT note.id FROM system_notes AS note + JOIN checkout_stages AS stage ON stage.attendee_id = note.attendee_id + WHERE stage.payment_session_id = ?`, + ["cs_staged_active"], + ); + expect(notes.rows.length).toBe(1); + // The money we hold is on the record: the ledger shows the payment we + // received — and nothing else, no sale and no refund — and the payment + // reference is stamped so the operator can refund it in-app. + const legs = await getDb().execute("SELECT kind FROM transfers"); + expect(legs.rows.map((row) => row.kind)).toEqual(["payment"]); + const stage = await getCheckoutStageOrNull("cs_staged_active"); + if (!stage) throw new Error("Expected a stage for cs_staged_active"); + const held = await getAttendeeRaw(stage.attendeeId); + if (!held) throw new Error("Expected the conflicted attendee to exist"); + expect( + (await decryptAttendeeFields(held, await getTestPrivateKey(), true)) + .payment_id, + ).toBe("pi_cs_staged_active"); + + // A replay answers the same without touching the money or the rows. + const replay = await paidReturn("cs_staged_active", intent, 1000); + expect(replay.status).toBe(200); + expect(await replay.text()).toContain( + "needs to be confirmed by the organiser", + ); + await expectStage("cs_staged_active", "failed", 1); + expect(refund.calls.length).toBe(0); + // The replay posted nothing: still exactly the one payment leg. + const legsAfterReplay = await getDb().execute( + "SELECT kind FROM transfers", + ); + expect(legsAfterReplay.rows.map((row) => row.kind)).toEqual(["payment"]); + } finally { + checkout.restore(); + refund.restore(); + } + }); + + test("refunds and keeps the order when the staged booking paths changed", async () => { + await setupStripe(); + const listing = await createTestListing({ unitPrice: 1000 }); + const { checkout, getCaptured } = stubCheckout("cs_staged_changed"); + const refund = stubSuccessfulRefund("re_staged_changed"); + + try { + await submitTicketForm(listing.slug, { + [`quantity_${listing.id}`]: "1", + email: "changed@example.com", + name: "Changed Buyer", + }); + await getDb().execute( + `UPDATE listing_attendees SET package_group_id = 999999 + WHERE attendee_id = (SELECT attendee_id FROM checkout_stages + WHERE payment_session_id = ?)`, + ["cs_staged_changed"], + ); + const intent = requireIntent(getCaptured); + + // The staged rows are still all quantity 0, so nothing can be live: + // refunding is safe, and the customer must never be crash-looped. + const response = await paidReturn("cs_staged_changed", intent, 1000); + expect(response.status).toBe(200); + expect(await response.text()).toContain("automatically refunded"); + await expectStage("cs_staged_changed", "failed", 0); + expect(refund.calls.length).toBe(1); + + // A replay returns the recorded outcome without refunding again. + const replay = await paidReturn("cs_staged_changed", intent, 1000); + expect(replay.status).toBe(200); + expect(await replay.text()).toContain("automatically refunded"); + expect(refund.calls.length).toBe(1); + } finally { + checkout.restore(); + refund.restore(); + } + }); +}); diff --git a/test/lib/server-payments-success-replay.test.ts b/test/lib/server-payments-success-replay.test.ts index c8281c73bf..66917aa946 100644 --- a/test/lib/server-payments-success-replay.test.ts +++ b/test/lib/server-payments-success-replay.test.ts @@ -163,8 +163,7 @@ describeWithEnv("server (payment flow: ticket success)", { db: true }, () => { ); try { - // First request redirects with tokens (no stored tokens — a hidden package - // carries no explicit thank-you URL, so storeTokens is false). + // First request redirects with tokens, then clears their persisted copy. const response1 = await handleRequest( mockRequest("/payment/success?session_id=cs_hidden_replay"), ); diff --git a/test/lib/server-payments/cancel.test.ts b/test/lib/server-payments/cancel.test.ts index 8b02d49678..de954edf72 100644 --- a/test/lib/server-payments/cancel.test.ts +++ b/test/lib/server-payments/cancel.test.ts @@ -1,9 +1,12 @@ // jscpd:ignore-start import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; +import { stub } from "@std/testing/mock"; import { handleRequest } from "#routes"; +import { getCheckoutStageOrNull } from "#shared/db/checkout-stages.ts"; import { getDb } from "#shared/db/client.ts"; -import { resetStripeClient } from "#shared/stripe.ts"; +import { reserveSession } from "#shared/db/processed-payments.ts"; +import { resetStripeClient, stripeApi } from "#shared/stripe.ts"; import { assertPublicHtml, expectHtmlResponse, @@ -12,6 +15,7 @@ import { johnCheckoutSession } from "#test-utils/checkout.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { createTestGroup } from "#test-utils/db-helpers/groups.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { stageTestCheckout } from "#test-utils/db-helpers/processed-payments.ts"; import { singleItem } from "#test-utils/factories.ts"; import { mockRequest, withMocks } from "#test-utils/mocks.ts"; import { makeParent } from "#test-utils/parents.ts"; @@ -26,6 +30,36 @@ import { stubRetrieveCheckoutSession } from "#test-utils/webhooks.ts"; const cancelSession = (sessionId: string, items: string) => johnCheckoutSession(sessionId, { items, paid: false }); +/** Run the cancel request for a session and expect the page to answer 200. */ +const requestCancel = async ( + listingId: number, + sessionId: string, +): Promise => { + await withMocks( + () => cancelSession(sessionId, singleItem(listingId, 1, 1000)), + async () => { + const response = await handleRequest( + mockRequest(`/payment/cancel?session_id=${sessionId}`), + ); + expect(response.status).toBe(200); + }, + resetStripeClient, + ); +}; + +/** A staged checkout ready to cancel: its listing and its staged row. */ +const stageForCancel = async (sessionId: string) => { + const listing = await createTestListing({ + maxAttendees: 50, + unitPrice: 1000, + }); + return { listing, stage: await stageTestCheckout(sessionId, listing) }; +}; + +/** Record calls to the provider's session-expire hook. */ +const stubExpire = () => + stub(stripeApi, "expireCheckoutSession", () => Promise.resolve()); + describeWithEnv("server (payment flow)", { db: true, triggers: true }, () => { describe("GET /payment/cancel", () => { test("returns error for missing session_id", async () => { @@ -114,6 +148,46 @@ describeWithEnv("server (payment flow)", { db: true, triggers: true }, () => { ); }); + test("removes the unpaid staged attendee", async () => { + await setupStripe(); + const sessionId = "cs_staged_cancel"; + const { listing, stage } = await stageForCancel(sessionId); + + await requestCancel(listing.id, sessionId); + + expect(await getCheckoutStageOrNull(sessionId)).toBeNull(); + const attendee = await getDb().execute({ + args: [stage.attendeeId], + sql: "SELECT id FROM attendees WHERE id = ?", + }); + expect(attendee.rows).toEqual([]); + }); + + test("closes the provider session after discarding the stage", async () => { + await setupStripe(); + const sessionId = "cs_staged_cancel_expire"; + const { listing } = await stageForCancel(sessionId); + using expire = stubExpire(); + + await requestCancel(listing.id, sessionId); + // The staged details are gone, so the hosted page is closed too — an + // old tab can no longer pay for a checkout we no longer hold. + expect(expire.calls.map((call) => call.args)).toEqual([[sessionId]]); + }); + + test("leaves the provider session open when nothing was discarded", async () => { + await setupStripe(); + const sessionId = "cs_staged_cancel_claimed"; + const { listing } = await stageForCancel(sessionId); + // A payment request has claimed the session: the discard is refused, so + // the hosted page must stay open for that payment to finish. + await reserveSession(sessionId); + using expire = stubExpire(); + + await requestCancel(listing.id, sessionId); + expect(expire.calls.length).toBe(0); + }); + test("a cancelled checkout for a now-non-standalone child suppresses the retry link", async () => { await setupStripe(); diff --git a/test/lib/server-payments/confirm.test.ts b/test/lib/server-payments/confirm.test.ts index d2d6d18ef0..514bbe2a61 100644 --- a/test/lib/server-payments/confirm.test.ts +++ b/test/lib/server-payments/confirm.test.ts @@ -3,6 +3,7 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; import { handleRequest } from "#routes"; import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; +import { reserveSession } from "#shared/db/processed-payments.ts"; import { resetStripeClient } from "#shared/stripe.ts"; import { expectHtmlResponse, @@ -13,6 +14,7 @@ import { johnCheckoutSession } from "#test-utils/checkout.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { bookAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { finalizeTestPaymentSession as finalizeSession } from "#test-utils/db-helpers/processed-payments.ts"; import { signMeta, singleItem } from "#test-utils/factories.ts"; import { mockRequest, withMocks } from "#test-utils/mocks.ts"; import { makeParent } from "#test-utils/parents.ts"; @@ -116,6 +118,39 @@ describeWithEnv("server (payment flow)", { db: true, triggers: true }, () => { ); }); + test("returns the ticket redirect once then renders a replay directly", async () => { + await setupStripe(); + const listing = await createTestListing({ + maxAttendees: 50, + unitPrice: 1000, + }); + + await withMocks( + () => + johnSession( + "cs_single_token_clear", + singleItem(listing.id, 1, 1000), + 1000, + ), + async () => { + const first = await handleRequest( + mockRequest("/payment/success?session_id=cs_single_token_clear"), + ); + expectRedirect(first, /^\/payment\/success\?tokens=.+$/); + const ticketPage = await followRedirect(first, handleRequest); + expect(await ticketPage.text()).toContain("View your ticket"); + + const replay = await handleRequest( + mockRequest("/payment/success?session_id=cs_single_token_clear"), + ); + expect(await expectHtmlResponse(replay, 200)).not.toContain( + "View your ticket", + ); + }, + resetStripeClient, + ); + }); + /** A parent with a configured thank-you URL folding one required paid child, * whose signed checkout metadata carries that explicit thank_you_url and two * listing ids. Returns the `withMocks` stub factory for the given provider @@ -229,12 +264,20 @@ describeWithEnv("server (payment flow)", { db: true, triggers: true }, () => { unitPrice: 1000, }); - // Create attendee as if payment was already processed (using atomic to simulate production flow) - await bookAttendee(listing, { + const booked = await bookAttendee(listing, { email: "john@example.com", name: "John", paymentId: "pi_test_123", }); + if (!booked.success) throw new Error("Expected replay fixture booking"); + const attendee = booked.attendees[0]!; + await reserveSession("cs_test_paid"); + await finalizeSession( + "cs_test_paid", + attendee.id, + [attendee.ticket_token], + "pi_test_123", + ); await withMocks( () => @@ -244,12 +287,7 @@ describeWithEnv("server (payment flow)", { db: true, triggers: true }, () => { mockRequest("/payment/success?session_id=cs_test_paid"), ); - // Capacity check will now fail since we already have the attendee - // This is expected - in the new flow, replaying creates a duplicate attempt - // which fails the capacity check if listing is near full - // For idempotent behavior, we'd need to check payment_intent uniqueness - // Response is either a 302 redirect (with tokens) or 200 (direct render for replay) - expect([200, 302]).toContain(response.status); + expectRedirect(response, /^\/payment\/success\?tokens=.+$/); }, resetStripeClient, ); diff --git a/test/lib/server-payments/replay.test.ts b/test/lib/server-payments/replay.test.ts index c3f4cf5eff..8e785f5cfb 100644 --- a/test/lib/server-payments/replay.test.ts +++ b/test/lib/server-payments/replay.test.ts @@ -268,10 +268,11 @@ describeWithEnv("server (payment flow)", { db: true, triggers: true }, () => { expect(await isSessionProcessed("cs_refund_failed")).toBeNull(); // The next retry re-attempts the refund (proof the lock was released). - await handleRequest( + const retry = await handleRequest( mockRequest("/payment/success?session_id=cs_refund_failed"), ); expect(mockRefund.calls.length).toBe(2); + expect(await retry.text()).toContain("contact support"); }, resetStripeClient, ); diff --git a/test/lib/server-privacy.test.ts b/test/lib/server-privacy.test.ts index 9d62ae5ec8..2ea767af5b 100644 --- a/test/lib/server-privacy.test.ts +++ b/test/lib/server-privacy.test.ts @@ -15,11 +15,7 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; import { parseFlashValue } from "#shared/cookies.ts"; import { queryOne } from "#shared/db/client.ts"; -import { - hashEmail, - hashPhone, - recordVisit, -} from "#shared/db/contact-preferences.ts"; +import { hashEmail, hashPhone } from "#shared/db/contact-preferences.ts"; import { settings } from "#shared/db/settings.ts"; import { nowMs } from "#shared/now.ts"; import { @@ -34,6 +30,7 @@ import { attendeeExists as attendeeExistsHelper, insertOrphanAttendee, } from "#test-utils/db-helpers/attendees.ts"; +import { seedContactVisits } from "#test-utils/db-helpers/contacts.ts"; import { awaitTestRequest } from "#test-utils/mocks.ts"; import { adminFormPost, @@ -191,7 +188,7 @@ describeWithEnv("server (admin privacy)", { db: true }, () => { test("erases a contact record found by email", async () => { const hash = await hashEmail("erase-me@example.com"); - await recordVisit(hash); + await seedContactVisits(hash); const { response } = await adminFormPost("/admin/privacy/erase", { contact_type: "email", @@ -207,7 +204,7 @@ describeWithEnv("server (admin privacy)", { db: true }, () => { test("erases a contact record found by phone", async () => { const hash = await hashPhone("07700 900222"); - await recordVisit(hash); + await seedContactVisits(hash); const { response } = await adminFormPost("/admin/privacy/erase", { contact_type: "sms", diff --git a/test/lib/server-public/ticket-additional-coverage.test.ts b/test/lib/server-public/ticket-additional-coverage.test.ts index 4270442a0f..ce08ffd997 100644 --- a/test/lib/server-public/ticket-additional-coverage.test.ts +++ b/test/lib/server-public/ticket-additional-coverage.test.ts @@ -3,7 +3,7 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; import { stub } from "@std/testing/mock"; import { handleRequest } from "#routes"; -import { hashPhone, recordVisit } from "#shared/db/contact-preferences.ts"; +import { hashPhone } from "#shared/db/contact-preferences.ts"; import { modifiersTable } from "#shared/db/modifiers.ts"; import { settings } from "#shared/db/settings.ts"; import { resetStripeClient } from "#shared/stripe.ts"; @@ -21,6 +21,7 @@ import { } from "#test-utils/csrf.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { bookAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; +import { seedContactVisits } from "#test-utils/db-helpers/contacts.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; import { mockFormRequest, mockRequest } from "#test-utils/mocks.ts"; import { setupStripe } from "#test-utils/settings.ts"; @@ -32,7 +33,7 @@ import { setupStripe } from "#test-utils/settings.ts"; * setup behind both returning-customer Square tests below (one records the * visit without an email configured, the other with). */ const setupReturningCustomerFeeListing = async () => { - await recordVisit(await hashPhone("555-1234")); + await seedContactVisits(await hashPhone("555-1234")); const listing = await createTestListing({ fields: "phone", maxAttendees: 50, diff --git a/test/lib/server-qr-book.test.ts b/test/lib/server-qr-book.test.ts index c737ce5128..c9c01bce95 100644 --- a/test/lib/server-qr-book.test.ts +++ b/test/lib/server-qr-book.test.ts @@ -12,6 +12,7 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; import { stub } from "@std/testing/mock"; import { FakeTime } from "@std/testing/time"; +import { t } from "#i18n"; import { handleRequest } from "#routes"; import { toMinorUnits } from "#shared/currency.ts"; import { addDays } from "#shared/dates.ts"; @@ -28,7 +29,10 @@ import { todayInTz } from "#shared/timezone.ts"; import { stubCheckout } from "#test-utils/checkout.ts"; import { hasInputWithValue, submitTicketForm } from "#test-utils/csrf.ts"; import { describeWithEnv } from "#test-utils/db.ts"; -import { getAttendeesRaw } from "#test-utils/db-helpers/attendees.ts"; +import { + bookTestAttendee, + getAttendeesRaw, +} from "#test-utils/db-helpers/attendees.ts"; import { createDailyTestListing, createTestListing, @@ -378,6 +382,23 @@ describeWithEnv("qr-book scan handler", { db: true }, () => { expect(await response.text()).toContain(refusal); }); + test("returns the availability refusal when a direct QR listing has sold out", async () => { + const listing = await createTestListing({ + fields: "", + maxAttendees: 1, + unitPrice: 500, + }); + await bookTestAttendee([listing.id]); + + await scanWithStripe(listing, async ({ response, stripe }) => { + expect(response.status).toBe(400); + expect(await response.text()).toContain( + t("public.checkout_unavailable"), + ); + expect(stripe.calls()).toBe(0); + }); + }); + test("falls through to the form when the listing requires email", async () => { const listing = await createTestListing({ fields: "email", diff --git a/test/lib/server-refunds-balance-payments.test.ts b/test/lib/server-refunds-balance-payments.test.ts index 1223860459..cf900b5a90 100644 --- a/test/lib/server-refunds-balance-payments.test.ts +++ b/test/lib/server-refunds-balance-payments.test.ts @@ -4,10 +4,7 @@ import { attendeeStatuses } from "#shared/db/attendee-statuses.ts"; import { createAttendeeAtomic } from "#shared/db/attendees/api.ts"; import { settleAttendeeBalance } from "#shared/db/attendees/balance.ts"; import { balanceFinalizeStatement } from "#shared/db/payment-finalize.ts"; -import { - finalizeSession as finalizePaymentSession, - reserveSession, -} from "#shared/db/processed-payments.ts"; +import { reserveSession } from "#shared/db/processed-payments.ts"; import type { Attendee, Listing } from "#shared/types.ts"; import { expectFlashRedirect, @@ -16,6 +13,7 @@ import { import { settle } from "#test-utils/balance.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { finalizeTestPaymentSession as finalizePaymentSession } from "#test-utils/db-helpers/processed-payments.ts"; import { setupErrorSpy } from "#test-utils/error-spy.ts"; import { postListingSale } from "#test-utils/ledger.ts"; import { diff --git a/test/lib/server-reservation/helpers.ts b/test/lib/server-reservation/helpers.ts index a06703a4b9..12001d9023 100644 --- a/test/lib/server-reservation/helpers.ts +++ b/test/lib/server-reservation/helpers.ts @@ -100,12 +100,9 @@ export const setupSoldOutModifierRace = async ( stock: 1, }); // Simulate the race: a *different*, concurrent order consumes the modifier's - // last unit between pricing and our commit. Its usage lands on that other - // order's attendee (a sentinel id, never ours), so it stands after our order - // rolls back — exactly as a real competing booking's stock would. Firing on our - // attendee INSERT (just before the booking insert in the batch) is only the - // hook for "stock gone by commit time"; it must not attach to NEW.id, or it - // would look like our own consumption. + // last unit between pricing and our commit. It lands on a sentinel attendee, + // never ours. The test trigger itself rolls back with this transaction; its + // purpose is only to make the in-transaction stock guard observe the race. await getDb().execute( `CREATE TRIGGER test_consume_modifier_before_order AFTER INSERT ON attendees diff --git a/test/lib/server-reservation/public-default-modifiers.test.ts b/test/lib/server-reservation/public-default-modifiers.test.ts index a959a6b044..e7130cdd50 100644 --- a/test/lib/server-reservation/public-default-modifiers.test.ts +++ b/test/lib/server-reservation/public-default-modifiers.test.ts @@ -1,7 +1,7 @@ import { expect } from "@std/expect"; import { afterEach, it as test } from "@std/testing/bdd"; -import { hashEmail, recordVisit } from "#shared/db/contact-preferences.ts"; -import { recordBooking } from "#shared/db/contact-tokens.ts"; +import { hashEmail } from "#shared/db/contact-preferences.ts"; +import { recordBookingActivity } from "#shared/db/contact-tokens.ts"; import { modifierUsedQuantities } from "#shared/db/modifier-usage.ts"; import { modifiersTable } from "#shared/db/modifiers.ts"; import { resetStripeClient } from "#shared/stripe.ts"; @@ -122,12 +122,10 @@ describeWithEnv( "An extra you selected sold out while you were checking out. Please try again.", false, ); - // The racing order's single consumption stands (it really got the stock); - // our rejected order added none of its own — the batch's stock-guarded - // booking insert never landed, so its gated usage insert never fired. - expect(await modifierUsedQuantities([modifier.id])).toEqual( - new Map([[modifier.id, 1]]), - ); + // The test trigger runs inside this order's transaction, so its simulated + // stock consume rolls back with the refused order. A real competing order + // commits separately and would remain; either way this order adds no use. + expect(await modifierUsedQuantities([modifier.id])).toEqual(new Map()); expect(await attendeeCount()).toBe(0); // A sold-out free order leaves no phantom contact history: the batch never // reaches the success path that records a visit + public booking, so there @@ -135,7 +133,7 @@ describeWithEnv( expect(await totalContactActivity()).toEqual({ bookings: 0, visits: 0 }); }); - test("reverses a phone contact's counters when a free order's stock rolls back", async () => { + test("leaves a phone contact unchanged when a free order rolls back", async () => { await setupStripe(); // A phone-only listing identifies the buyer by phone hash, exercising the // SMS-reachable contact path rather than email. @@ -153,8 +151,8 @@ describeWithEnv( false, ); expect(await attendeeCount()).toBe(0); - // The phone identity must be compensated just like email: a sold-out free - // order leaves no visit or booking on the texted contact. + // The atomic write never reaches contact activity, so there is nothing to + // reverse and the texted contact remains unchanged. expect(await totalContactActivity()).toEqual({ bookings: 0, visits: 0 }); }); @@ -162,8 +160,7 @@ describeWithEnv( await setupStripe(); // This contact already has one genuine public booking + visit on record. const emailHash = await hashEmail("buyer@example.com"); - await recordVisit(emailHash); - await recordBooking(emailHash, "public", "tok-earlier"); + await recordBookingActivity(emailHash, "public", "tok-earlier"); const { listing } = await setupSoldOutModifierRace(); const response = await submitBuyerOrder(listing); @@ -174,8 +171,8 @@ describeWithEnv( false, ); expect(await attendeeCount()).toBe(0); - // The rollback decrements by exactly one (clamped at zero), so the earlier - // booking survives — a rejected order must never wipe real history. + // The rejected order never writes contact activity, so the earlier + // booking survives unchanged. expect(await totalContactActivity()).toEqual({ bookings: 1, visits: 1 }); }); }, diff --git a/test/lib/server-scheduled.test.ts b/test/lib/server-scheduled.test.ts index ab12bfaf79..3fac9cf17c 100644 --- a/test/lib/server-scheduled.test.ts +++ b/test/lib/server-scheduled.test.ts @@ -131,9 +131,16 @@ describeWithEnv( }, ); -describeWithEnv("server (scheduled tasks): not a builder", { db: true }, () => { - test("POST does not poke built sites when not a builder", async () => { - await insertBuiltSite("Client", "client.b-cdn.net"); - await expectPostScheduledPokesNothing(); - }); -}); +// CAN_BUILD_SITES is pinned OFF, not left ambient: this file shares an isolate +// with builder tests that switch the flag on, and a leaked "true" would make +// this instance poke (the exact thing being tested against). +describeWithEnv( + "server (scheduled tasks): not a builder", + { db: true, env: { CAN_BUILD_SITES: undefined } }, + () => { + test("POST does not poke built sites when not a builder", async () => { + await insertBuiltSite("Client", "client.b-cdn.net"); + await expectPostScheduledPokesNothing(); + }); + }, +); diff --git a/test/lib/server-settings/stripe.test.ts b/test/lib/server-settings/stripe.test.ts index 517a9ce2ce..2e654360b4 100644 --- a/test/lib/server-settings/stripe.test.ts +++ b/test/lib/server-settings/stripe.test.ts @@ -5,6 +5,7 @@ import { stub } from "@std/testing/mock"; import { handleRequest } from "#routes"; import { settings } from "#shared/db/settings.ts"; import { type StripeConnectionTestResult, stripeApi } from "#shared/stripe.ts"; +import { STRIPE_WEBHOOK_EVENTS_VERSION } from "#shared/stripe-webhook-events.ts"; import { getAllActivityLog } from "#test-utils/activity-log.ts"; import { assertJson, @@ -153,6 +154,9 @@ describeAdminSettings(() => { // persisted, not just acknowledged in the flash. expect(settings.stripe.webhookSecret).toBe("whsec_test_secret"); expect(settings.stripe.webhookEndpointId).toBe("we_test_123"); + expect(settings.stripe.webhookEventsVersion).toBe( + STRIPE_WEBHOOK_EVENTS_VERSION, + ); }, ); }); diff --git a/test/lib/server-update.test.ts b/test/lib/server-update.test.ts index 6807dc0a10..4f72ebacfd 100644 --- a/test/lib/server-update.test.ts +++ b/test/lib/server-update.test.ts @@ -2,7 +2,7 @@ import { expect } from "@std/expect"; import { afterEach, describe, it as test } from "@std/testing/bdd"; import { stub } from "@std/testing/mock"; import { bunnyCdnApi } from "#shared/bunny-cdn.ts"; -import { backupKey, backupTimestamp } from "#shared/db/backup.ts"; +import { backupKey, backupTimestamp } from "#shared/db/backup-storage.ts"; import { ALL_SETTINGS_KEYS, settings } from "#shared/db/settings.ts"; import { uploadRaw } from "#shared/storage.ts"; import { setBuildTimestampForTest } from "#shared/update.ts"; diff --git a/test/lib/server-webhooks/already-processed-rollback.test.ts b/test/lib/server-webhooks/already-processed-rollback.test.ts index 30437ce7c1..80a13e0d64 100644 --- a/test/lib/server-webhooks/already-processed-rollback.test.ts +++ b/test/lib/server-webhooks/already-processed-rollback.test.ts @@ -138,10 +138,12 @@ describeWithEnv( if (!attResult.success) throw new Error("Failed to create attendee"); // Reserve and finalize the session with the real attendee - const { - reserveSession: reserveSessionFn, - finalizeSession: finalizeSessionFn, - } = await import("#shared/db/processed-payments.ts"); + const { reserveSession: reserveSessionFn } = await import( + "#shared/db/processed-payments.ts" + ); + const { finalizeTestPaymentSession: finalizeSessionFn } = await import( + "#test-utils/db-helpers/processed-payments.ts" + ); await reserveSessionFn("cs_del_listing_wh"); await finalizeSessionFn( "cs_del_listing_wh", diff --git a/test/lib/server-webhooks/concurrent-processing.test.ts b/test/lib/server-webhooks/concurrent-processing.test.ts index ab27ee90a3..c6c5e3466e 100644 --- a/test/lib/server-webhooks/concurrent-processing.test.ts +++ b/test/lib/server-webhooks/concurrent-processing.test.ts @@ -64,6 +64,9 @@ describeWithEnv("server webhooks > concurrent processing", { db: true }, () => { mockWebhookRequest({}, { "stripe-signature": "sig_valid" }), ); expect(response.status).toBe(409); + expect(await response.text()).toContain( + "Payment is being processed. Please wait a moment and refresh.", + ); } finally { mockVerify.restore(); } @@ -174,10 +177,12 @@ describeWithEnv("server webhooks > concurrent processing", { db: true }, () => { if (!result.success) throw new Error("Failed to create test attendee"); const attendee = result.attendees[0]!; - const { - reserveSession: reserveSessionFn, - finalizeSession: finalizeSessionFn, - } = await import("#shared/db/processed-payments.ts"); + const { reserveSession: reserveSessionFn } = await import( + "#shared/db/processed-payments.ts" + ); + const { finalizeTestPaymentSession: finalizeSessionFn } = await import( + "#test-utils/db-helpers/processed-payments.ts" + ); await reserveSessionFn("cs_multi_already_done"); await finalizeSessionFn( "cs_multi_already_done", diff --git a/test/lib/server-webhooks/custom-questions-multi.test.ts b/test/lib/server-webhooks/custom-questions-multi.test.ts index c1a4fc0d61..8d620f2da0 100644 --- a/test/lib/server-webhooks/custom-questions-multi.test.ts +++ b/test/lib/server-webhooks/custom-questions-multi.test.ts @@ -205,7 +205,7 @@ describeWithEnv( 1000, ), paymentIntent: "pi_multi_q", - sessionId: "cs_multi_q", + sessionId: "cs_multi_q_stub", }), ); @@ -290,7 +290,7 @@ describeWithEnv( 1000, ), paymentIntent: "pi_text_q", - sessionId: "cs_text_q", + sessionId: "cs_text_q_stub", }), ); diff --git a/test/lib/server-webhooks/modifier-refunds.test.ts b/test/lib/server-webhooks/modifier-refunds.test.ts index 248d001010..3e3e4d6aab 100644 --- a/test/lib/server-webhooks/modifier-refunds.test.ts +++ b/test/lib/server-webhooks/modifier-refunds.test.ts @@ -134,9 +134,8 @@ describeWithEnv( "cs_modifier_soldout", mockRefund, ); - // The greedy create's visit + booking are reversed, and the quantity-0 - // placeholder records neither, so the refunded order leaves no phantom - // history on the buyer's contact. + // The failed atomic create and quantity-0 placeholder record no activity, + // so the refunded order leaves no phantom history on the buyer's contact. const { getContactRecord, getVisits, hashEmail } = await import( "#shared/db/contact-preferences.ts" ); diff --git a/test/lib/server-webhooks/multi-ticket-refunds.test.ts b/test/lib/server-webhooks/multi-ticket-refunds.test.ts index 92d23cbfc9..5ce04eda15 100644 --- a/test/lib/server-webhooks/multi-ticket-refunds.test.ts +++ b/test/lib/server-webhooks/multi-ticket-refunds.test.ts @@ -6,15 +6,13 @@ 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 { setupStripe, stubWebhookVerify } from "#test-utils/settings.ts"; +import { setupStripe } from "#test-utils/settings.ts"; import { checkoutSessionEvent, expectKeptAsQuantityZeroAndRefunded, expectMergedMultiListingAttendee, expectSessionFailed, expectWebhookKeptAndRefunded, - postWebhookAndAssert, - stubRefundPayment, } from "#test-utils/webhooks.ts"; // jscpd:ignore-end @@ -48,7 +46,7 @@ describeWithEnv( quantity: 1, }); - const mockVerify = await stubWebhookVerify( + const { mockRefund } = await expectWebhookKeptAndRefunded( checkoutSessionEvent({ amountTotal: 800, eventId: "evt_multi_cap", @@ -66,22 +64,7 @@ describeWithEnv( paymentIntent: "pi_multi_cap", sessionId: "cs_multi_cap", }), - ); - - const mockRefund = stubRefundPayment("re_multi_cap"); - - await postWebhookAndAssert( - () => { - mockVerify.restore(); - mockRefund.restore(); - }, - 200, - (json) => { - expect(json.processed).toBe(false); - // The capacity reason now lives in the note; the customer sees the - // generic saved-details message. - expect(json.error).toContain("saved your details"); - }, + "re_multi_cap", ); // Signed by us → the order is kept as a quantity-0 placeholder (one diff --git a/test/lib/server-webhooks/refund-helper-functions.test.ts b/test/lib/server-webhooks/refund-helper-functions.test.ts index 47adc41a2b..f47e4a57bf 100644 --- a/test/lib/server-webhooks/refund-helper-functions.test.ts +++ b/test/lib/server-webhooks/refund-helper-functions.test.ts @@ -60,6 +60,7 @@ describeWithEnv( ); // Should show "contact support" since refund failed (no payment reference) expect(html).toContain("contact support"); + expect(html).not.toContain("automatically refunded"); } finally { mockRetrieve.restore(); } diff --git a/test/lib/server-webhooks/registration-closed.test.ts b/test/lib/server-webhooks/registration-closed.test.ts index b491634798..5eccf5cb71 100644 --- a/test/lib/server-webhooks/registration-closed.test.ts +++ b/test/lib/server-webhooks/registration-closed.test.ts @@ -129,8 +129,7 @@ describeWithEnv( const { getAttendeesRaw } = await import( "#shared/db/attendees/queries.ts" ); - const attendees1 = await getAttendeesRaw(listing1.id); - expect(attendees1.length).toBe(0); + expect(await getAttendeesRaw(listing1.id)).toEqual([]); }); test("multi-ticket webhook passes date to daily listings only", async () => { diff --git a/test/lib/stripe-mock/ports.test.ts b/test/lib/stripe-mock/ports.test.ts index 25aff8f7bc..6a1556e385 100644 --- a/test/lib/stripe-mock/ports.test.ts +++ b/test/lib/stripe-mock/ports.test.ts @@ -140,10 +140,15 @@ describe("startStripeMock ports", () => { test("surfaces stderr when the spawned mock fails", async () => { await withTempStripeMockPaths(async (paths) => { await writeFailingMock(paths, "wrong binary"); + // A generous poll budget: the wait loop returns the moment the child's + // exit is observed, so this normally takes one script run (~60ms). A + // tight budget flakes on a loaded runner — the harness can give up and + // kill the script before its echo runs, and the stderr this test + // exists to see is then never written. await expectStripeMockFails( { - delayMs: 10, - maxAttempts: 10, + delayMs: 20, + maxAttempts: 100, paths, }, `${STRIPE_MOCK_FAILED_TO_START}: wrong binary`, diff --git a/test/lib/stripe/core.test.ts b/test/lib/stripe/core.test.ts index a811621dbd..01a8a93535 100644 --- a/test/lib/stripe/core.test.ts +++ b/test/lib/stripe/core.test.ts @@ -134,6 +134,37 @@ describeStripe("stripe", () => { expect(retrievedSession?.id).toBe(createdSession?.id); }); + test("creates the session with the configured expiry deadline", async () => { + await settings.update.stripe.secretKey("sk_test_mock"); + const { CHECKOUT_SESSION_EXPIRY_MINUTES } = await import( + "#shared/limits.ts" + ); + const client = await stripeClient(); + const listing = testListing({ unit_price: 1000 }); + const before = Math.floor(Date.now() / 1000); + await withMocks( + () => stub(client.checkout.sessions, "create"), + async (createSpy) => { + await createCheckoutSession( + checkoutIntent({ items: [lineFor(listing)] }), + "http://localhost:3000", + ); + const after = Math.floor(Date.now() / 1000); + const params = createSpy.calls[0]!.args[0] as CreatedSessionParams & { + expires_at?: number; + }; + // The hosted page gets a hard deadline: after it, no money can + // arrive for a checkout whose staged details we may have discarded. + expect(params.expires_at).toBeGreaterThanOrEqual( + before + CHECKOUT_SESSION_EXPIRY_MINUTES * 60, + ); + expect(params.expires_at).toBeLessThanOrEqual( + after + CHECKOUT_SESSION_EXPIRY_MINUTES * 60, + ); + }, + ); + }); + test("creates checkout session with intent metadata", async () => { await settings.update.stripe.secretKey("sk_test_mock"); diff --git a/test/lib/stripe/webhook-reconcile.test.ts b/test/lib/stripe/webhook-reconcile.test.ts new file mode 100644 index 0000000000..801d94d7f1 --- /dev/null +++ b/test/lib/stripe/webhook-reconcile.test.ts @@ -0,0 +1,380 @@ +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { stub } from "@std/testing/mock"; +import { handleRequest } from "#routes"; +import { CONFIG_KEYS, settings } from "#shared/db/settings.ts"; +import { + REQUIRED_STRIPE_WEBHOOK_EVENTS, + STRIPE_WEBHOOK_EVENTS_VERSION, +} from "#shared/stripe-webhook-events.ts"; +import { reconcileStoredStripeWebhook } from "#shared/stripe-webhook-reconcile.ts"; +import { + installUrlHandler, + mockRequestWithHost, + urlFromFetchInput, + withFetchMock, +} from "#test-utils/mocks.ts"; +import { describeStripe } from "./harness.ts"; + +const WEBHOOK_URL = "https://tickets.example/payment/webhook"; + +type StripeRequest = { + method: string; + path: string; + params: URLSearchParams; +}; + +const stripeRequest = ( + input: string | URL | Request, + init?: RequestInit, +): StripeRequest => ({ + method: init?.method ?? "GET", + params: new URLSearchParams(String(init?.body ?? "")), + path: new URL(urlFromFetchInput(input)).pathname, +}); + +const enabledEvents = (request: StripeRequest): string[] => + [...request.params.entries()] + .filter(([key]) => key.startsWith("enabled_events[")) + .map(([, value]) => value) + .toSorted(); + +const endpointResponse = ( + id: string, + enabledEvents: readonly string[], + secret?: string, + url = WEBHOOK_URL, +): Response => + Response.json({ + enabled_events: enabledEvents, + id, + object: "webhook_endpoint", + ...(secret ? { secret } : {}), + status: "enabled", + url, + }); + +const stripeErrorResponse = ( + status: number, + type: string, + code?: string, +): Response => + Response.json( + { + error: { + ...(code ? { code } : {}), + message: "Stripe request failed", + type, + }, + }, + { status }, + ); + +const seedStoredStripe = async (): Promise => { + await settings.update.stripe.secretKey("sk_test_upgrade"); + await settings.update.stripe.webhookConfig({ + endpointId: "we_existing", + secret: "whsec_existing", + }); + await settings.update.paymentProvider("stripe"); + await settings.setRaw(CONFIG_KEYS.SETUP_COMPLETE, "true"); + settings.setup.clearCache(); +}; + +const recordStripeRequests = async ( + respond: (request: StripeRequest) => Response, + run: () => Promise, +): Promise => { + const requests: StripeRequest[] = []; + await withFetchMock(async (originalFetch) => { + installUrlHandler(originalFetch, (url, init) => { + if (!url.includes("/v1/webhook_endpoints")) return null; + const request = stripeRequest(url, init); + requests.push(request); + return Promise.resolve(respond(request)); + }); + await run(); + }); + return requests; +}; + +const reconcileRequests = ( + respond: (request: StripeRequest) => Response, +): Promise => + recordStripeRequests(respond, () => + reconcileStoredStripeWebhook(WEBHOOK_URL), + ); + +const reconcileRequestsWithoutErrorLog = async ( + respond: (request: StripeRequest) => Response, +): Promise => { + const errorLog = stub(console, "error"); + try { + return await reconcileRequests(respond); + } finally { + errorLog.restore(); + } +}; + +const expectRequests = ( + requests: StripeRequest[], + expected: Array<{ method: string; path: string }>, +): void => { + expect(requests.map(({ method, path }) => ({ method, path }))).toEqual( + expected, + ); +}; + +const expectStoredWebhook = ( + endpointId: string, + secret: string, + eventsVersion: string, +): void => { + expect(settings.stripe.webhookEndpointId).toBe(endpointId); + expect(settings.stripe.webhookSecret).toBe(secret); + expect(settings.stripe.webhookEventsVersion).toBe(eventsVersion); +}; + +const withoutStoredWebhookSecret = async ( + run: () => Promise, +): Promise => { + await seedStoredStripe(); + settings.setForTest({ stripe_webhook_secret: "" }); + try { + return await run(); + } finally { + settings.clearTestOverride("stripe_webhook_secret"); + } +}; + +const replacementResponses = + (deletion: Response) => + (request: StripeRequest): Response => + request.method === "DELETE" + ? deletion + : endpointResponse( + "we_replacement", + REQUIRED_STRIPE_WEBHOOK_EVENTS, + "whsec_replacement", + ); + +const expectReplacement = (requests: StripeRequest[]): void => { + expectRequests(requests, [ + { method: "DELETE", path: "/v1/webhook_endpoints/we_existing" }, + { method: "POST", path: "/v1/webhook_endpoints" }, + ]); + expectStoredWebhook( + "we_replacement", + "whsec_replacement", + STRIPE_WEBHOOK_EVENTS_VERSION, + ); +}; + +describeStripe("stripe webhook reconciliation", () => { + test("does nothing when Stripe is not the payment provider", async () => { + const requests = await reconcileRequests(() => + stripeErrorResponse(500, "unexpected_request"), + ); + + expectRequests(requests, []); + expect(settings.stripe.webhookEventsVersion).toBe(""); + }); + + test("does nothing when this Stripe event version is already current", async () => { + await seedStoredStripe(); + await settings.update.stripe.webhookEventsVersion( + STRIPE_WEBHOOK_EVENTS_VERSION, + ); + + const requests = await reconcileRequests(() => + stripeErrorResponse(500, "unexpected_request"), + ); + + expectRequests(requests, []); + expectStoredWebhook( + "we_existing", + "whsec_existing", + STRIPE_WEBHOOK_EVENTS_VERSION, + ); + }); + + test("marks a Stripe install without a key as current", async () => { + await settings.update.paymentProvider("stripe"); + + const requests = await reconcileRequests(() => + stripeErrorResponse(500, "unexpected_request"), + ); + + expectRequests(requests, []); + expect(settings.stripe.webhookEventsVersion).toBe( + STRIPE_WEBHOOK_EVENTS_VERSION, + ); + }); + + test("the first request adds checkout expiry without replacing the stored signing secret", async () => { + await seedStoredStripe(); + const requests = await recordStripeRequests( + (request) => { + if (request.method === "GET") { + return endpointResponse("we_existing", [ + REQUIRED_STRIPE_WEBHOOK_EVENTS[0], + ]); + } + return endpointResponse("we_existing", REQUIRED_STRIPE_WEBHOOK_EVENTS); + }, + async () => { + const [first, second] = await Promise.all([ + handleRequest(mockRequestWithHost("/read-only", "tickets.example")), + handleRequest(mockRequestWithHost("/read-only", "tickets.example")), + ]); + const third = await handleRequest( + mockRequestWithHost("/read-only", "tickets.example"), + ); + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(third.status).toBe(200); + }, + ); + + expectRequests(requests, [ + { method: "GET", path: "/v1/webhook_endpoints/we_existing" }, + { method: "POST", path: "/v1/webhook_endpoints/we_existing" }, + ]); + expect(enabledEvents(requests[1]!)).toEqual( + [...REQUIRED_STRIPE_WEBHOOK_EVENTS].toSorted(), + ); + expectStoredWebhook( + "we_existing", + "whsec_existing", + STRIPE_WEBHOOK_EVENTS_VERSION, + ); + }); + + test("replaces a missing endpoint and stores the new signing secret", async () => { + await seedStoredStripe(); + const requests = await reconcileRequests((request) => { + if (request.method === "GET") { + return stripeErrorResponse( + 404, + "invalid_request_error", + "resource_missing", + ); + } + return endpointResponse( + "we_replacement", + REQUIRED_STRIPE_WEBHOOK_EVENTS, + "whsec_replacement", + ); + }); + + expectRequests(requests, [ + { method: "GET", path: "/v1/webhook_endpoints/we_existing" }, + { method: "POST", path: "/v1/webhook_endpoints" }, + ]); + expectStoredWebhook( + "we_replacement", + "whsec_replacement", + STRIPE_WEBHOOK_EVENTS_VERSION, + ); + }); + + test("keeps the existing endpoint when Stripe retrieval fails transiently", async () => { + await seedStoredStripe(); + const requests = await reconcileRequestsWithoutErrorLog(() => + stripeErrorResponse(500, "api_error"), + ); + + expectRequests(requests, [ + { method: "GET", path: "/v1/webhook_endpoints/we_existing" }, + ]); + expectStoredWebhook("we_existing", "whsec_existing", ""); + }); + + test("treats an all-events endpoint as current without updating it", async () => { + await seedStoredStripe(); + const requests = await reconcileRequests(() => + endpointResponse("we_existing", ["*"]), + ); + + expectRequests(requests, [ + { method: "GET", path: "/v1/webhook_endpoints/we_existing" }, + ]); + expectStoredWebhook( + "we_existing", + "whsec_existing", + STRIPE_WEBHOOK_EVENTS_VERSION, + ); + }); + + test("keeps all events when correcting an endpoint URL", async () => { + await seedStoredStripe(); + const requests = await reconcileRequests((request) => + request.method === "GET" + ? endpointResponse( + "we_existing", + ["*"], + undefined, + "https://old.example/payment/webhook", + ) + : endpointResponse("we_existing", ["*"]), + ); + + expectRequests(requests, [ + { method: "GET", path: "/v1/webhook_endpoints/we_existing" }, + { method: "POST", path: "/v1/webhook_endpoints/we_existing" }, + ]); + expect(enabledEvents(requests[1]!)).toEqual(["*"]); + expect(requests[1]!.params.get("url")).toBe(WEBHOOK_URL); + }); + + test("replaces an endpoint when its signing secret is missing", async () => { + const requests = await withoutStoredWebhookSecret(() => + reconcileRequests(replacementResponses(Response.json({ deleted: true }))), + ); + + expectReplacement(requests); + }); + + test("replaces an endpoint when deleting its missing predecessor", async () => { + const requests = await withoutStoredWebhookSecret(() => + reconcileRequests( + replacementResponses( + stripeErrorResponse(404, "invalid_request_error", "resource_missing"), + ), + ), + ); + + expectReplacement(requests); + }); + + test("keeps the stored endpoint when deleting it fails transiently", async () => { + const requests = await withoutStoredWebhookSecret(() => + reconcileRequestsWithoutErrorLog(() => + stripeErrorResponse(500, "api_error"), + ), + ); + + expectRequests(requests, [ + { method: "DELETE", path: "/v1/webhook_endpoints/we_existing" }, + ]); + expectStoredWebhook("we_existing", "whsec_existing", ""); + }); + + test("keeps stored verification details when replacement returns no secret", async () => { + await seedStoredStripe(); + const requests = await reconcileRequestsWithoutErrorLog((request) => + request.method === "GET" + ? stripeErrorResponse(404, "invalid_request_error", "resource_missing") + : endpointResponse( + "we_broken_replacement", + REQUIRED_STRIPE_WEBHOOK_EVENTS, + ), + ); + + expectRequests(requests, [ + { method: "GET", path: "/v1/webhook_endpoints/we_existing" }, + { method: "POST", path: "/v1/webhook_endpoints" }, + ]); + expectStoredWebhook("we_existing", "whsec_existing", ""); + }); +}); diff --git a/test/lib/stripe/webhook-setup.test.ts b/test/lib/stripe/webhook-setup.test.ts index 62993a7fb5..2b2bd3e518 100644 --- a/test/lib/stripe/webhook-setup.test.ts +++ b/test/lib/stripe/webhook-setup.test.ts @@ -184,7 +184,7 @@ describeStripe("Stripe webhook setup", () => { expectFailedResultWithNoDeletes(result, calls); }); - test("subscribes only to completed checkouts", async () => { + test("subscribes to completed and expired checkouts", async () => { const webhookUrl = "https://example.com/payment/webhook"; const calls = newWebhookApiCalls(); @@ -192,6 +192,7 @@ describeStripe("Stripe webhook setup", () => { expect([...calls.createdBody!.entries()]).toEqual([ ["enabled_events[0]", "checkout.session.completed"], + ["enabled_events[1]", "checkout.session.expired"], ["url", webhookUrl], ]); }); diff --git a/test/lib/test-utils/factories.test.ts b/test/lib/test-utils/factories.test.ts index edb6ba9287..3132cc5b85 100644 --- a/test/lib/test-utils/factories.test.ts +++ b/test/lib/test-utils/factories.test.ts @@ -32,25 +32,22 @@ describe("test-utils — listing & attendee factories", () => { expect(attendee.id).toBeGreaterThan(0); }); - test("rolls back the partial booking and fails loudly when a listing can't take it", async () => { + test("fails loudly and leaves no booking when a listing can't take the order", async () => { const open = await createTestListing({ maxAttendees: 10 }); const full = await createTestListing({ maxAttendees: 1, name: "Full" }); await bookTestAttendee([full.id], "Filler"); // uses the only spot await expect( bookTestAttendee([open.id, full.id], "Partial"), - ).rejects.toThrow("Failed to book test attendee onto all 2 listing(s)"); + ).rejects.toThrow("Failed to create attendee: capacity_exceeded"); - // The booking that DID land on `open` is rolled back, so no stray - // attendee is left occupying capacity or skewing later assertions. + // The atomic batch leaves no attendee occupying capacity. const { getAttendeesRaw } = await import( "#shared/db/attendees/queries.ts" ); expect((await getAttendeesRaw(open.id)).length).toBe(0); - // Regression: the rollback must also reverse the contact-activity count - // that the greedy create recorded — under the same source — or the - // contact keeps a booking that no longer exists. + // Regression: an order that did not commit records no contact activity. const { getContactCountFields, hashEmail } = await import( "#shared/db/contact-preferences.ts" ); diff --git a/test/lib/webhook-price-signature/helpers.ts b/test/lib/webhook-price-signature/helpers.ts index 9fbdc37392..16e0a15592 100644 --- a/test/lib/webhook-price-signature/helpers.ts +++ b/test/lib/webhook-price-signature/helpers.ts @@ -37,7 +37,9 @@ export const signedMeta = ( items: string; name?: string; email?: string; + phone?: string; modifiers?: string; + answer_ids?: string; }, ): Record => signMeta( diff --git a/test/lib/webhook-price-signature/post-commit.test.ts b/test/lib/webhook-price-signature/post-commit.test.ts new file mode 100644 index 0000000000..569dff06a3 --- /dev/null +++ b/test/lib/webhook-price-signature/post-commit.test.ts @@ -0,0 +1,274 @@ +import { expect } from "@std/expect"; +import { afterEach, it as test } from "@std/testing/bdd"; +import { stub } from "@std/testing/mock"; +import { handleRequest } from "#routes"; +import { validatePaidSession } from "#routes/api/payment-processing/classify.ts"; +import { validateAllItems } from "#routes/api/payment-processing/items.ts"; +import { parseTokens } from "#routes/tickets/token-utils.ts"; +import { attendeesApi } from "#shared/db/attendees/api.ts"; +import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; +import { getDb } from "#shared/db/client.ts"; +import { getContactRecord, hashEmail } from "#shared/db/contact-preferences.ts"; +import { getRecentBookingTokens } from "#shared/db/contact-tokens.ts"; +import { listingsTable } from "#shared/db/listings/records.ts"; +import { modifiersTable } from "#shared/db/modifiers.ts"; +import { + isSessionProcessed, + releaseReservation, +} from "#shared/db/processed-payments.ts"; +import { getAttendeeAnswersBatch } from "#shared/db/questions/attendee-answers/reads.ts"; +import { listingQuestions } from "#shared/db/questions/queries.ts"; +import { answersTable, questionsTable } from "#shared/db/questions/tables.ts"; +import { resetStripeClient } from "#shared/stripe.ts"; +import { getAllActivityLog } from "#test-utils/activity-log.ts"; +import { + assertJson, + expectRedirect, + followRedirect, +} from "#test-utils/assertions.ts"; +import { getTestPrivateKey } from "#test-utils/crypto.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; +import { singleItem } from "#test-utils/factories.ts"; +import { stubRetrieveCheckoutSession } from "#test-utils/webhooks.ts"; +import { + redirectRequest, + runWebhook, + setupWithListing, + signedMeta, + webhookRequest, +} from "./helpers.ts"; + +const STORED_BOOKING_FAILURE = { + error: + "We couldn't complete your booking, so we've saved your details and a member of our team can help you rebook.", + processed: false, + received: true, +}; + +const expectStoredBookingFailure = () => + assertJson(webhookRequest(), 200, (json) => { + expect(json).toEqual(STORED_BOOKING_FAILURE); + }); + +const expectPlaceholders = async (listingIds: number[]): Promise => { + for (const listingId of listingIds) { + expect( + (await getAttendeesRaw(listingId)).map(({ quantity }) => quantity), + ).toEqual([0]); + } +}; + +describeWithEnv( + "webhook signed price oracle - post-commit failures", + { db: true }, + () => { + afterEach(() => { + resetStripeClient(); + }); + + test("recovers the ticket when post-commit processing throws", async () => { + const listing = await setupWithListing(); + const sessionId = "cs_post_commit_failure"; + const question = await questionsTable.insert({ + displayType: "radio", + text: "Recovery answer?", + }); + const answer = await answersTable.insert({ + questionId: question.id, + sortOrder: 1, + text: "Recovered", + }); + await listingQuestions.setIds(listing.id, [question.id]); + const modifier = await modifiersTable.insert({ + calcKind: "fixed", + calcValue: 1, + direction: "discount", + name: "RECOVER", + trigger: "code", + }); + const metadata = signedMeta(900, { + answer_ids: JSON.stringify({ + [String(listing.id)]: [answer.id], + }), + items: singleItem(listing.id, 1, 1000), + modifiers: JSON.stringify([{ i: modifier.id, q: 1 }]), + }); + const createBooking = attendeesApi.createBookingAtomic; + let racingRedirect: Response | null = null; + const failAfterCommit = stub( + attendeesApi, + "createBookingAtomic", + async (...args) => { + const result = await createBooking(...args); + if (result === "sold-out" || !result.success) { + throw new Error("Expected the synthetic booking to commit"); + } + + racingRedirect = await redirectRequest(sessionId); + + Object.defineProperty(result, "attendees", { + get: () => { + throw new Error("synthetic post-commit failure"); + }, + }); + return result; + }, + ); + const retrieve = stubRetrieveCheckoutSession({ + amountTotal: 900, + metadata, + paymentIntent: `pi_${sessionId}`, + sessionId, + }); + + try { + await runWebhook( + { amount_total: 900, id: sessionId, metadata }, + async (refund) => { + await assertJson(webhookRequest(), 200, (json) => { + expect(json.processed).toBe(true); + }); + expect(refund.calls.length).toBe(0); + const attendees = await getAttendeesRaw(listing.id); + expect(attendees.map(({ quantity }) => quantity)).toEqual([1]); + const attendeeId = attendees[0]!.id; + const answers = await getAttendeeAnswersBatch([attendeeId], { + texts: false, + }); + expect(answers.get(attendeeId)).toEqual([answer.id]); + const activity = await getAllActivityLog(); + expect(activity.map(({ message }) => message)).toEqual( + expect.arrayContaining([ + `Attendee registered for '${listing.name}'`, + "Promo code 'RECOVER' used: £1 off", + ]), + ); + + const validation = await validatePaidSession(sessionId); + if (!validation.ok) + throw new Error("Expected a valid paid session"); + const validated = await validateAllItems( + validation.data.session, + validation.data.intent, + ); + if ("success" in validated) { + throw new Error("Expected valid recovery items"); + } + const processed = await isSessionProcessed(sessionId); + expect(processed?.ticket_tokens).toBe(""); + expect(racingRedirect).not.toBeNull(); + const redirect = racingRedirect!; + expectRedirect(redirect, /^\/payment\/success\?tokens=.+$/); + const location = redirect.headers.get("location"); + expect(location).not.toBeNull(); + const ticketTokens = parseTokens( + new URL(location!, "http://localhost").searchParams.get( + "tokens", + )!, + ); + const contactHash = await hashEmail("buyer@example.com"); + const privateKey = await getTestPrivateKey(); + const contactRecord = await getContactRecord( + contactHash, + privateKey, + ); + expect({ + publicBookingCount: contactRecord.publicBookingCount, + visits: contactRecord.visits, + }).toEqual({ publicBookingCount: 1, visits: 1 }); + expect( + await getRecentBookingTokens(contactHash, privateKey, 1), + ).toEqual([{ source: "public", token: ticketTokens[0] }]); + const page = await followRedirect(redirect, handleRequest); + expect(await page.text()).toContain("View your ticket"); + }, + ); + } finally { + retrieve.restore(); + failAfterCommit.restore(); + } + }); + + test("refunds only after an incomplete cart leaves no live booking", async () => { + const first = await setupWithListing(); + const second = await setupWithListing(); + const sessionId = "cs_partial_commit_failure"; + const items = JSON.stringify([ + { e: first.id, p: 1000, q: 1 }, + { e: second.id, p: 1000, q: 1 }, + ]); + const createBooking = attendeesApi.createBookingAtomic; + const failAfterIncompleteCreate = stub( + attendeesApi, + "createBookingAtomic", + async (...args) => { + await listingsTable.update(second.id, { active: false }); + await createBooking(...args); + throw new Error("synthetic partial post-commit failure"); + }, + ); + + try { + await runWebhook( + { + amount_total: 2000, + id: sessionId, + metadata: signedMeta(2000, { items }), + }, + async (refund) => { + await expectStoredBookingFailure(); + expect(refund.calls.length).toBe(1); + await expectPlaceholders([first.id, second.id]); + + const processed = await isSessionProcessed(sessionId); + expect(processed).not.toBeNull(); + expect(processed!.failure_data).not.toBe(""); + await getDb().execute({ + args: ["2000-01-01T00:00:00.000Z", sessionId], + sql: "UPDATE processed_payments SET processed_at = ? WHERE payment_session_id = ?", + }); + + await expectStoredBookingFailure(); + await expectPlaceholders([first.id, second.id]); + expect(refund.calls.length).toBe(1); + }, + ); + } finally { + failAfterIncompleteCreate.restore(); + } + }); + + test("does not refund when an unexpected failure loses its reservation", async () => { + const listing = await setupWithListing(); + const sessionId = "cs_missing_reservation_failure"; + const failWithoutReservation = stub( + attendeesApi, + "createBookingAtomic", + async () => { + await releaseReservation(sessionId); + throw new Error("synthetic ambiguous failure"); + }, + ); + + try { + await runWebhook( + { + id: sessionId, + metadata: signedMeta(1000, { + items: singleItem(listing.id, 1, 1000), + }), + }, + async (refund) => { + await expect(webhookRequest()).rejects.toThrow( + "synthetic ambiguous failure", + ); + expect(refund.calls.length).toBe(0); + expect(await getAttendeesRaw(listing.id)).toEqual([]); + }, + ); + } finally { + failWithoutReservation.restore(); + } + }); + }, +); diff --git a/test/lib/webhook-price-signature/recovery-decision.test.ts b/test/lib/webhook-price-signature/recovery-decision.test.ts new file mode 100644 index 0000000000..a0986d7606 --- /dev/null +++ b/test/lib/webhook-price-signature/recovery-decision.test.ts @@ -0,0 +1,43 @@ +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { decideUnexpectedCreate } from "#routes/api/payment-processing/recovery-decision.ts"; + +test("recovers the attendee finalized for this ticket token", () => { + expect( + decideUnexpectedCreate({ + finalizedAttendeeId: 42, + tokenAttendeeId: 42, + unresolved: false, + }), + ).toEqual({ attendeeId: 42, kind: "recover" }); +}); + +test("refunds an unresolved reservation with no committed attendee", () => { + expect( + decideUnexpectedCreate({ + finalizedAttendeeId: null, + tokenAttendeeId: null, + unresolved: true, + }), + ).toEqual({ kind: "refund" }); +}); + +test("rethrows when another outcome already resolved the reservation", () => { + expect( + decideUnexpectedCreate({ + finalizedAttendeeId: null, + tokenAttendeeId: null, + unresolved: false, + }), + ).toEqual({ kind: "rethrow" }); +}); + +test("rethrows an attendee beside an unresolved reservation", () => { + expect( + decideUnexpectedCreate({ + finalizedAttendeeId: null, + tokenAttendeeId: 42, + unresolved: true, + }), + ).toEqual({ kind: "rethrow" }); +}); diff --git a/test/lib/webhook-price-signature/stored-refund-and-ignore.test.ts b/test/lib/webhook-price-signature/stored-refund-and-ignore.test.ts index c3301755f6..8181e18b8b 100644 --- a/test/lib/webhook-price-signature/stored-refund-and-ignore.test.ts +++ b/test/lib/webhook-price-signature/stored-refund-and-ignore.test.ts @@ -182,6 +182,13 @@ describeWithEnv( async (refund) => { await expectStoredRefund(listing.id); expect(refund.calls.length).toBe(1); + const [attendee] = await getAttendeesRaw(listing.id); + const legs = await transfersByAccount( + attendeeAccount(attendee!.id), + ); + expect(legs.find(({ kind }) => kind === "refund_cash")?.memo).toBe( + "unexpected_error", + ); const record = await isSessionProcessed("cs_crash_store"); expect(record?.attendee_id).toBeNull(); expect(record?.failure_data).not.toBe(""); diff --git a/test/lib/webhook-price-signature/trusted-and-mismatch.test.ts b/test/lib/webhook-price-signature/trusted-and-mismatch.test.ts index 52de69a3b9..562d0647dd 100644 --- a/test/lib/webhook-price-signature/trusted-and-mismatch.test.ts +++ b/test/lib/webhook-price-signature/trusted-and-mismatch.test.ts @@ -7,24 +7,26 @@ 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 { getRefundPaymentReferences } from "#shared/db/payment-references.ts"; import { isSessionProcessed } from "#shared/db/processed-payments.ts"; import { prunePayments } from "#shared/db/prune.ts"; import { resetStripeClient } from "#shared/stripe.ts"; -import { assertJson } from "#test-utils/assertions.ts"; +import { getTestPrivateKey } from "#test-utils/crypto.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; import { signMeta, singleItem, webhookMeta } from "#test-utils/factories.ts"; import { setupStripe } from "#test-utils/settings.ts"; +import { stubRetrieveCheckoutSession } from "#test-utils/webhooks.ts"; import { expectProcessed, expectReplayOutcome, expectStoredRefund, expectStoredRefundRecord, + redirectRequest, runWebhook, setupPackage, setupWithListing, signedMeta, - webhookRequest, } from "./helpers.ts"; const pruneReplayRowWithoutRefundReference = async (sessionId: string) => { @@ -111,9 +113,20 @@ describeWithEnv( const legsAfter = await transfersByAccount(attendeeAccount(original!.id)); expect(legsAfter.length).toBe(legsBefore.length); expect(legsAfter.some((leg) => leg.kind === "refund_cash")).toBe(false); - expect((await isSessionProcessed(session.id))!.attendee_id).toBe( - original!.id, + const processed = (await isSessionProcessed(session.id))!; + expect(processed.attendee_id).toBe(original!.id); + const references = await getRefundPaymentReferences( + [original!], + await getTestPrivateKey(), ); + const replayReference = references + .get(original!.id)! + .find(({ sessionIds }) => sessionIds.includes(session.id)); + expect(replayReference).toEqual({ + providerRefunded: false, + reference: "pi_cs_replay_after_prune", + sessionIds: [session.id], + }); }); test("a pruned replay whose listing price changed is recovered, not refunded", async () => { @@ -155,13 +168,27 @@ describeWithEnv( // acknowledge without refunding or recreating a ghost. await deleteListing(listing.id); - await runWebhook(session, async (refund) => { - await assertJson(webhookRequest(), 200, (json) => { - expect(json.processed).toBe(false); - expect(json.error).toContain("already been processed"); - }); - expect(refund.calls.length).toBe(0); + const retrieve = stubRetrieveCheckoutSession({ + amountTotal: 1000, + metadata: session.metadata, + paymentIntent: `pi_${session.id}`, + sessionId: session.id, }); + try { + await runWebhook(session, async (refund) => { + const first = await redirectRequest(session.id); + expect(first.status).toBe(200); + expect(await first.text()).toContain("already been processed"); + + const replay = await redirectRequest(session.id); + expect(replay.status).toBe(200); + expect(await replay.text()).toContain("already been processed"); + + expect(refund.calls.length).toBe(0); + }); + } finally { + retrieve.restore(); + } // No placeholder ghost was created for the orphaned replay. expect((await getAttendeesRaw(listing.id)).length).toBe(0); }); diff --git a/test/routes/unsubscribe.test.ts b/test/routes/unsubscribe.test.ts index ded97d1ab0..cf1db21e0e 100644 --- a/test/routes/unsubscribe.test.ts +++ b/test/routes/unsubscribe.test.ts @@ -6,7 +6,6 @@ import { getVisits, hashEmail, isHashUnsubscribed, - recordVisit, unsubscribeHash, } from "#shared/db/contact-preferences.ts"; import { settings } from "#shared/db/settings.ts"; @@ -16,6 +15,7 @@ import { followRedirectWithFlash, } from "#test-utils/assertions.ts"; import { describeWithEnv } from "#test-utils/db.ts"; +import { seedContactVisits } from "#test-utils/db-helpers/contacts.ts"; import { mockFormRequest, mockRequest } from "#test-utils/mocks.ts"; const getUnsubscribe = (query = ""): Promise => @@ -131,7 +131,7 @@ describeWithEnv("routes (unsubscribe)", { db: true }, () => { test("forgets the contact row", async () => { const hash = await hashEmail("forgetme@example.com"); - await recordVisit(hash); + await seedContactVisits(hash); const response = await postUnsubscribe({ action: "forget", email: hash, diff --git a/test/shared/accounting/rows.test.ts b/test/shared/accounting/rows.test.ts index 3232664852..0e2c30b19a 100644 --- a/test/shared/accounting/rows.test.ts +++ b/test/shared/accounting/rows.test.ts @@ -3,6 +3,7 @@ import { describe, it as test } from "@std/testing/bdd"; import { bookingLegBatchInsert, fromDb, + insertManyStatement, insertStatement, selectTransfers, } from "#shared/accounting/rows.ts"; @@ -50,6 +51,12 @@ describe("accounting > rows > insertStatement", () => { expect(boundValue(absent, "posted_by")).toBe("system"); expect(boundValue(absent, "reverses_id")).toBe(null); }); + + test("refuses to build an insert without transfer legs", () => { + expect(() => insertManyStatement([], recordedAt)).toThrow( + "Cannot build a transfer insert with no rows", + ); + }); }); describe("accounting > rows > bookingLegBatchInsert", () => { diff --git a/test/shared/columns/attendee-columns.test.ts b/test/shared/columns/attendee-columns.test.ts index 43d8e2a70b..85a30f3860 100644 --- a/test/shared/columns/attendee-columns.test.ts +++ b/test/shared/columns/attendee-columns.test.ts @@ -39,7 +39,7 @@ const METADATA: Record = { email: { description: "Attendee email address", label: "Email" }, listings: { description: - "The row's listings in display order, each linked to its detail page", + "The row's listings in display order, with live listings linked", isHtml: true, label: "Listings", }, diff --git a/test/shared/db/attendees/activate-large-cart.test.ts b/test/shared/db/attendees/activate-large-cart.test.ts new file mode 100644 index 0000000000..700c4b1267 --- /dev/null +++ b/test/shared/db/attendees/activate-large-cart.test.ts @@ -0,0 +1,115 @@ +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { + attendeeAccount, + revenueAccount, + WORLD, +} from "#shared/accounting/accounts.ts"; +import { KIND } from "#shared/accounting/kinds.ts"; +import type { CanonicalBooking } from "#shared/booking-lines.ts"; +import { activateStagedBooking } from "#shared/db/attendees/activate.ts"; +import type { FinalizedBookingBatchPlan } from "#shared/db/attendees/create-batch.ts"; +import { stageCheckout } from "#shared/db/checkout-stages.ts"; +import { execute } from "#shared/db/client.ts"; +import { reserveSession } from "#shared/db/processed-payments.ts"; +import { runWithQueryLogContext } from "#shared/db/query-log.ts"; +import type { TransferInput } from "#shared/ledger/types.ts"; +import { checkoutIntent, checkoutItem } from "#test-utils/checkout.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; +import { createTestListing } from "#test-utils/db-helpers/listings.ts"; + +const CART_SIZE = 12; + +describeWithEnv("db > staged large-cart activation", { db: true }, () => { + test("activates a normal large cart without an input-sized transaction", async () => { + const sessionId = "cs_activate_large_cart"; + const listings = []; + for (let index = 0; index < CART_SIZE; index += 1) { + listings.push( + await createTestListing({ + maxAttendees: 10, + name: `Large cart listing ${index}`, + unitPrice: 1000, + }), + ); + } + const intent = checkoutIntent({ + items: listings.map((listing) => + checkoutItem({ + listingId: listing.id, + name: listing.name, + slug: listing.slug, + }), + ), + }); + const stage = await stageCheckout(sessionId, "stripe", intent); + await reserveSession(sessionId); + const bookings: CanonicalBooking[] = listings.map((listing) => ({ + date: null, + durationDays: 1, + listingId: listing.id, + pricePaid: 1000, + quantity: 1, + })); + const occurredAt = "2026-07-14T12:00:00.000Z"; + const eventGroup = "large-cart-event"; + const sales: TransferInput[] = listings.map((listing, index) => ({ + amount: 1000, + destination: revenueAccount(listing.id), + eventGroup, + kind: KIND.sale, + occurredAt, + reference: `large-cart-sale-${index}`, + source: attendeeAccount(stage.attendeeId), + })); + const plan: FinalizedBookingBatchPlan = { + finalize: { paymentReference: "pi_large_cart", sessionId }, + legs: [ + ...sales, + { + amount: CART_SIZE * 1000, + destination: attendeeAccount(stage.attendeeId), + eventGroup, + kind: KIND.payment, + occurredAt, + reference: "large-cart-payment", + source: WORLD, + }, + ], + usages: [], + }; + + const activated = await runWithQueryLogContext(() => + activateStagedBooking( + sessionId, + stage.attendeeId, + stage.ticketToken, + { + address: intent.address, + bookings, + email: intent.email, + name: intent.name, + paymentId: "pi_large_cart", + phone: intent.phone, + special_instructions: intent.special_instructions, + }, + plan, + ), + ); + + expect(activated).toEqual({ success: true }); + const result = await execute( + `SELECT COUNT(*) AS booking_count, SUM(quantity) AS quantity + FROM listing_attendees WHERE attendee_id = ?`, + [stage.attendeeId], + ); + expect(result.rows.map((row) => [row.booking_count, row.quantity])).toEqual( + [[CART_SIZE, CART_SIZE]], + ); + const legs = await execute( + "SELECT COUNT(*) AS leg_count FROM transfers WHERE event_group = ?", + [eventGroup], + ); + expect(legs.rows[0]!.leg_count).toBe(CART_SIZE + 1); + }); +}); diff --git a/test/shared/db/attendees/activate.test.ts b/test/shared/db/attendees/activate.test.ts new file mode 100644 index 0000000000..341d5ff06e --- /dev/null +++ b/test/shared/db/attendees/activate.test.ts @@ -0,0 +1,483 @@ +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { attendeeAccount, WORLD } from "#shared/accounting/accounts.ts"; +import { postTransfers } from "#shared/accounting/store.ts"; +import type { CanonicalBooking } from "#shared/booking-lines.ts"; +import { activateStagedBooking } from "#shared/db/attendees/activate.ts"; +import { refusalReason } from "#shared/db/attendees/activation-refusal.ts"; +import type { FinalizedBookingBatchPlan } from "#shared/db/attendees/create-batch.ts"; +import { decryptAttendeeFields } from "#shared/db/attendees/pii.ts"; +import { getAttendeeRaw } from "#shared/db/attendees/queries.ts"; +import { stageCheckout } from "#shared/db/checkout-stages.ts"; +import { execute } from "#shared/db/client.ts"; +import { listingChildren } from "#shared/db/listing-parents.ts"; +import { modifierUsedQuantities } from "#shared/db/modifier-usage.ts"; +import { modifiersTable } from "#shared/db/modifiers.ts"; +import { reserveSession } from "#shared/db/processed-payments.ts"; +import { settings } from "#shared/db/settings.ts"; +import { CONFIG_KEYS } from "#shared/settings/keys.ts"; +import { checkoutIntent, checkoutItem } from "#test-utils/checkout.ts"; +import { getTestPrivateKey } from "#test-utils/crypto.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; +import { bookAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; +import { createTestListing } from "#test-utils/db-helpers/listings.ts"; + +const setupStage = async (sessionId: string, maxAttendees = 2) => { + const listing = await createTestListing({ maxAttendees, unitPrice: 1000 }); + const intent = checkoutIntent({ + items: [ + checkoutItem({ + listingId: listing.id, + name: listing.name, + slug: listing.slug, + }), + ], + }); + const stage = await stageCheckout(sessionId, "stripe", intent); + const bookings: CanonicalBooking[] = [ + { + date: null, + durationDays: 1, + listingId: listing.id, + pricePaid: 1000, + quantity: 1, + }, + ]; + const input = { + address: intent.address, + bookings, + email: intent.email, + name: intent.name, + paymentId: `pi_${sessionId}`, + phone: intent.phone, + special_instructions: intent.special_instructions, + }; + const plan: FinalizedBookingBatchPlan = { + finalize: { + paymentReference: `pi_${sessionId}`, + sessionId, + }, + legs: [], + usages: [], + }; + return { input, listing, plan, stage }; +}; + +const activate = async (sessionId: string) => { + const setup = await setupStage(sessionId); + await reserveSession(sessionId); + return { + ...setup, + run: () => + activateStagedBooking( + sessionId, + setup.stage.attendeeId, + setup.stage.ticketToken, + setup.input, + setup.plan, + ), + }; +}; + +/** Run an activation expected to throw `message`, then assert the whole thing + * rolled back — the staged row stays at quantity 0, never claimed against a + * stage the activation couldn't finish. */ +const expectActivationRollback = async ( + setup: { run: () => Promise; stage: { attendeeId: number } }, + message: string, +): Promise => { + await expect(setup.run()).rejects.toThrow(message); + const row = await execute( + "SELECT quantity FROM listing_attendees WHERE attendee_id = ?", + [setup.stage.attendeeId], + ); + expect(row.rows.map((value) => value.quantity)).toEqual([0]); +}; + +describeWithEnv("db > staged booking activation", { db: true }, () => { + test("activates the staged row", async () => { + const setup = await activate("cs_activate_ok"); + + expect(await setup.run()).toEqual({ success: true }); + const row = await execute( + `SELECT stage.state, stage.ticket_tokens, booking.quantity + FROM checkout_stages AS stage + JOIN listing_attendees AS booking + ON booking.attendee_id = stage.attendee_id + WHERE stage.payment_session_id = ?`, + ["cs_activate_ok"], + ); + expect( + row.rows.map((value) => [ + value.state, + value.ticket_tokens, + value.quantity, + ]), + ).toEqual([["booked", "", 1]]); + const attendee = await getAttendeeRaw(setup.stage.attendeeId); + if (!attendee) throw new Error("Expected activated attendee"); + expect( + (await decryptAttendeeFields(attendee, await getTestPrivateKey(), true)) + .payment_id, + ).toBe("pi_cs_activate_ok"); + }); + + test("activates a nonzero parent and package path", async () => { + const setup = await activate("cs_activate_path"); + await execute( + `UPDATE listing_attendees + SET parent_listing_id = 123, package_group_id = 456 + WHERE attendee_id = ?`, + [setup.stage.attendeeId], + ); + setup.input.bookings[0]!.parentListingId = 123; + setup.input.bookings[0]!.packageGroupId = 456; + + expect(await setup.run()).toEqual({ success: true }); + const row = await execute( + `SELECT parent_listing_id, package_group_id + FROM listing_attendees WHERE attendee_id = ?`, + [setup.stage.attendeeId], + ); + expect( + row.rows.map((value) => [ + value.parent_listing_id, + value.package_group_id, + ]), + ).toEqual([[123, 456]]); + }); + + test("activates a parent and child staged together without allocations", async () => { + const sessionId = "cs_activate_family"; + const parent = await createTestListing({ + maxAttendees: 5, + name: "Family Parent", + unitPrice: 1000, + }); + const child = await createTestListing({ + maxAttendees: 5, + name: "Family Child", + unitPrice: 500, + }); + await listingChildren.setIds(parent.id, [child.id]); + const stage = await stageCheckout( + sessionId, + "stripe", + checkoutIntent({ + items: [ + checkoutItem({ + listingId: parent.id, + name: parent.name, + slug: parent.slug, + }), + checkoutItem({ + listingId: child.id, + name: child.name, + slug: child.slug, + unitPrice: 500, + }), + ], + }), + ); + // Staging stamped the pairing on the stored child row (the child's parent + // is booked in the same order), even though nothing threaded allocations. + const staged = await execute( + `SELECT listing_id, parent_listing_id FROM listing_attendees + WHERE attendee_id = ? ORDER BY listing_id`, + [stage.attendeeId], + ); + expect( + staged.rows.map((row) => [row.listing_id, row.parent_listing_id]), + ).toEqual([ + [parent.id, 0], + [child.id, parent.id], + ]); + await reserveSession(sessionId); + + // Activation receives the raw un-stamped bookings (as the payment path + // builds them) and must derive the same pairing itself — before it did, + // every parent+child order read as "changed" and was refunded. + const bookings: CanonicalBooking[] = [ + { + date: null, + durationDays: 1, + listingId: parent.id, + pricePaid: 1000, + quantity: 1, + }, + { + date: null, + durationDays: 1, + listingId: child.id, + pricePaid: 500, + quantity: 1, + }, + ]; + const activated = await activateStagedBooking( + sessionId, + stage.attendeeId, + stage.ticketToken, + { + address: "", + bookings, + email: "buyer@example.com", + name: "Buyer", + paymentId: `pi_${sessionId}`, + phone: "", + special_instructions: "", + }, + { + finalize: { paymentReference: `pi_${sessionId}`, sessionId }, + legs: [], + usages: [], + }, + ); + expect(activated).toEqual({ success: true }); + const claimed = await execute( + `SELECT listing_id, parent_listing_id, quantity FROM listing_attendees + WHERE attendee_id = ? ORDER BY listing_id`, + [stage.attendeeId], + ); + expect( + claimed.rows.map((row) => [ + row.listing_id, + row.parent_listing_id, + row.quantity, + ]), + ).toEqual([ + [parent.id, 0, 1], + [child.id, parent.id, 1], + ]); + }); + + test("reports a stage whose row is already live without touching it", async () => { + const setup = await activate("cs_activate_live"); + await execute( + "UPDATE listing_attendees SET quantity = 1 WHERE attendee_id = ?", + [setup.stage.attendeeId], + ); + + // The row may be a live booking, so activation reports the conflict as a + // structured outcome (the caller holds the money for the operator) rather + // than throwing into a webhook retry loop. + expect(await setup.run()).toEqual({ + reason: "stage_active", + success: false, + }); + const row = await execute( + "SELECT quantity FROM listing_attendees WHERE attendee_id = ?", + [setup.stage.attendeeId], + ); + expect(row.rows.map((value) => value.quantity)).toEqual([1]); + }); + + test("reports a stage whose booking paths changed", async () => { + const setup = await activate("cs_activate_changed"); + await execute( + "UPDATE listing_attendees SET package_group_id = 999999 WHERE attendee_id = ?", + [setup.stage.attendeeId], + ); + + // Still all quantity 0, so nothing is live: the caller can safely refund. + expect(await setup.run()).toEqual({ + reason: "stage_mismatch", + success: false, + }); + }); + + test("refuses a negative activation quantity before opening the write batch", async () => { + const setup = await activate("cs_activate_negative"); + setup.input.bookings[0]!.quantity = -1; + + expect(await setup.run()).toEqual({ + reason: "capacity_exceeded", + success: false, + }); + }); + + test("propagates an unexpected batch failure and rolls activation back", async () => { + const setup = await activate("cs_activate_transfer_conflict"); + const reference = "activate-transfer-conflict"; + const occurredAt = "2026-07-14T12:00:00.000Z"; + await postTransfers([ + { + amount: 1, + destination: attendeeAccount(999), + eventGroup: "existing-transfer", + occurredAt, + reference, + source: WORLD, + }, + ]); + setup.plan.legs.push({ + amount: 1000, + destination: attendeeAccount(setup.stage.attendeeId), + eventGroup: "activation-transfer", + occurredAt, + reference, + source: WORLD, + }); + + await expectActivationRollback(setup, "transfers.reference"); + }); + + test("leaves the stage at zero when capacity was taken", async () => { + const sessionId = "cs_activate_full"; + const setup = await setupStage(sessionId, 1); + const filler = await bookAttendee(setup.listing, { + email: "filler@example.com", + name: "Filler", + }); + if (!filler.success) throw new Error("Expected filler booking"); + await reserveSession(sessionId); + + expect( + await activateStagedBooking( + sessionId, + setup.stage.attendeeId, + setup.stage.ticketToken, + setup.input, + setup.plan, + ), + ).toEqual({ reason: "capacity_exceeded", success: false }); + }); + + test("reports an extra that sold out", async () => { + const setup = await activate("cs_activate_extra"); + const modifier = await modifiersTable.insert({ + calcKind: "fixed", + calcValue: 1, + direction: "charge", + name: "Last extra", + stock: 1, + }); + await execute( + `INSERT INTO modifier_usages + (modifier_id, attendee_id, quantity, amount_applied, created) + VALUES (?, 999999, 1, 100, '2026-07-12T00:00:00.000Z')`, + [modifier.id], + ); + setup.plan.usages.push({ + amountApplied: 100, + modifierId: modifier.id, + quantity: 1, + }); + + expect(await setup.run()).toEqual({ reason: "sold_out", success: false }); + }); + + test("records available extra usage during activation", async () => { + const setup = await activate("cs_activate_extra_ok"); + const modifier = await modifiersTable.insert({ + calcKind: "fixed", + calcValue: 1, + direction: "charge", + name: "Available extra", + stock: 1, + }); + setup.plan.usages.push({ + amountApplied: 100, + modifierId: modifier.id, + quantity: 1, + }); + + expect(await setup.run()).toEqual({ success: true }); + expect(await modifierUsedQuantities([modifier.id])).toEqual( + new Map([[modifier.id, 1]]), + ); + }); + + test("fails loudly when attendee encryption is unavailable", async () => { + const setup = await activate("cs_activate_encryption"); + await execute("DELETE FROM settings WHERE key = ?", [ + CONFIG_KEYS.PUBLIC_KEY, + ]); + settings.invalidateCache(); + + await expect(setup.run()).rejects.toThrow( + "Could not encrypt staged attendee", + ); + }); + + test("rolls back when payment finalization is lost", async () => { + const setup = await activate("cs_activate_finalize"); + await execute( + `CREATE TRIGGER lose_activation_finalize + BEFORE UPDATE OF quantity ON listing_attendees + BEGIN + DELETE FROM processed_payments + WHERE payment_session_id = 'cs_activate_finalize'; + END`, + ); + + await expectActivationRollback( + setup, + "Payment session cs_activate_finalize was not finalized", + ); + }); + + test("throws when the stage is no longer pending at activation", async () => { + const setup = await activate("cs_activate_notpending"); + // Resolve the stage out from under the activation (as a concurrent delivery + // would) while leaving the quantity-0 rows intact: the row-level pre-check + // still passes, so the guard that matters is the compare-and-set flip, which + // must find no pending stage to book and refuse to book someone else's. + await execute( + "UPDATE checkout_stages SET state = 'failed' WHERE payment_session_id = ?", + ["cs_activate_notpending"], + ); + + // The throw rolls the whole activation back, so the row stays at quantity 0 + // rather than being claimed against a stage that was already resolved. + await expectActivationRollback( + setup, + "was not this attendee's pending stage at activation", + ); + }); + + // refusalReason is the answer given when the atomic claim refuses. The + // stage-problem branches model a change that lands WHILE the claim is in + // flight — after the pre-check passed — so they are driven directly against + // fabricated row state rather than through activateStagedBooking, whose + // pre-check would catch the same change first. + test("refusalReason names a row flipped live during the claim", async () => { + const setup = await setupStage("cs_refusal_live"); + await execute( + "UPDATE listing_attendees SET quantity = 1 WHERE attendee_id = ?", + [setup.stage.attendeeId], + ); + expect( + await refusalReason(setup.stage.attendeeId, setup.input.bookings, []), + ).toBe("stage_active"); + }); + + test("refusalReason names lines changed during the claim", async () => { + const setup = await setupStage("cs_refusal_changed"); + const other = await createTestListing({ unitPrice: 1000 }); + const changed = [{ ...setup.input.bookings[0]!, listingId: other.id }]; + expect(await refusalReason(setup.stage.attendeeId, changed, [])).toBe( + "stage_mismatch", + ); + }); + + test("refusalReason blames capacity when the stage is untouched", async () => { + const setup = await setupStage("cs_refusal_capacity"); + expect( + await refusalReason(setup.stage.attendeeId, setup.input.bookings, []), + ).toBe("capacity_exceeded"); + }); + + test("refusalReason throws when the staged rows are gone (impossible state)", async () => { + const setup = await setupStage("cs_refusal_gone"); + // A staged attendee with zero booking rows can never happen in production: + // a pending stage's rows are only ever removed with the stage itself, and + // admin deletes / listing deletes are blocked while pending. If it is ever + // observed it is a missed cascade, so it must throw, not book fresh around. + await execute("DELETE FROM listing_attendees WHERE attendee_id = ?", [ + setup.stage.attendeeId, + ]); + await expect( + refusalReason(setup.stage.attendeeId, setup.input.bookings, []), + ).rejects.toThrow("has no booking rows at activation"); + }); +}); diff --git a/test/shared/db/attendees/create-errors.test.ts b/test/shared/db/attendees/create-errors.test.ts new file mode 100644 index 0000000000..93ebc3413f --- /dev/null +++ b/test/shared/db/attendees/create-errors.test.ts @@ -0,0 +1,86 @@ +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { createBookingAtomic } from "#shared/db/attendees/api.ts"; +import { createAttendeeAtomicImpl } from "#shared/db/attendees/create.ts"; +import { execute, queryOne } from "#shared/db/client.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; +import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { tx } from "#test-utils/transfer-factory.ts"; + +describeWithEnv("db > create booking errors", { db: true }, () => { + test("propagates an unexpected database write failure", async () => { + const listing = await createTestListing({ maxAttendees: 2 }); + await execute(`CREATE TRIGGER fail_attendee_create + BEFORE INSERT ON attendees + BEGIN + SELECT RAISE(ABORT, 'unexpected create failure'); + END`); + + await expect( + createBookingAtomic( + { + bookings: [{ listingId: listing.id, pricePaid: 1000, quantity: 1 }], + email: "failure@example.com", + name: "Failure", + ticketToken: "FAILURETOKEN", + }, + { finalize: null, legs: [], usages: [] }, + ), + ).rejects.toThrow("unexpected create failure"); + }); + + test("stamps a single batch ledger leg on the booking", async () => { + const listing = await createTestListing({ maxAttendees: 2 }); + const result = await createBookingAtomic( + { + bookings: [{ listingId: listing.id, pricePaid: 1000, quantity: 1 }], + email: "one-leg@example.com", + name: "One leg", + ticketToken: "ONELEGTOKEN", + }, + { + finalize: null, + legs: [tx({ eventGroup: "one-leg", reference: "one-leg-ref" })], + usages: [], + }, + ); + if (result === "sold-out" || !result.success) { + throw new Error("Expected one-leg booking"); + } + + const row = await queryOne<{ ledger_event_group: string }>( + "SELECT ledger_event_group FROM listing_attendees WHERE attendee_id = ?", + [result.attendees[0]!.id], + ); + expect(row?.ledger_event_group).toBe("one-leg"); + }); + + test("rolls back an interactive partial booking before posting the ledger", async () => { + const open = await createTestListing({ maxAttendees: 2 }); + const full = await createTestListing({ maxAttendees: 0 }); + let posted = false; + + const result = await createAttendeeAtomicImpl( + { + bookings: [ + { listingId: open.id, quantity: 1 }, + { listingId: full.id, quantity: 1 }, + ], + email: "interactive@example.com", + name: "Interactive", + }, + () => { + posted = true; + return Promise.resolve(); + }, + ); + + expect(result).toEqual({ reason: "capacity_exceeded", success: false }); + expect(posted).toBe(false); + const rows = await execute( + "SELECT COUNT(*) AS count FROM listing_attendees WHERE listing_id IN (?, ?)", + [open.id, full.id], + ); + expect(Number(rows.rows[0]!.count)).toBe(0); + }); +}); diff --git a/test/shared/db/attendees/create.test.ts b/test/shared/db/attendees/create.test.ts index 9cef8f9b95..b6d5029d52 100644 --- a/test/shared/db/attendees/create.test.ts +++ b/test/shared/db/attendees/create.test.ts @@ -17,8 +17,8 @@ import type { PricedLine, PricedOrder, } from "#shared/checkout-pricing.ts"; -import { createBookingAtomic } from "#shared/db/attendees/api.ts"; -import type { BookingBatchPlan } from "#shared/db/attendees/create.ts"; +import { createBookingAtomic as createBookingAtomicImpl } from "#shared/db/attendees/api.ts"; +import type { BookingBatchPlan } from "#shared/db/attendees/create-batch.ts"; import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; import { queryOne, withTransaction } from "#shared/db/client.ts"; import { modifierUsedQuantities } from "#shared/db/modifier-usage.ts"; @@ -30,6 +30,11 @@ import { import { describeWithEnv } from "#test-utils/db.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +const createBookingAtomic = ( + input: Parameters[0], + plan: Parameters[1], +) => createBookingAtomicImpl({ ...input, ticketToken: "BATCHTOKEN" }, plan); + /** Narrow a createBookingAtomic result to the successful shape, or fail the test. */ const expectBookingOk = ( result: Awaited>, @@ -104,11 +109,12 @@ const buildPlan = async (opts: { total: opts.total ?? 0, }); const plan = await bookingBatchPlan( + null, usages, { eventId: opts.eventId, occurredAt: OCCURRED_AT, pricedOrder }, opts.sessionId ? { paymentReference: `pi_${opts.sessionId}`, sessionId: opts.sessionId } - : undefined, + : null, ); return { plan, pricedOrder }; }; @@ -366,6 +372,7 @@ describeWithEnv("db > createBookingAtomic", { db: true }, () => { sessionId: "sess_batch_existing_ledger", total: 500, }); + plan.legs = [plan.legs[0]!]; await postTransfers(plan.legs); await expectCapacityExceeded(plan, listing.id, 500, plan.legs.length); @@ -374,7 +381,7 @@ describeWithEnv("db > createBookingAtomic", { db: true }, () => { ).toBe(null); }); - test("posts no legs and does not finalize when a multi-listing cart only partly lands", async () => { + test("rolls back every row when a multi-listing cart only partly lands", async () => { const open = await createTestListing({ maxAttendees: 5, unitPrice: 500 }); const full = await createTestListing({ maxAttendees: 0, unitPrice: 500 }); const { plan } = await buildPlan({ @@ -397,10 +404,9 @@ describeWithEnv("db > createBookingAtomic", { db: true }, () => { plan, ); - // Greedy create: the open listing's booking landed, the full one didn't. - expect(expectBookingOk(result).attendees.length).toBe(1); - // The all-bookings-landed guard held back every leg and the finalize, so the - // caller's ensureAllBookings can roll the partial booking back cleanly. + expect(result).toEqual({ reason: "capacity_exceeded", success: false }); + expect(await getAttendeesRaw(open.id)).toEqual([]); + expect(await getAttendeesRaw(full.id)).toEqual([]); expect((await allTransfers()).length).toBe(0); expect((await isSessionProcessed("sess_batch_partial"))!.attendee_id).toBe( null, diff --git a/test/shared/db/attendees/pii.test.ts b/test/shared/db/attendees/pii.test.ts index a21dce1d4a..3efa3c56fb 100644 --- a/test/shared/db/attendees/pii.test.ts +++ b/test/shared/db/attendees/pii.test.ts @@ -164,8 +164,8 @@ describeWithEnv("PII crypto", { db: true }, () => { expect(pii.lng).toBe(""); }); - test("encryptAttendeeFields encrypts blank coordinates that decrypt back to empty", async () => { - const result = await encryptAttendeeFields(encInput); + test("encryptAttendeeFields encrypts the exact supplied ticket token", async () => { + const result = await encryptAttendeeFields(encInput, "PIITOKEN01"); expect(result).not.toBeNull(); const pii = await decryptPiiBlob( result!.encryptedPiiBlob, @@ -175,6 +175,8 @@ describeWithEnv("PII crypto", { db: true }, () => { expect(pii.lat).toBe(""); expect(pii.lng).toBe(""); expect(pii.payment_id).toBe("pay_pii"); + expect(pii.ticket_token).toBe("PIITOKEN01"); + expect(result!.ticketToken).toBe("PIITOKEN01"); }); test("encryptAttendeeFields returns null when no public key is configured", async () => { @@ -184,7 +186,7 @@ describeWithEnv("PII crypto", { db: true }, () => { }); settings.invalidateCache(); - const result = await encryptAttendeeFields(encInput); + const result = await encryptAttendeeFields(encInput, "PIITOKEN02"); expect(result).toBeNull(); }); diff --git a/test/shared/db/attendees/servicing/editing.test.ts b/test/shared/db/attendees/servicing/editing.test.ts index c14fa1dd2a..7f4c3bd647 100644 --- a/test/shared/db/attendees/servicing/editing.test.ts +++ b/test/shared/db/attendees/servicing/editing.test.ts @@ -16,6 +16,7 @@ import { expect } from "@std/expect"; import { it as test } from "@std/testing/bdd"; import { ATTENDEE_KIND, SERVICING_KIND } from "#shared/db/attendees/kind.ts"; +import { getDb } from "#shared/db/client.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { createDailyTestListing } from "#test-utils/db-helpers/listings.ts"; import { @@ -45,6 +46,32 @@ describeWithEnv("servicing §4 — editing", { db: true }, () => { expect(await tokenIndexOf(event.id)).toBe(before); }); + test("editing preserves the booking row and its line state", async () => { + const { event, listing } = await createServicingHold({ quantity: 2 }); + await getDb().execute({ + args: [event.id, listing.id], + sql: `UPDATE listing_attendees + SET checked_in = 1, attachment_downloads = 7 + WHERE attendee_id = ? AND listing_id = ?`, + }); + const readLine = () => + getDb().execute({ + args: [event.id, listing.id], + sql: `SELECT id, checked_in, attachment_downloads + FROM listing_attendees + WHERE attendee_id = ? AND listing_id = ?`, + }); + const before = (await readLine()).rows[0]!; + + await updateServicingEvent(event.id, { + bookings: [{ listingId: listing.id, quantity: 2 }], + name: "Renamed service", + }); + + const after = (await readLine()).rows[0]!; + expect(after).toEqual(before); + }); + test("editing updates name and bookings (changed qty, removed listing)", async () => { const [a, b] = await createDailyListingPair("A", "B"); const { createTestServicingEvent } = await import( diff --git a/test/shared/db/backup.test.ts b/test/shared/db/backup.test.ts index 085ac45cc9..e13e3206f4 100644 --- a/test/shared/db/backup.test.ts +++ b/test/shared/db/backup.test.ts @@ -4,28 +4,29 @@ import { describe, it as test } from "@std/testing/bdd"; import { stub } from "@std/testing/mock"; import { unzipSync, zipSync } from "fflate"; import { - BACKUP_REQUIRED_WITHIN_MS, type BackupManifest, + countZipStatements, + createBackupZip, + readManifest, + restoreFromSql, + restoreFromZip, + splitStatements, +} from "#shared/db/backup.ts"; +import { captureBackup, exportTable } from "#shared/db/backup-snapshot.ts"; +import { + BACKUP_REQUIRED_WITHIN_MS, backupDir, backupKey, backupLeaf, backupTimestamp, - countZipStatements, - createBackup, - createBackupZip, dbName, - exportTable, hasRecentBackup, isBackupLeaf, isBackupPath, isRemoteDatabase, parseBackupTime, pruneOldBackups, - readManifest, - restoreFromSql, - restoreFromZip, - splitStatements, -} from "#shared/db/backup.ts"; +} from "#shared/db/backup-storage.ts"; import { getDb, queryAll } from "#shared/db/client.ts"; import { listingsTable } from "#shared/db/listings/records.ts"; import { SCHEMA } from "#shared/db/migrations/schema/index.ts"; @@ -117,14 +118,14 @@ describeWithEnv("backup", { db: true }, () => { describe("createBackup", () => { test("returns tables in SCHEMA order", async () => { - const backups = await createBackup(); + const backups = (await captureBackup()).tables; expect(backups.map((b) => b.table)).toEqual(SCHEMA_TABLE_NAMES); }); test("skips tables that do not exist", async () => { await getDb().execute("DROP TABLE IF EXISTS holidays"); try { - const backups = await createBackup(); + const backups = (await captureBackup()).tables; const names = backups.map((b) => b.table); expect(names).not.toContain("holidays"); expect(names.length).toBe(SCHEMA_TABLE_NAMES.length - 1); diff --git a/test/shared/db/backup/checkout-stage-snapshot.test.ts b/test/shared/db/backup/checkout-stage-snapshot.test.ts new file mode 100644 index 0000000000..a479e8e93c --- /dev/null +++ b/test/shared/db/backup/checkout-stage-snapshot.test.ts @@ -0,0 +1,319 @@ +import type { InArgs, InStatement } from "@libsql/client"; +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { stub } from "@std/testing/mock"; +import { unzipSync, zipSync } from "fflate"; +import { + type BackupManifest, + createBackupZip, + PostResetError, + restoreFromZip, +} from "#shared/db/backup.ts"; +import { + BACKUP_CAPTURE_ATTEMPTS, + captureBackup, +} from "#shared/db/backup-snapshot.ts"; +import { stageCheckout } from "#shared/db/checkout-stages.ts"; +import { getDb } from "#shared/db/client.ts"; +import { checkoutIntent, checkoutItem } from "#test-utils/checkout.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; +import { createTestListing } from "#test-utils/db-helpers/listings.ts"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +const stageForBackup = async (sessionId: string): Promise => { + const listing = await createTestListing({ unitPrice: 1000 }); + await stageCheckout( + sessionId, + "stripe", + checkoutIntent({ + items: [ + checkoutItem({ + listingId: listing.id, + name: listing.name, + slug: listing.slug, + }), + ], + }), + ); +}; + +type BatchStatement = InStatement | [string, InArgs?]; + +const statementSql = (statement: BatchStatement): string => + Array.isArray(statement) + ? statement[0] + : typeof statement === "string" + ? statement + : statement.sql; + +const isRevisionRead = (statements: BatchStatement[]): boolean => + statements.some((statement) => + statementSql(statement).includes( + "SELECT revision FROM checkout_stage_revisions", + ), + ); + +const rewriteManifest = ( + zip: Uint8Array, + change: (manifest: BackupManifest) => void, +): Uint8Array => { + const files = unzipSync(zip); + const manifest = JSON.parse( + decoder.decode(files["manifest.json"]!), + ) as BackupManifest; + change(manifest); + files["manifest.json"] = encoder.encode(JSON.stringify(manifest)); + return zipSync(files); +}; + +type RevisionReadTracker = { count: number }; + +const captureWhileStageRevisionChanges = async ( + sessionId: string, + shouldChange: (revisionReads: number) => boolean, + tracker: RevisionReadTracker, +): Promise>["tables"]> => { + await stageForBackup(sessionId); + const client = getDb(); + const originalBatch = client.batch.bind(client); + const originalExecute = client.execute.bind(client); + using _batch = stub(client, "batch", (async (statements, mode) => { + if (isRevisionRead(statements)) { + tracker.count += 1; + if (shouldChange(tracker.count)) { + await originalExecute( + "UPDATE checkout_stages SET state = state WHERE payment_session_id = ?", + [sessionId], + ); + } + } + return await originalBatch(statements, mode); + }) as typeof client.batch); + return (await captureBackup()).tables; +}; + +const stagedBackupWithout = async ( + sessionId: string, + table: "attendees" | "listing_attendees", +): Promise => { + await stageForBackup(sessionId); + const files = unzipSync(await createBackupZip()); + const manifest = JSON.parse( + decoder.decode(files["manifest.json"]!), + ) as BackupManifest; + manifest.tables[table] = 0; + files[`${table}.sql`] = new Uint8Array(0); + files["manifest.json"] = encoder.encode(JSON.stringify(manifest)); + return zipSync(files); +}; + +describeWithEnv("db > checkout stage backup snapshots", { db: true }, () => { + test("puts the stable checkout stage revision in the manifest", async () => { + await stageForBackup("cs_backup_certificate"); + + const files = unzipSync(await createBackupZip()); + const manifest = JSON.parse( + decoder.decode(files["manifest.json"]!), + ) as BackupManifest; + const revision = await getDb().execute( + "SELECT revision FROM checkout_stage_revisions WHERE id = 1", + ); + + expect(manifest.checkoutStageRevision).toEqual({ + revision: Number(revision.rows[0]!.revision), + }); + }); + + test("pins table discovery and paginated export reads to the primary", async () => { + await createTestListing({ name: "Primary snapshot" }); + const client = getDb(); + const originalBatch = client.batch.bind(client); + const backupReadModes: string[] = []; + using _batch = stub(client, "batch", ((statements, mode) => { + if ( + statements.some((statement) => { + const sql = statementSql(statement); + return ( + sql.includes("sqlite_master") || sql.includes("__backup_rowid__") + ); + }) + ) { + backupReadModes.push(String(mode)); + } + return originalBatch(statements, mode); + }) as typeof client.batch); + + await captureBackup(); + + expect(new Set(backupReadModes)).toEqual(new Set(["write"])); + }); + + test("captures a genuine pre-stage schema without reading a revision", async () => { + const client = getDb(); + const originalBatch = client.batch.bind(client); + let revisionReads = 0; + using _batch = stub(client, "batch", (async (statements, mode) => { + if (isRevisionRead(statements)) revisionReads += 1; + const results = await originalBatch(statements, mode); + if ( + statements.some((statement) => + statementSql(statement).includes("sqlite_master"), + ) + ) { + results[0]!.rows = results[0]!.rows.filter( + (row) => + row.name !== "checkout_stages" && + row.name !== "checkout_stage_revisions", + ); + } + return results; + }) as typeof client.batch); + + const files = unzipSync(await createBackupZip()); + const manifest = JSON.parse( + decoder.decode(files["manifest.json"]!), + ) as BackupManifest; + + expect(files["checkout_stages.sql"]).toBeUndefined(); + expect(manifest.checkoutStageRevision).toBeUndefined(); + expect(revisionReads).toBe(0); + }); + + test("retries the whole capture when the stage revision changes", async () => { + const tracker = { count: 0 }; + + await captureWhileStageRevisionChanges( + "cs_backup_retry", + (reads) => reads === 2, + tracker, + ); + + expect(tracker.count).toBe(4); + }); + + test("fails when checkout stages keep changing through every capture", async () => { + const tracker = { count: 0 }; + + await expect( + captureWhileStageRevisionChanges( + "cs_backup_never_stable", + (reads) => reads % 2 === 0, + tracker, + ), + ).rejects.toThrow("Checkout stages kept changing during backup"); + expect(tracker.count).toBe(BACKUP_CAPTURE_ATTEMPTS * 2); + }); + + test("rejects a staged archive without a revision certificate before reset", async () => { + await stageForBackup("cs_backup_missing_certificate"); + const zip = rewriteManifest(await createBackupZip(), (manifest) => { + delete manifest.checkoutStageRevision; + }); + + await expect(restoreFromZip(zip)).rejects.toThrow( + "checkout stage revision certificate", + ); + const listings = await getDb().execute( + "SELECT COUNT(*) AS count FROM listings", + ); + expect(listings.rows[0]!.count).toBe(1); + }); + + test("accepts a genuine backup from before checkout stages existed", async () => { + const manifest: BackupManifest = { + latestUpdate: "before checkout stages", + schemaHash: "pre-stage", + tables: { settings: 1 }, + timestamp: "2026-07-11T00:00:00.000Z", + }; + const zip = zipSync({ + "manifest.json": encoder.encode(JSON.stringify(manifest)), + "settings.sql": encoder.encode( + "INSERT INTO settings (key, value) VALUES ('pre_stage', 'kept');", + ), + }); + + await restoreFromZip(zip); + + const restored = await getDb().execute( + "SELECT value FROM settings WHERE key = 'pre_stage'", + ); + expect(restored.rows[0]!.value).toBe("kept"); + }); + + test("rejects a manifest whose table file is missing before reset", async () => { + await createTestListing({ name: "Must survive invalid archive" }); + const manifest: BackupManifest = { + latestUpdate: "old", + schemaHash: "old", + tables: { settings: 0 }, + timestamp: "2026-07-11T00:00:00.000Z", + }; + const zip = zipSync({ + "manifest.json": encoder.encode(JSON.stringify(manifest)), + }); + + await expect(restoreFromZip(zip)).rejects.toThrow( + "missing table file settings.sql", + ); + const listings = await getDb().execute( + "SELECT COUNT(*) AS count FROM listings", + ); + expect(listings.rows[0]!.count).toBe(1); + }); + + test("rejects a table file absent from its manifest before reset", async () => { + await createTestListing({ name: "Unlisted file survivor" }); + const manifest: BackupManifest = { + latestUpdate: "old", + schemaHash: "old", + tables: {}, + timestamp: "2026-07-11T00:00:00.000Z", + }; + const zip = zipSync({ + "listings.sql": new Uint8Array(0), + "manifest.json": encoder.encode(JSON.stringify(manifest)), + }); + + await expect(restoreFromZip(zip)).rejects.toThrow( + "listings.sql is absent from its manifest", + ); + const listings = await getDb().execute( + "SELECT COUNT(*) AS count FROM listings", + ); + expect(listings.rows[0]!.count).toBe(1); + }); + + test("rejects a restored row count that differs from the manifest", async () => { + await createTestListing({ name: "Count mismatch" }); + const zip = rewriteManifest(await createBackupZip(), (manifest) => { + const count = manifest.tables.listings; + if (count === undefined) + throw new Error("Expected listings manifest row"); + manifest.tables.listings = count + 1; + }); + + await expect(restoreFromZip(zip)).rejects.toThrow(PostResetError); + }); + + test("rejects a restored open stage without a booking", async () => { + await expect( + restoreFromZip( + await stagedBackupWithout( + "cs_backup_missing_booking", + "listing_attendees", + ), + ), + ).rejects.toThrow(PostResetError); + }); + + test("rejects a restored open stage without its attendee", async () => { + await expect( + restoreFromZip( + await stagedBackupWithout("cs_backup_missing_attendee", "attendees"), + ), + ).rejects.toThrow(PostResetError); + }); +}); diff --git a/test/shared/db/backup/restore.test.ts b/test/shared/db/backup/restore.test.ts index 0e3a9b5b5f..0118dff575 100644 --- a/test/shared/db/backup/restore.test.ts +++ b/test/shared/db/backup/restore.test.ts @@ -2,11 +2,11 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; import { createBackupZip, - exportTable, PostResetError, restoreFromSql, restoreFromZip, } from "#shared/db/backup.ts"; +import { exportTable } from "#shared/db/backup-snapshot.ts"; import { getDb, queryAll, queryOne } from "#shared/db/client.ts"; import { initDb } from "#shared/db/migrations.ts"; import { describeWithEnv } from "#test-utils/db.ts"; diff --git a/test/shared/db/capacity.test.ts b/test/shared/db/capacity.test.ts index cf42e55bf9..461bfc3822 100644 --- a/test/shared/db/capacity.test.ts +++ b/test/shared/db/capacity.test.ts @@ -1,8 +1,9 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; import { capacityRuleTypeSql } from "#shared/capacity-rules.ts"; +import { currentBatchCapacityCondition } from "#shared/db/attendees/capacity/checks.ts"; import { - buildBatchCapacitySql, + buildBatchCapacityCondition, buildCapacityCondition, buildListingCountSql, type CapacityBucket, @@ -56,6 +57,15 @@ describe("dateToRange", () => { }); }); +describe("currentBatchCapacityCondition", () => { + test("an empty order needs no database reads", async () => { + expect(await currentBatchCapacityCondition([])).toEqual({ + args: [], + sql: "1 = 1", + }); + }); +}); + describe("buildListingCountSql", () => { test("date-less: reads the running total", () => { expect(buildListingCountSql(LISTING, null)).toEqual({ @@ -191,7 +201,7 @@ describe("buildCapacityCondition", () => { }); }); -describe("buildBatchCapacitySql", () => { +describe("buildBatchCapacityCondition", () => { const bucket = ( perDay: [string, number][], total: number, @@ -201,31 +211,33 @@ describe("buildBatchCapacitySql", () => { }); test("no demand at all trivially fits", () => { - expect(buildBatchCapacitySql(new Map(), new Map())).toEqual({ + expect(buildBatchCapacityCondition(new Map(), new Map())).toEqual({ args: [], - sql: "SELECT 1 AS fits", + sql: "1 = 1", }); }); test("a zero-total bucket produces no clause", () => { expect( - buildBatchCapacitySql(new Map([[LISTING, bucket([], 0)]]), new Map()), - ).toEqual({ args: [], sql: "SELECT 1 AS fits" }); + buildBatchCapacityCondition( + new Map([[LISTING, bucket([], 0)]]), + new Map(), + ), + ).toEqual({ args: [], sql: "1 = 1" }); }); test("date-less listing demand checks the running total against the cap", () => { - const { sql, args } = buildBatchCapacitySql( + const { sql, args } = buildBatchCapacityCondition( new Map([[LISTING, bucket([], QTY)]]), new Map(), ); expect(args).toEqual([LISTING]); expect(sql).toContain(`+ ${QTY} <=`); expect(sql).toContain(`id = ${LISTING} AND active = 1`); - expect(sql).toContain("THEN 1 ELSE 0 END AS fits"); }); test("a single remaining unit of demand still gets its clause", () => { - const { sql } = buildBatchCapacitySql( + const { sql } = buildBatchCapacityCondition( new Map([[LISTING, bucket([], 1)]]), new Map(), ); @@ -234,7 +246,7 @@ describe("buildBatchCapacitySql", () => { test("per-day listing demand emits one clause per day", () => { const other = dateToRange("2026-05-02"); - const { args, sql } = buildBatchCapacitySql( + const { args, sql } = buildBatchCapacityCondition( new Map([ [ LISTING, @@ -266,7 +278,7 @@ describe("buildBatchCapacitySql", () => { }); test("group demand folds the cart's date-less units into every day", () => { - const { args, sql } = buildBatchCapacitySql( + const { args, sql } = buildBatchCapacityCondition( new Map(), new Map([[9, bucket([[DAY, 2]], 3)]]), ); @@ -278,7 +290,7 @@ describe("buildBatchCapacitySql", () => { }); test("date-less-only group demand emits a single total clause", () => { - const { args, sql } = buildBatchCapacitySql( + const { args, sql } = buildBatchCapacityCondition( new Map(), new Map([[9, bucket([], 3)]]), ); diff --git a/test/shared/db/checkout-stage-fences.test.ts b/test/shared/db/checkout-stage-fences.test.ts new file mode 100644 index 0000000000..5fe60e239f --- /dev/null +++ b/test/shared/db/checkout-stage-fences.test.ts @@ -0,0 +1,106 @@ +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { + markCheckoutStage, + stageCheckout, +} from "#shared/db/checkout-stages.ts"; +import { getDb } from "#shared/db/client.ts"; +import { + isSessionProcessed, + reserveSession, +} from "#shared/db/processed-payments.ts"; +import { checkoutIntent, checkoutItem } from "#test-utils/checkout.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; +import { createTestAttendeeDirect } from "#test-utils/db-helpers/attendees.ts"; +import { createTestListing } from "#test-utils/db-helpers/listings.ts"; + +const stageFor = async ( + sessionId: string, +): Promise<{ attendeeId: number; listingId: number }> => { + const listing = await createTestListing({ unitPrice: 1000 }); + const stage = await stageCheckout( + sessionId, + "stripe", + checkoutIntent({ + items: [ + checkoutItem({ + listingId: listing.id, + name: listing.name, + slug: listing.slug, + }), + ], + }), + ); + return { attendeeId: stage.attendeeId, listingId: listing.id }; +}; + +const stageRevision = async (): Promise => { + const result = await getDb().execute( + "SELECT revision FROM checkout_stage_revisions WHERE id = 1", + ); + return Number(result.rows[0]?.revision ?? 0); +}; + +describeWithEnv("db > checkout stage fences", { db: true }, () => { + test("changes the checkout stage revision on insert, update, and delete", async () => { + const before = await stageRevision(); + await stageFor("cs_revision_mutations"); + expect(await stageRevision()).toBe(before + 1); + + await markCheckoutStage("cs_revision_mutations", "failed"); + expect(await stageRevision()).toBe(before + 2); + + await getDb().execute( + "DELETE FROM checkout_stages WHERE payment_session_id = ?", + ["cs_revision_mutations"], + ); + expect(await stageRevision()).toBe(before + 3); + }); + + test("rejects a legacy payment reservation that omits a staged attendee claim", async () => { + await stageFor("cs_legacy_reservation"); + + await expect( + getDb().execute({ + args: ["cs_legacy_reservation", new Date().toISOString()], + sql: `INSERT INTO processed_payments + (payment_session_id, attendee_id, processed_at) + VALUES (?, NULL, ?)`, + }), + ).rejects.toThrow("checkout stage claim does not match"); + expect(await isSessionProcessed("cs_legacy_reservation")).toBeNull(); + }); + + test("reserveSession records the staged attendee claim", async () => { + const stage = await stageFor("cs_current_reservation"); + + expect(await reserveSession("cs_current_reservation")).toEqual({ + reserved: true, + }); + expect( + (await isSessionProcessed("cs_current_reservation")) + ?.checkout_stage_attendee_id, + ).toBe(stage.attendeeId); + }); + + test("rejects finalizing an open stage to another attendee", async () => { + const stage = await stageFor("cs_mismatched_finalize"); + const other = await createTestAttendeeDirect( + stage.listingId, + "Other buyer", + "other-buyer@example.com", + ); + await reserveSession("cs_mismatched_finalize"); + + await expect( + getDb().execute( + `UPDATE processed_payments SET attendee_id = ? + WHERE payment_session_id = ?`, + [other.attendee.id, "cs_mismatched_finalize"], + ), + ).rejects.toThrow("open checkout stage belongs to another attendee"); + expect( + (await isSessionProcessed("cs_mismatched_finalize"))?.attendee_id, + ).toBeNull(); + }); +}); diff --git a/test/shared/db/checkout-stages.test.ts b/test/shared/db/checkout-stages.test.ts new file mode 100644 index 0000000000..3c283d7966 --- /dev/null +++ b/test/shared/db/checkout-stages.test.ts @@ -0,0 +1,393 @@ +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { stub } from "@std/testing/mock"; +import { t } from "#i18n"; +import { createAttendeeAtomic } from "#shared/db/attendees/api.ts"; +import { + discardPendingCheckoutSessions, + pruneCheckoutStageRows, +} from "#shared/db/checkout-stage-cleanup.ts"; +import { + attendeeIdsWithPendingStage, + beginCheckoutStageRefund, + createStagedCheckout, + getCheckoutStageOrNull, + listingHasPendingCheckout, + markCheckoutStage, + resolvePendingStage, + stageCheckout, +} from "#shared/db/checkout-stages.ts"; +import { getDb } from "#shared/db/client.ts"; +import { reserveSession } from "#shared/db/processed-payments.ts"; +import { stripePaymentProvider } from "#shared/stripe-provider.ts"; +import { checkoutIntent, checkoutItem } from "#test-utils/checkout.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; +import { createTestAttendeeDirect } from "#test-utils/db-helpers/attendees.ts"; +import { createTestListing } from "#test-utils/db-helpers/listings.ts"; + +const intentFor = (listing: { id: number; name: string; slug: string }) => + checkoutIntent({ + items: [ + checkoutItem({ + listingId: listing.id, + name: listing.name, + slug: listing.slug, + }), + ], + }); + +/** Assert a checkout for `listing` is refused up front: the provider is stubbed + * to throw (so a checkout that reached it fails loudly with `reason`), the + * result is the unavailable error, and no stage was written. */ +const expectStagedCheckoutRefused = async ( + listing: { id: number; name: string; slug: string }, + reason: string, +): Promise => { + using _checkout = stub(stripePaymentProvider, "createCheckoutSession", () => { + throw new Error(`provider reached for ${reason}`); + }); + const result = await createStagedCheckout( + stripePaymentProvider, + intentFor(listing), + "https://example.com", + ); + expect(result).toEqual({ error: t("public.checkout_unavailable") }); + expect(await listingHasPendingCheckout(listing.id)).toBe(false); +}; + +describeWithEnv("db > checkout stages", { db: true }, () => { + test("refuses to begin a refund for a missing stage", async () => { + await expect(beginCheckoutStageRefund("cs_missing_refund")).rejects.toThrow( + "did not enter refunding", + ); + }); + + test("fails loudly when the listing vanishes before local staging", async () => { + const listing = await createTestListing({ unitPrice: 1000 }); + using _checkout = stub( + stripePaymentProvider, + "createCheckoutSession", + async () => { + await getDb().execute("DELETE FROM listings WHERE id = ?", [ + listing.id, + ]); + return { + checkoutUrl: "https://stripe.example/vanished", + sessionId: "cs_stage_vanished", + }; + }, + ); + + await expect( + createStagedCheckout( + stripePaymentProvider, + intentFor(listing), + "https://example.com", + ), + ).rejects.toThrow(`Listing ${listing.id} vanished before checkout`); + }); + + test("stages even when the listing was deactivated mid-checkout", async () => { + const listing = await createTestListing({ unitPrice: 1000 }); + using _checkout = stub( + stripePaymentProvider, + "createCheckoutSession", + async () => { + await getDb().execute("UPDATE listings SET active = 0 WHERE id = ?", [ + listing.id, + ]); + return { + checkoutUrl: "https://stripe.example/inactive", + sessionId: "cs_stage_inactive", + }; + }, + ); + + // The preflight sees the listing still active (deactivation happens inside + // the provider stub, after the check passes), so the checkout starts. The + // quantity-0 stage then claims nothing and writes regardless; the activation + // capacity check (which requires an active listing) is the gate, and its + // refusal refunds the customer rather than crashing here. + await createStagedCheckout( + stripePaymentProvider, + intentFor(listing), + "https://example.com", + ); + expect((await getCheckoutStageOrNull("cs_stage_inactive"))?.state).toBe( + "pending", + ); + }); + + test("refuses a checkout for an already-overbooked listing", async () => { + const listing = await createTestListing({ + maxAttendees: 1, + unitPrice: 1000, + }); + // Fill the one seat, then admin-overbook a second (a supported admin state, + // added the way a manual admin add does, with the overbook flag). A new + // public checkout for its real quantity (1) cannot fit, so it is refused up + // front — before the provider session — instead of after the customer pays. + await createTestAttendeeDirect(listing.id, "First", "first@example.com"); + const overbooked = await createAttendeeAtomic({ + allowOverbook: true, + bookings: [{ listingId: listing.id, quantity: 1 }], + email: "second@example.com", + name: "Second", + source: "admin", + }); + if (!overbooked.success) throw new Error("Expected admin overbook to save"); + + await expectStagedCheckoutRefused(listing, "an unbookable order"); + }); + + test("refuses a checkout for an off-sale listing", async () => { + const listing = await createTestListing({ unitPrice: 1000 }); + // Take the listing off sale before the customer starts paying. It has room, + // but an inactive listing can't be booked, so the checkout is refused up + // front rather than after payment. + await getDb().execute("UPDATE listings SET active = 0 WHERE id = ?", [ + listing.id, + ]); + + await expectStagedCheckoutRefused(listing, "an off-sale listing"); + }); + + test("fails loudly when a staged ticket token is missing", async () => { + const listing = await createTestListing({ unitPrice: 1000 }); + using _checkout = stub(stripePaymentProvider, "createCheckoutSession", () => + Promise.resolve({ + checkoutUrl: "https://stripe.example/corrupt", + sessionId: "cs_stage_corrupt", + }), + ); + await createStagedCheckout( + stripePaymentProvider, + intentFor(listing), + "https://example.com", + ); + await getDb().execute( + "UPDATE checkout_stages SET ticket_tokens = '' WHERE payment_session_id = ?", + ["cs_stage_corrupt"], + ); + + await expect(getCheckoutStageOrNull("cs_stage_corrupt")).rejects.toThrow( + "Checkout stage cs_stage_corrupt has no token", + ); + }); + + test("round-trips booked and failed stage states", async () => { + const listing = await createTestListing({ unitPrice: 1000 }); + using _checkout = stub(stripePaymentProvider, "createCheckoutSession", () => + Promise.resolve({ + checkoutUrl: "https://stripe.example/states", + sessionId: "cs_stage_states", + }), + ); + await createStagedCheckout( + stripePaymentProvider, + intentFor(listing), + "https://example.com", + ); + + await markCheckoutStage("cs_stage_states", "booked"); + const bookedRow = await getDb().execute( + "SELECT ticket_tokens FROM checkout_stages WHERE payment_session_id = ?", + ["cs_stage_states"], + ); + expect(bookedRow.rows[0]!.ticket_tokens).toBe(""); + expect((await getCheckoutStageOrNull("cs_stage_states"))?.state).toBe( + "booked", + ); + await markCheckoutStage("cs_stage_states", "failed"); + expect((await getCheckoutStageOrNull("cs_stage_states"))?.state).toBe( + "failed", + ); + }); + + test("indexes stage state and age for pruning and pending guards", async () => { + const index = await getDb().execute( + "PRAGMA index_info(idx_checkout_stages_state_created_at)", + ); + expect(index.rows.map((row) => row.name)).toEqual(["state", "created_at"]); + }); + + test("scrubs the ticket token when healing an open stage", async () => { + const listing = await createTestListing({ unitPrice: 1000 }); + await stageCheckout("cs_stage_healed", "stripe", intentFor(listing)); + + await resolvePendingStage("cs_stage_healed"); + + const row = await getDb().execute( + "SELECT state, ticket_tokens FROM checkout_stages WHERE payment_session_id = ?", + ["cs_stage_healed"], + ); + expect(row.rows.map((value) => [value.state, value.ticket_tokens])).toEqual( + [["failed", ""]], + ); + }); + + test("discards an array of cancelled pending stages", async () => { + const listing = await createTestListing({ unitPrice: 1000 }); + const first = await stageCheckout( + "cs_stage_discard_first", + "stripe", + intentFor(listing), + ); + const second = await stageCheckout( + "cs_stage_discard_second", + "stripe", + intentFor(listing), + ); + + expect( + await discardPendingCheckoutSessions([ + "cs_stage_discard_first", + "cs_stage_discard_second", + ]), + ).toBe(2); + expect(await getCheckoutStageOrNull("cs_stage_discard_first")).toBeNull(); + expect(await getCheckoutStageOrNull("cs_stage_discard_second")).toBeNull(); + const attendees = await getDb().execute({ + args: [first.attendeeId, second.attendeeId], + sql: "SELECT id FROM attendees WHERE id IN (?, ?)", + }); + expect(attendees.rows).toEqual([]); + }); + + test("keeps a pending stage once payment processing claims it", async () => { + const listing = await createTestListing({ unitPrice: 1000 }); + await stageCheckout("cs_stage_claimed", "stripe", intentFor(listing)); + await reserveSession("cs_stage_claimed"); + + expect(await discardPendingCheckoutSessions(["cs_stage_claimed"])).toBe(0); + expect((await getCheckoutStageOrNull("cs_stage_claimed"))?.state).toBe( + "pending", + ); + }); + + test("prunes only old pending stages", async () => { + const listing = await createTestListing({ unitPrice: 1000 }); + await stageCheckout("cs_stage_old", "stripe", intentFor(listing)); + await stageCheckout("cs_stage_recent", "stripe", intentFor(listing)); + await stageCheckout("cs_stage_failed", "stripe", intentFor(listing)); + await getDb().execute( + "UPDATE checkout_stages SET created_at = ? WHERE payment_session_id = ?", + ["2000-01-01T00:00:00.000Z", "cs_stage_old"], + ); + await markCheckoutStage("cs_stage_failed", "failed"); + + expect( + await pruneCheckoutStageRows( + "2020-01-01T00:00:00.000Z", + "1990-01-01T00:00:00.000Z", + "1990-01-01T00:00:00.000Z", + ), + ).toBe(1); + expect(await getCheckoutStageOrNull("cs_stage_old")).toBeNull(); + expect((await getCheckoutStageOrNull("cs_stage_recent"))?.state).toBe( + "pending", + ); + expect((await getCheckoutStageOrNull("cs_stage_failed"))?.state).toBe( + "failed", + ); + }); + + test("discarding no sessions is a no-op", async () => { + expect(await discardPendingCheckoutSessions([])).toBe(0); + }); + + test("throws on a pending stage whose attendee was deleted", async () => { + const listing = await createTestListing({ unitPrice: 1000 }); + const stage = await stageCheckout( + "cs_stage_orphan_pending", + "stripe", + intentFor(listing), + ); + // Bypass the delete cascade (which removes the stage) to simulate a + // deletion path that missed it: the stage now points at a dead id. + await getDb().execute("DELETE FROM attendees WHERE id = ?", [ + stage.attendeeId, + ]); + + // A pending orphan is an impossible state — a pending stage is never deleted + // without its stage row (the cascade removes both; admin/listing deletes are + // blocked while pending) — so the lookup throws instead of quietly booking + // fresh around a missed cascade. + await expect( + getCheckoutStageOrNull("cs_stage_orphan_pending"), + ).rejects.toThrow("must never outlive its attendee"); + }); + + test("keeps returning a resolved stage whose attendee was deleted", async () => { + const listing = await createTestListing({ unitPrice: 1000 }); + const stage = await stageCheckout( + "cs_stage_orphan_failed", + "stripe", + intentFor(listing), + ); + await markCheckoutStage("cs_stage_orphan_failed", "failed"); + await getDb().execute("DELETE FROM attendees WHERE id = ?", [ + stage.attendeeId, + ]); + + // A resolved orphan still answers with its state, so the resolved-stage + // guard refuses to re-process money that was already handled. + expect( + (await getCheckoutStageOrNull("cs_stage_orphan_failed"))?.state, + ).toBe("failed"); + }); + + test("names attendees whose stage is pending or refunding", async () => { + const listing = await createTestListing({ unitPrice: 1000 }); + const pending = await stageCheckout( + "cs_stage_pending_ids", + "stripe", + intentFor(listing), + ); + const failed = await stageCheckout( + "cs_stage_failed_ids", + "stripe", + intentFor(listing), + ); + const refunding = await stageCheckout( + "cs_stage_refunding_ids", + "stripe", + intentFor(listing), + ); + await beginCheckoutStageRefund("cs_stage_refunding_ids"); + await markCheckoutStage("cs_stage_failed_ids", "failed"); + + // Both open states block admin mutations. A resolved stage and an unknown + // id are out. + expect( + await attendeeIdsWithPendingStage([ + pending.attendeeId, + refunding.attendeeId, + failed.attendeeId, + 999999, + ]), + ).toEqual(new Set([pending.attendeeId, refunding.attendeeId])); + expect(await attendeeIdsWithPendingStage([])).toEqual(new Set()); + }); + + test("reports a listing with a pending checkout, and clears once resolved", async () => { + const listing = await createTestListing({ unitPrice: 1000 }); + const other = await createTestListing({ unitPrice: 1000 }); + await stageCheckout( + "cs_stage_listing_pending", + "stripe", + intentFor(listing), + ); + + // The staged listing has a mid-payment booking; an unrelated listing does not. + expect(await listingHasPendingCheckout(listing.id)).toBe(true); + expect(await listingHasPendingCheckout(other.id)).toBe(false); + + await beginCheckoutStageRefund("cs_stage_listing_pending"); + expect(await listingHasPendingCheckout(listing.id)).toBe(true); + + // Resolving the stage clears the listing, so it can be deleted again. + await markCheckoutStage("cs_stage_listing_pending", "failed"); + expect(await listingHasPendingCheckout(listing.id)).toBe(false); + }); +}); diff --git a/test/shared/db/client.test.ts b/test/shared/db/client.test.ts index 453a35ef5f..1bc86b24b4 100644 --- a/test/shared/db/client.test.ts +++ b/test/shared/db/client.test.ts @@ -4,6 +4,7 @@ import { describe, it as test } from "@std/testing/bdd"; import { stub } from "@std/testing/mock"; import { registerTableInvalidation } from "#shared/cache-registry.ts"; import { + andConditions, deleteByFieldStatement, execute, executeUpdate, @@ -30,6 +31,12 @@ const emptyResultSet = (): ResultSet => ({ toJSON: () => ({}), }); +describe("andConditions", () => { + test("an empty condition list is always true", () => { + expect(andConditions([])).toEqual({ args: [], sql: "1 = 1" }); + }); +}); + describe("extractUpdateColumns", () => { test("single column assignment", () => { const cols = extractUpdateColumns( diff --git a/test/shared/db/contact-preferences.test.ts b/test/shared/db/contact-preferences.test.ts index c964ef8210..d653de57a1 100644 --- a/test/shared/db/contact-preferences.test.ts +++ b/test/shared/db/contact-preferences.test.ts @@ -16,16 +16,15 @@ import { hashPhone, isHashUnsubscribed, recordContacts, - recordVisit, resubscribeHash, saveContactRecord, toContactHashParam, - unrecordVisit, unsubscribeHash, } from "#shared/db/contact-preferences.ts"; import { settings } from "#shared/db/settings.ts"; import { getTestPrivateKey } from "#test-utils/crypto.ts"; import { describeWithEnv } from "#test-utils/db.ts"; +import { seedContactVisits } from "#test-utils/db-helpers/contacts.ts"; const rowFor = ( hash: string, @@ -138,7 +137,7 @@ describeWithEnv("contact-preferences: unsubscribe state", { db: true }, () => { const two = await hashEmail("two@example.com"); await unsubscribeHash(one); await unsubscribeHash(two); - await recordVisit(await hashEmail("seeded@example.com")); + await seedContactVisits(await hashEmail("seeded@example.com")); const set = await getUnsubscribedHashSet(); @@ -158,38 +157,18 @@ describeWithEnv("contact-preferences: unsubscribe state", { db: true }, () => { }); describeWithEnv("contact-preferences: visit counter", { db: true }, () => { - test("recordVisit seeds a row at visits 1 and sets last_activity", async () => { - const hash = await hashEmail("first@example.com"); - const before = Date.now(); - await recordVisit(hash); - const row = await rowFor(hash); - expect(row?.visits).toBe(1); - expect(row?.last_activity).toBeGreaterThanOrEqual(before); - expect(row?.last_activity).toBeLessThanOrEqual(Date.now()); - }); - - test("recordVisit increments visits once per call", async () => { - const hash = await hashEmail("repeat@example.com"); - await recordVisit(hash); - await recordVisit(hash); - await recordVisit(hash); - expect((await rowFor(hash))?.visits).toBe(3); - }); - test("getVisits reads the plaintext count, 0 when absent", async () => { const hash = await hashEmail("counted@example.com"); expect(await getVisits(hash)).toBe(0); - await recordVisit(hash); - await recordVisit(hash); + await seedContactVisits(hash, 2); expect(await getVisits(hash)).toBe(2); }); - test("recordVisit on a phone hash counts separately from email", async () => { + test("getVisits counts phone and email hashes separately", async () => { const email = await hashEmail("dual@example.com"); const phone = await hashPhone("07700 900111"); - await recordVisit(email); - await recordVisit(phone); - await recordVisit(phone); + await seedContactVisits(email); + await seedContactVisits(phone, 2); expect(await getVisits(email)).toBe(1); expect(await getVisits(phone)).toBe(2); }); @@ -199,8 +178,8 @@ describeWithEnv("contact-preferences: erasure", { db: true }, () => { test("forgetContact deletes only the targeted hash", async () => { const target = await hashEmail("forget@example.com"); const keep = await hashEmail("keep@example.com"); - await recordVisit(target); - await recordVisit(keep); + await seedContactVisits(target); + await seedContactVisits(keep); await forgetContact(target); @@ -210,7 +189,7 @@ describeWithEnv("contact-preferences: erasure", { db: true }, () => { test("forgetContact reports one deleted row when a record existed", async () => { const hash = await hashEmail("counted@example.com"); - await recordVisit(hash); + await seedContactVisits(hash); expect(await forgetContact(hash)).toBe(1); }); @@ -242,7 +221,7 @@ describeWithEnv("contact-preferences: contact history", { db: true }, () => { test("a visited address has zero contacts", async () => { const pk = await getTestPrivateKey(); const hash = await hashEmail("booked@example.com"); - await recordVisit(hash); + await seedContactVisits(hash); expect((await getContactRecord(hash, pk)).contactCount).toBe(0); }); @@ -306,8 +285,7 @@ describeWithEnv("contact-preferences: contact history", { db: true }, () => { test("recordContacts sets last_activity without touching visits", async () => { const pk = await getTestPrivateKey(); const hash = await hashEmail("outreach@example.com"); - await recordVisit(hash); - await recordVisit(hash); + await seedContactVisits(hash, 2); const before = Date.now(); await recordContacts([hash], "Newsletter", pk); const row = await rowFor(hash); @@ -330,14 +308,6 @@ describeWithEnv("contact-preferences: contact history", { db: true }, () => { await recordContacts([], "Nothing", pk); }); - test("unrecordVisit reverses a recordVisit, clamped at zero", async () => { - const hash = await hashEmail("undovisit@example.com"); - await recordVisit(hash); - await unrecordVisit(hash); - await unrecordVisit(hash); - expect(await getVisits(hash)).toBe(0); - }); - test("saveContactRecord overwrites the counts and the encrypted note", async () => { const pk = await getTestPrivateKey(); const hash = await hashEmail("notes@example.com"); diff --git a/test/shared/db/contact-token-activity.test.ts b/test/shared/db/contact-token-activity.test.ts new file mode 100644 index 0000000000..3d2e2312fa --- /dev/null +++ b/test/shared/db/contact-token-activity.test.ts @@ -0,0 +1,103 @@ +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { queryOne } from "#shared/db/client.ts"; +import { + getContactRecord, + hashEmail, + hashPhone, + recordContacts, +} from "#shared/db/contact-preferences.ts"; +import { + getRecentBookingTokens, + recordBookingActivity, + recordOrderActivity, +} from "#shared/db/contact-tokens.ts"; +import { getTestPrivateKey } from "#test-utils/crypto.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; + +const expectOneBookingVisit = async ( + hash: string, + token: string, +): Promise => { + const privateKey = await getTestPrivateKey(); + const record = await getContactRecord(hash, privateKey); + expect({ + publicBookingCount: record.publicBookingCount, + visits: record.visits, + }).toEqual({ publicBookingCount: 1, visits: 1 }); + expect(await getRecentBookingTokens(hash, privateKey, 1)).toEqual([ + { source: "public", token }, + ]); +}; + +const firstMarkerFor = async (hash: string): Promise => { + const row = await queryOne<{ attendee_tokens_blob: string }>( + "SELECT attendee_tokens_blob FROM contact_preferences WHERE contact_hash = ?", + [hash], + ); + return row!.attendee_tokens_blob.split("\n")[0]!.split("\t")[0]!; +}; + +describeWithEnv("contact booking activity", { db: true }, () => { + test("splits booking counts by source without changing outreach stats", async () => { + const privateKey = await getTestPrivateKey(); + const hash = await hashEmail("bookings@example.com"); + await recordContacts([hash], "Newsletter", privateKey); + await recordBookingActivity(hash, "public", "tok-pub-1"); + await recordBookingActivity(hash, "public", "tok-pub-2"); + await recordBookingActivity(hash, "admin", "tok-adm-1"); + + const record = await getContactRecord(hash, privateKey); + expect({ + adminBookingCount: record.adminBookingCount, + contactCount: record.contactCount, + lastSubject: record.lastSubject, + publicBookingCount: record.publicBookingCount, + }).toEqual({ + adminBookingCount: 1, + contactCount: 1, + lastSubject: "Newsletter", + publicBookingCount: 2, + }); + expect( + await getRecentBookingTokens(hash, privateKey, Number.MAX_SAFE_INTEGER), + ).toEqual([ + { source: "public", token: "tok-pub-1" }, + { source: "public", token: "tok-pub-2" }, + { source: "admin", token: "tok-adm-1" }, + ]); + }); + + test("records missing booking history without an owner key", async () => { + const hash = await hashEmail("keyless@example.com"); + await recordBookingActivity(hash, "public", "tok-keyless"); + await expectOneBookingVisit(hash, "tok-keyless"); + }); + + test("does not duplicate completed booking history", async () => { + const hash = await hashEmail("recover-complete@example.com"); + await recordBookingActivity(hash, "public", "tok-recover-complete"); + await recordBookingActivity(hash, "public", "tok-recover-complete"); + await expectOneBookingVisit(hash, "tok-recover-complete"); + }); + + test("recordOrderActivity records one replay-safe visit for email and phone", async () => { + const emailHash = await hashEmail("linked-token@example.com"); + const phoneHash = await hashPhone("07700 900111"); + const record = () => + recordOrderActivity( + "linked-token@example.com", + "07700 900111", + "public", + "tok-linked-contact", + ); + await record(); + await record(); + + expect(await firstMarkerFor(emailHash)).not.toBe( + await firstMarkerFor(phoneHash), + ); + await expectOneBookingVisit(emailHash, "tok-linked-contact"); + await expectOneBookingVisit(phoneHash, "tok-linked-contact"); + }); +}); diff --git a/test/shared/db/contact-tokens.test.ts b/test/shared/db/contact-tokens.test.ts index 7a5a6b2989..bdcc9aa51a 100644 --- a/test/shared/db/contact-tokens.test.ts +++ b/test/shared/db/contact-tokens.test.ts @@ -6,13 +6,11 @@ import { getContactRecord, hashEmail, hashPhone, - recordContacts, } from "#shared/db/contact-preferences.ts"; import { getRecentBookingTokens, - recordBooking, + recordBookingActivity, syncAttendeeContactTokens, - unrecordBooking, } from "#shared/db/contact-tokens.ts"; import { getTestPrivateKey } from "#test-utils/crypto.ts"; import { describeWithEnv } from "#test-utils/db.ts"; @@ -48,73 +46,7 @@ const tokenBlobFor = async (hash: string): Promise => ) )?.attendee_tokens_blob ?? ""; -const firstMarkerFrom = (blob: string): string => - blob.split("\n")[0]!.split("\t")[0]!; - describeWithEnv("contact-tokens", { db: true }, () => { - test("recordBooking splits the count by source, leaving outreach stats intact", async () => { - const pk = await getTestPrivateKey(); - const hash = await hashEmail("bookings@example.com"); - await recordContacts([hash], "Newsletter", pk); - await recordBooking(hash, "public", "tok-pub-1"); - await recordBooking(hash, "public", "tok-pub-2"); - await recordBooking(hash, "admin", "tok-adm-1"); - - const record = await getContactRecord(hash, pk); - expect(record.publicBookingCount).toBe(2); - expect(record.adminBookingCount).toBe(1); - expect(record.contactCount).toBe(1); - expect(record.lastSubject).toBe("Newsletter"); - }); - - test("recordBooking needs no owner key", async () => { - const pk = await getTestPrivateKey(); - const hash = await hashEmail("keyless@example.com"); - await recordBooking(hash, "public", "tok-keyless"); - expect((await getContactRecord(hash, pk)).publicBookingCount).toBe(1); - }); - - test("unrecordBooking reverses a recordBooking and clamps at zero", async () => { - const pk = await getTestPrivateKey(); - const hash = await hashEmail("undo@example.com"); - await recordBooking(hash, "public", "tok-undo-1"); - await recordBooking(hash, "public", "tok-undo-2"); - await unrecordBooking(hash, "public"); - expect((await getContactRecord(hash, pk)).publicBookingCount).toBe(1); - await unrecordBooking(hash, "public"); - await unrecordBooking(hash, "public"); - expect((await getContactRecord(hash, pk)).publicBookingCount).toBe(0); - }); - - test("recordBooking appends the booked token, tagged by source", async () => { - const pk = await getTestPrivateKey(); - const hash = await hashEmail("tokens@example.com"); - await recordBooking(hash, "public", "tok-online"); - await recordBooking(hash, "admin", "tok-manual"); - expect(await readAllTokens(hash, pk)).toEqual([ - { source: "public", token: "tok-online" }, - { source: "admin", token: "tok-manual" }, - ]); - }); - - test("recordBooking does not reuse one marker across contact rows", async () => { - const pk = await getTestPrivateKey(); - const emailHash = await hashEmail("linked-token@example.com"); - const phoneHash = await hashPhone("07700 900111"); - await recordBooking(emailHash, "public", "tok-linked-contact"); - await recordBooking(phoneHash, "public", "tok-linked-contact"); - - expect(firstMarkerFrom(await tokenBlobFor(emailHash))).not.toBe( - firstMarkerFrom(await tokenBlobFor(phoneHash)), - ); - expect(await readAllTokens(emailHash, pk)).toEqual([ - { source: "public", token: "tok-linked-contact" }, - ]); - expect(await readAllTokens(phoneHash, pk)).toEqual([ - { source: "public", token: "tok-linked-contact" }, - ]); - }); - test("getRecentBookingTokens is empty for a contact with no bookings", async () => { const pk = await getTestPrivateKey(); expect( @@ -129,8 +61,8 @@ describeWithEnv("contact-tokens", { db: true }, () => { "INSERT INTO contact_preferences (contact_hash, last_activity, attendee_tokens_blob) VALUES (?, ?, ?)", [hash, 1, "not-an-owner-key-token\n"], ); - await recordBooking(hash, "public", "tok-newer"); - await recordBooking(hash, "admin", "tok-newest"); + await recordBookingActivity(hash, "public", "tok-newer"); + await recordBookingActivity(hash, "admin", "tok-newest"); expect(await getRecentBookingTokens(hash, pk, 1)).toEqual([ { source: "admin", token: "tok-newest" }, @@ -151,7 +83,7 @@ describeWithEnv("contact-tokens", { db: true }, () => { test("syncAttendeeContactTokens appends without bumping counts", async () => { const pk = await getTestPrivateKey(); const hash = await hashEmail("readd@example.com"); - await recordBooking(hash, "public", "tok-first"); + await recordBookingActivity(hash, "public", "tok-first"); await syncToken( "tok-moved", { email: "readd@example.com", phone: "" }, @@ -171,7 +103,7 @@ describeWithEnv("contact-tokens", { db: true }, () => { const pk = await getTestPrivateKey(); const oldHash = await hashEmail("old@example.com"); const newHash = await hashEmail("new@example.com"); - await recordBooking(oldHash, "admin", "tok-move"); + await recordBookingActivity(oldHash, "admin", "tok-move"); await syncToken( "tok-move", { email: "old@example.com", phone: "" }, @@ -194,7 +126,7 @@ describeWithEnv("contact-tokens", { db: true }, () => { "INSERT INTO contact_preferences (contact_hash, last_activity, attendee_tokens_blob) VALUES (?, ?, ?)", [oldHash, 1, "not-an-owner-key-token\n"], ); - await recordBooking(oldHash, "public", "tok-marker-move"); + await recordBookingActivity(oldHash, "public", "tok-marker-move"); await syncToken( "tok-marker-move", @@ -213,7 +145,7 @@ describeWithEnv("contact-tokens", { db: true }, () => { const pk = await getTestPrivateKey(); const oldHash = await hashPhone("07700 900001"); const newHash = await hashPhone("07700 900002"); - await recordBooking(oldHash, "public", "tok-phone"); + await recordBookingActivity(oldHash, "public", "tok-phone"); await syncToken( "tok-phone", { email: "", phone: "07700 900001" }, @@ -229,7 +161,7 @@ describeWithEnv("contact-tokens", { db: true }, () => { test("syncAttendeeContactTokens does not duplicate an unchanged token", async () => { const pk = await getTestPrivateKey(); const hash = await hashEmail("same@example.com"); - await recordBooking(hash, "public", "tok-same"); + await recordBookingActivity(hash, "public", "tok-same"); await syncToken( "tok-same", { email: "same@example.com", phone: "" }, @@ -244,8 +176,8 @@ describeWithEnv("contact-tokens", { db: true }, () => { test("syncAttendeeContactTokens does not reorder history on an unchanged edit", async () => { const pk = await getTestPrivateKey(); const hash = await hashEmail("order@example.com"); - await recordBooking(hash, "public", "tok-first"); - await recordBooking(hash, "admin", "tok-second"); + await recordBookingActivity(hash, "public", "tok-first"); + await recordBookingActivity(hash, "admin", "tok-second"); // Re-sync tok-first with the contact unchanged (before === after): the // token must stay in place, not be removed-and-re-appended to the end. await syncToken( @@ -264,7 +196,7 @@ describeWithEnv("contact-tokens", { db: true }, () => { const pk = await getTestPrivateKey(); const email = "erased-token@example.com"; const hash = await hashEmail(email); - await recordBooking(hash, "public", "tok-erased"); + await recordBookingActivity(hash, "public", "tok-erased"); expect(await forgetContact(hash)).toBe(1); await syncToken( @@ -285,7 +217,7 @@ describeWithEnv("contact-tokens", { db: true }, () => { test("syncAttendeeContactTokens drops the link when a field is cleared", async () => { const pk = await getTestPrivateKey(); const oldHash = await hashEmail("cleared@example.com"); - await recordBooking(oldHash, "admin", "tok-clear"); + await recordBookingActivity(oldHash, "admin", "tok-clear"); await syncToken( "tok-clear", { email: "cleared@example.com", phone: "" }, @@ -341,8 +273,8 @@ describeWithEnv("contact-tokens", { db: true }, () => { const pk = await getTestPrivateKey(); const oldHash = await hashEmail("race-old@example.com"); const newHash = await hashEmail("race-new@example.com"); - await recordBooking(oldHash, "public", "tok-move"); - await recordBooking(oldHash, "admin", "tok-stay"); + await recordBookingActivity(oldHash, "public", "tok-move"); + await recordBookingActivity(oldHash, "admin", "tok-stay"); await syncToken( "tok-move", { email: "race-old@example.com", phone: "" }, @@ -392,7 +324,7 @@ describeWithEnv("contact-tokens", { db: true }, () => { test("removing the last moved token leaves an empty blob, not a stale newline", async () => { const pk = await getTestPrivateKey(); const hash = await hashEmail("blank-line@example.com"); - await recordBooking(hash, "public", "tok-only"); + await recordBookingActivity(hash, "public", "tok-only"); await syncToken( "tok-only", { email: "blank-line@example.com", phone: "" }, diff --git a/test/shared/db/listing-overview-stats.test.ts b/test/shared/db/listing-overview-stats.test.ts index 87916fe3ed..7d392a84b5 100644 --- a/test/shared/db/listing-overview-stats.test.ts +++ b/test/shared/db/listing-overview-stats.test.ts @@ -11,10 +11,7 @@ import { execute } from "#shared/db/client.ts"; import { getListingOverviewStats } from "#shared/db/listing-overview-stats.ts"; import { getAttendeesByListingIds } from "#shared/db/listings/attendees.ts"; import { getListingWithCount } from "#shared/db/listings/records.ts"; -import { - finalizeSession, - reserveSession, -} from "#shared/db/processed-payments.ts"; +import { reserveSession } from "#shared/db/processed-payments.ts"; import { isPaidListing } from "#shared/types.ts"; import { overviewStatsFromAttendees, @@ -27,6 +24,7 @@ import { createPaidTestAttendee, } from "#test-utils/db-helpers/attendee-payments.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { finalizeTestPaymentSession as finalizeSession } from "#test-utils/db-helpers/processed-payments.ts"; import { postListingSale, postWriteoffAdjustment } from "#test-utils/ledger.ts"; const checkIn = (attendeeId: number, listingId: number): Promise => diff --git a/test/shared/db/listings/delete.test.ts b/test/shared/db/listings/delete.test.ts index 47f201b08b..5fe0fb03a0 100644 --- a/test/shared/db/listings/delete.test.ts +++ b/test/shared/db/listings/delete.test.ts @@ -10,6 +10,10 @@ import { getAttendeeRaw, getAttendeesRaw, } from "#shared/db/attendees/queries.ts"; +import { + markCheckoutStage, + stageCheckout, +} from "#shared/db/checkout-stages.ts"; import { queryAll } from "#shared/db/client.ts"; import { deleteListing } from "#shared/db/listings/delete.ts"; import { @@ -17,7 +21,6 @@ import { listingsTable, } from "#shared/db/listings/records.ts"; import { - finalizeSession as finalizePaymentSession, isSessionProcessed, reserveSession, } from "#shared/db/processed-payments.ts"; @@ -25,6 +28,7 @@ import { getAttendeeAnswersBatch } from "#shared/db/questions/attendee-answers/r import { saveAttendeeAnswers } from "#shared/db/questions/attendee-answers/save.ts"; import { listingQuestions } from "#shared/db/questions/queries.ts"; import { answersTable, questionsTable } from "#shared/db/questions/tables.ts"; +import { checkoutIntent, checkoutItem } from "#test-utils/checkout.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { createTestAttendee, @@ -35,6 +39,7 @@ import { createTestAttributeWithOptions, } from "#test-utils/db-helpers/attributes.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { finalizeTestPaymentSession as finalizePaymentSession } from "#test-utils/db-helpers/processed-payments.ts"; import { withTestSession } from "#test-utils/session.ts"; describeWithEnv("db > listings", { db: true, triggers: true }, () => { @@ -254,6 +259,40 @@ describeWithEnv("db > listings", { db: true, triggers: true }, () => { expect(orphan!.listing_id).toBe(0); }); + test("keeps a pending checkout's rows, but cascades a resolved one's", async () => { + const listing = await createTestListing({ maxAttendees: 50 }); + const intent = checkoutIntent({ + items: [ + checkoutItem({ + listingId: listing.id, + name: listing.name, + slug: listing.slug, + }), + ], + }); + // Two checkouts staged onto the listing about to be deleted: one still + // mid-payment (pending) and one that already resolved (booked). + const pending = await stageCheckout("sess_del_pending", "stripe", intent); + const booked = await stageCheckout("sess_del_booked", "stripe", intent); + await markCheckoutStage("sess_del_booked", "booked"); + + await deleteListing(listing.id); + + // The delete guard is a preflight, so a stage that lands in its race window + // must not lose its rows — otherwise the paid order strands on an empty + // record. The pending checkout's quantity-0 row survives; the resolved + // one's is cascaded like any ordinary booking. + const rowCount = async (attendeeId: number): Promise => + ( + await queryAll<{ id: number }>( + "SELECT id FROM listing_attendees WHERE attendee_id = ?", + [attendeeId], + ) + ).length; + expect(await rowCount(pending.attendeeId)).toBe(1); + expect(await rowCount(booked.attendeeId)).toBe(0); + }); + test("invalidates cache", async () => { const listing = await createTestListing({ maxAttendees: 50, diff --git a/test/shared/db/migrations/trigger-dependencies.test.ts b/test/shared/db/migrations/trigger-dependencies.test.ts new file mode 100644 index 0000000000..ff5f19534f --- /dev/null +++ b/test/shared/db/migrations/trigger-dependencies.test.ts @@ -0,0 +1,26 @@ +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { getDb } from "#shared/db/client.ts"; +import { CHECKOUT_STAGE_TRIGGERS } from "#shared/db/migrations/schema/checkout-stage-triggers.ts"; +import { syncTriggers } from "#shared/db/migrations/schema-sync.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; + +describeWithEnv("db > trigger dependencies", { db: true }, () => { + test("does not install triggers before every dependent table exists", async () => { + for (const trigger of CHECKOUT_STAGE_TRIGGERS) { + await getDb().execute(`DROP TRIGGER IF EXISTS ${trigger.name}`); + } + await getDb().execute("DROP TABLE checkout_stage_revisions"); + + await syncTriggers(); + + const rows = await getDb().execute( + "SELECT name FROM sqlite_master WHERE type = 'trigger'", + ); + const installed = new Set(rows.rows.map((row) => String(row.name))); + expect(installed.has("trg_checkout_stages_revision_insert")).toBe(false); + expect( + installed.has("trg_processed_payments_checkout_stage_claim_insert"), + ).toBe(true); + }); +}); diff --git a/test/shared/db/modifier-resolve.test.ts b/test/shared/db/modifier-resolve.test.ts index 0cfe33031b..37f1dbbd14 100644 --- a/test/shared/db/modifier-resolve.test.ts +++ b/test/shared/db/modifier-resolve.test.ts @@ -2,11 +2,7 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; import { hmacHash } from "#shared/crypto/hashing.ts"; import { toMinorUnits } from "#shared/currency.ts"; -import { - hashEmail, - hashPhone, - recordVisit, -} from "#shared/db/contact-preferences.ts"; +import { hashEmail, hashPhone } from "#shared/db/contact-preferences.ts"; import { ADDON_MAX_QUANTITY, type AddOnReachabilityCheck, @@ -27,6 +23,7 @@ import { answersTable, questionsTable } from "#shared/db/questions/tables.ts"; import { normalizeCode } from "#shared/price-modifier.ts"; import { checkoutItem } from "#test-utils/checkout.ts"; import { describeWithEnv } from "#test-utils/db.ts"; +import { seedContactVisits } from "#test-utils/db-helpers/contacts.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; import { consumeModifierStock, @@ -519,9 +516,8 @@ describeWithEnv("db > modifier-resolve", { db: true }, () => { }); test("reads the max visit count across email and phone", async () => { - await recordVisit(await hashEmail("seen@example.com")); - await recordVisit(await hashPhone("07700 900123")); - await recordVisit(await hashPhone("07700 900123")); + await seedContactVisits(await hashEmail("seen@example.com")); + await seedContactVisits(await hashPhone("07700 900123"), 2); expect(await buyerVisits("seen@example.com", "07700 900123")).toBe(2); }); diff --git a/test/shared/db/modifier-usage.test.ts b/test/shared/db/modifier-usage.test.ts index 0d6a808d0b..967fb02b96 100644 --- a/test/shared/db/modifier-usage.test.ts +++ b/test/shared/db/modifier-usage.test.ts @@ -1,6 +1,10 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; -import { modifierUsedQuantities } from "#shared/db/modifier-usage.ts"; +import { + anyModifierSoldOut, + modifierUsedQuantities, + usageBatchInsert, +} from "#shared/db/modifier-usage.ts"; import { modifiersTable } from "#shared/db/modifiers.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { @@ -24,6 +28,12 @@ const usage = (modifierId: number, quantity = 1) => ({ }); describeWithEnv("db > modifier-usage", { db: true }, () => { + test("refuses to build a batch insert without modifier uses", () => { + expect(() => usageBatchInsert([], 1)).toThrow( + "Cannot build a modifier usage insert with no rows", + ); + }); + describe("consumeModifierStock", () => { test("returns true for no usages", async () => { expect(await consumeModifierStock(1, [])).toBe(true); @@ -68,4 +78,30 @@ describeWithEnv("db > modifier-usage", { db: true }, () => { expect(await modifierUsedQuantities([])).toEqual(new Map()); }); }); + + describe("anyModifierSoldOut", () => { + test("returns false for no usages", async () => { + expect(await anyModifierSoldOut([])).toBe(false); + }); + + test("returns false for an unlimited modifier", async () => { + const modifier = await makeModifier(null); + expect(await anyModifierSoldOut([usage(modifier.id)])).toBe(false); + }); + + test("returns false for an unknown modifier", async () => { + expect(await anyModifierSoldOut([usage(999_999)])).toBe(false); + }); + + test("returns false while limited stock remains", async () => { + const modifier = await makeModifier(1); + expect(await anyModifierSoldOut([usage(modifier.id)])).toBe(false); + }); + + test("detects exhausted stock", async () => { + const modifier = await makeModifier(1); + await consumeModifierStock(100, [usage(modifier.id)]); + expect(await anyModifierSoldOut([usage(modifier.id)])).toBe(true); + }); + }); }); diff --git a/test/shared/db/orphan-attendees.test.ts b/test/shared/db/orphan-attendees.test.ts index 42aaa978a8..e9701c7695 100644 --- a/test/shared/db/orphan-attendees.test.ts +++ b/test/shared/db/orphan-attendees.test.ts @@ -142,12 +142,25 @@ describeWithEnv("db > orphan-attendees", { db: true }, () => { processed_at: nowIso(), }), ); + // A stage left behind when the orphan's listing was deleted mid-checkout: + // the purge must cascade it too, so no dangling stage row survives. + await getDb().execute( + insert("checkout_stages", { + attendee_id: id, + created_at: nowIso(), + payment_session_id: `cs-orphan-${id}`, + provider: "stripe", + state: "pending", + ticket_tokens: "", + }), + ); await createSystemNote(id, "orphan note"); await purgeOrphanedAttendees(nowIso()); expect(await childCount("attendee_answers", id)).toBe(0); expect(await childCount("processed_payments", id)).toBe(0); + expect(await childCount("checkout_stages", id)).toBe(0); expect(await getNoteRows([id])).toEqual([]); }); }); diff --git a/test/shared/db/payment-references.test.ts b/test/shared/db/payment-references.test.ts index 64688da011..7afe89dd8e 100644 --- a/test/shared/db/payment-references.test.ts +++ b/test/shared/db/payment-references.test.ts @@ -10,14 +10,12 @@ import { legacyMergePaymentReferenceStatement, markPaymentReferencesProviderRefunded, } from "#shared/db/payment-references.ts"; -import { - finalizeSession as finalizePaymentSession, - reserveSession, -} from "#shared/db/processed-payments.ts"; +import { reserveSession } from "#shared/db/processed-payments.ts"; import { getTestPrivateKey } from "#test-utils/crypto.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { bookAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { finalizeTestPaymentSession as finalizePaymentSession } from "#test-utils/db-helpers/processed-payments.ts"; describeWithEnv("db > payment references", { db: true }, () => { test("encrypts non-empty references and leaves empty references empty", async () => { diff --git a/test/shared/db/processed-payments.test.ts b/test/shared/db/processed-payments.test.ts index 2c09efe83a..e207d0bfb6 100644 --- a/test/shared/db/processed-payments.test.ts +++ b/test/shared/db/processed-payments.test.ts @@ -1,23 +1,24 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; +import { encrypt } from "#shared/crypto/encryption.ts"; import type { EnvKeyEncrypted } from "#shared/crypto/sealed.ts"; import { getDb, insert } from "#shared/db/client.ts"; import { batchFinalizeStatement } from "#shared/db/payment-finalize.ts"; import { decryptSessionTokens, - finalizeSession as finalizePaymentSession, finalizeSessionIfUnresolved, isSessionProcessed, + isUnresolvedReservation, markSessionFailed, parseSessionFailure, reserveSession, STALE_RESERVATION_MS, - setSessionTicketTokens, } from "#shared/db/processed-payments.ts"; import { nowMs } from "#shared/now.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { bookAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { finalizeTestPaymentSession as finalizePaymentSession } from "#test-utils/db-helpers/processed-payments.ts"; import { expectRefundReferences } from "#test-utils/payment-references.ts"; const finalizeSession = ( @@ -186,29 +187,33 @@ describeWithEnv("db > processed payments", { db: true }, () => { }); test("re-throws non-unique-constraint errors", async () => { - await getDb().execute("DROP TABLE processed_payments"); - - try { - await reserveSession("sess_error"); - throw new Error("should not reach here"); - } catch (e) { - expect(String(e)).not.toContain("should not reach here"); - expect(String(e)).not.toContain("UNIQUE constraint"); - } - - // Recreate the table for subsequent tests await getDb().execute(` - CREATE TABLE IF NOT EXISTS processed_payments ( - payment_session_id TEXT PRIMARY KEY, - attendee_id INTEGER, - processed_at TEXT NOT NULL, - ticket_tokens TEXT NOT NULL DEFAULT '', - failure_data TEXT NOT NULL DEFAULT '', - payment_reference TEXT NOT NULL DEFAULT '', - provider_refunded_at TEXT NOT NULL DEFAULT '', - FOREIGN KEY (attendee_id) REFERENCES attendees(id) - ) + CREATE TRIGGER reject_processed_payment_insert + BEFORE INSERT ON processed_payments + BEGIN + SELECT RAISE(ABORT, 'synthetic insert failure'); + END `); + + await expect(reserveSession("sess_error")).rejects.toThrow( + "synthetic insert failure", + ); + }); + + test("recognizes only attendee-less, outcome-less reservations as unresolved", async () => { + await reserveSession("sess_unresolved_shape"); + const reserved = (await isSessionProcessed("sess_unresolved_shape"))!; + + expect(isUnresolvedReservation(reserved)).toBe(true); + expect(isUnresolvedReservation({ ...reserved, attendee_id: 1 })).toBe( + false, + ); + expect( + isUnresolvedReservation({ + ...reserved, + failure_data: "recorded" as EnvKeyEncrypted, + }), + ).toBe(false); }); }); @@ -217,8 +222,7 @@ describeWithEnv("db > processed payments", { db: true }, () => { // unit test binds it as a literal `?` and uses a trivially-true guard, so it // exercises the UNRESOLVED + guard gating without an in-batch attendee row. const trueGuard = { args: [] as never[], sql: "1 = 1" }; - - test("sets attendee_id and clears ticket_tokens on an unresolved reservation", async () => { + test("sets the attendee and encrypted ticket token on an unresolved reservation", async () => { const listing = await createTestListing({ maxAttendees: 50 }); const attendeeResult = await bookAttendee(listing, { email: "fss@example.com", @@ -228,12 +232,17 @@ describeWithEnv("db > processed payments", { db: true }, () => { const attendeeId = attendeeResult.attendees[0]!.id; await reserveSession("sess_fss"); + await getDb().execute( + "UPDATE processed_payments SET ticket_tokens = ? WHERE payment_session_id = ?", + [await encrypt("tok-replacement"), "sess_fss"], + ); const stmt = await batchFinalizeStatement( "sess_fss", "?", attendeeId, trueGuard, "pi_fss", + "tok-fss", ); await getDb().execute(stmt); @@ -241,7 +250,8 @@ describeWithEnv("db > processed payments", { db: true }, () => { expect(row!.attendee_id).toBe(attendeeId); expect(row!.payment_reference).not.toContain("pi_fss"); await expectRefundReferences(attendeeId, ["pi_fss"]); - expect(row!.ticket_tokens).toBe(""); + expect(row!.ticket_tokens).not.toContain("tok-fss"); + expect(await decryptSessionTokens(row!.ticket_tokens)).toBe("tok-fss"); }); test("is a no-op when the session is already finalized", async () => { @@ -263,6 +273,7 @@ describeWithEnv("db > processed payments", { db: true }, () => { attendeeId + 999, trueGuard, "pi_second", + "tok-second", ); await getDb().execute(stmt); @@ -292,6 +303,7 @@ describeWithEnv("db > processed payments", { db: true }, () => { sql: "1 = 0", }, "pi_fss3", + "tok-fss3", ); await getDb().execute(stmt); @@ -301,42 +313,29 @@ describeWithEnv("db > processed payments", { db: true }, () => { }); }); - describe("setSessionTicketTokens", () => { - test("stores encrypted ticket tokens on a finalized session", async () => { - const listing = await createTestListing({ maxAttendees: 50 }); - const attendeeResult = await bookAttendee(listing, { - email: "stt@example.com", - name: "Stt", - }); - if (!attendeeResult.success) throw new Error("setup failed"); - const attendeeId = attendeeResult.attendees[0]!.id; - - await reserveSession("sess_stt"); - await finalizeSession("sess_stt", attendeeId, ["tok-test"]); - await setSessionTicketTokens("sess_stt", ["tok-abc"]); - - const row = await isSessionProcessed("sess_stt"); - // ticket_tokens is stored encrypted, not as plaintext - expect(row!.ticket_tokens).not.toBe(""); - expect(row!.ticket_tokens).not.toContain("tok-abc"); - }); - - test("is a no-op if the session was pruned", async () => { - // Should not throw even when the session row is absent - await setSessionTicketTokens("sess_nonexistent", ["tok-abc"]); - }); - }); - describe("finalizeSessionIfUnresolved", () => { test("stamps attendee_id on an unresolved reservation, leaving tokens untouched", async () => { await reserveSession("sess_heal"); - await finalizeSessionIfUnresolved("sess_heal", 42); + await finalizeSessionIfUnresolved("sess_heal", 42, ""); const row = (await isSessionProcessed("sess_heal"))!; expect(row.attendee_id).toBe(42); // The ledger-replay heal never writes ticket_tokens. expect(row.ticket_tokens).toBe(""); + expect(row.payment_reference).toBe(""); + }); + + test("stores a supplied payment reference on the healed reservation", async () => { + await reserveSession("sess_heal_reference"); + + await finalizeSessionIfUnresolved( + "sess_heal_reference", + 42, + "pi_heal_reference", + ); + + await expectRefundReferences(42, ["pi_heal_reference"]); }); test("is a no-op once resolved — preserves a racing delivery's attendee and tokens", async () => { @@ -347,7 +346,7 @@ describeWithEnv("db > processed payments", { db: true }, () => { // The replaying delivery tries to heal it to a different attendee; the // unresolved guard must make it a no-op so it never clobbers the winner's // ticket_tokens (which would render the success page without the ticket). - await finalizeSessionIfUnresolved("sess_raced", 99); + await finalizeSessionIfUnresolved("sess_raced", 99, ""); const row = (await isSessionProcessed("sess_raced"))!; expect(row.attendee_id).toBe(7); @@ -355,7 +354,11 @@ describeWithEnv("db > processed payments", { db: true }, () => { }); test("is a no-op if the session was pruned", async () => { - await finalizeSessionIfUnresolved("sess_gone", 1); + await finalizeSessionIfUnresolved("sess_gone", 1, ""); }); }); + + test("decryptSessionTokens returns an empty string for an empty field", async () => { + expect(await decryptSessionTokens("")).toBe(""); + }); }); diff --git a/test/shared/db/processed-payments/staleness.test.ts b/test/shared/db/processed-payments/staleness.test.ts index cae0641a44..feeb2b02b3 100644 --- a/test/shared/db/processed-payments/staleness.test.ts +++ b/test/shared/db/processed-payments/staleness.test.ts @@ -1,9 +1,9 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; +import { FakeTime } from "@std/testing/time"; import { getDb, insert } from "#shared/db/client.ts"; import { deleteAllStaleReservations, - finalizeSession as finalizePaymentSession, isReservationStale, isSessionProcessed, releaseReservation, @@ -12,6 +12,7 @@ import { } from "#shared/db/processed-payments.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { useProcessedPaymentsAttendee } from "#test-utils/db-helpers/attendee-payments.ts"; +import { finalizeTestPaymentSession as finalizePaymentSession } from "#test-utils/db-helpers/processed-payments.ts"; const finalizeSession = ( sessionId: string, @@ -27,23 +28,23 @@ const finalizeSession = ( describeWithEnv("processed-payments / staleness", { db: true }, () => { const ctx = useProcessedPaymentsAttendee(); + const now = 2_000_000_000_000; describe("isReservationStale", () => { test("returns false for a recent timestamp", () => { - expect(isReservationStale(new Date().toISOString())).toBe(false); + using _time = new FakeTime(now); + expect(isReservationStale(new Date(now).toISOString())).toBe(false); }); - test("returns false for a timestamp just under the threshold", () => { - const justUnder = new Date( - Date.now() - STALE_RESERVATION_MS + 1000, - ).toISOString(); - expect(isReservationStale(justUnder)).toBe(false); + test("returns false at the threshold", () => { + using _time = new FakeTime(now); + const threshold = new Date(now - STALE_RESERVATION_MS).toISOString(); + expect(isReservationStale(threshold)).toBe(false); }); test("returns true for a timestamp over the threshold", () => { - const stale = new Date( - Date.now() - STALE_RESERVATION_MS - 1000, - ).toISOString(); + using _time = new FakeTime(now); + const stale = new Date(now - STALE_RESERVATION_MS - 1).toISOString(); expect(isReservationStale(stale)).toBe(true); }); }); diff --git a/test/shared/db/prune/helpers.ts b/test/shared/db/prune/helpers.ts index e189d16b4c..e2981ab5e1 100644 --- a/test/shared/db/prune/helpers.ts +++ b/test/shared/db/prune/helpers.ts @@ -28,13 +28,14 @@ export const insertUnfinalizedPayment = async ( sessionId: string, processedAtIso: string, ): Promise => { - await getDb().execute( - insert("processed_payments", { - attendee_id: null, - payment_session_id: sessionId, - processed_at: processedAtIso, - }), - ); + await getDb().execute({ + args: [sessionId, sessionId, processedAtIso], + sql: `INSERT INTO processed_payments + (payment_session_id, attendee_id, checkout_stage_attendee_id, processed_at) + VALUES (?, NULL, ( + SELECT attendee_id FROM checkout_stages WHERE payment_session_id = ? + ), ?)`, + }); }; export const insertFailedPayment = async ( @@ -59,6 +60,16 @@ export const paymentExists = async (sessionId: string): Promise => { return rows.length > 0; }; +export const checkoutStageExists = async ( + sessionId: string, +): Promise => { + const { rows } = await getDb().execute({ + args: [sessionId], + sql: "SELECT 1 FROM checkout_stages WHERE payment_session_id = ?", + }); + return rows.length > 0; +}; + export const postRefundCash = async (attendeeId: number): Promise => { await postTransfers([ { @@ -87,6 +98,24 @@ export const insertSumupCheckout = async ( ); }; +export const insertPendingCheckoutStage = async ( + sessionId: string, + createdAtIso: string, +): Promise => { + const attendeeId = await insertOrphanAttendee(createdAtIso); + await getDb().execute( + insert("checkout_stages", { + attendee_id: attendeeId, + created_at: createdAtIso, + payment_session_id: sessionId, + provider: "stripe", + state: "pending", + ticket_tokens: "ciphertext", + }), + ); + return attendeeId; +}; + export const sumupCheckoutExists = async ( referenceIndex: string, ): Promise => { @@ -210,6 +239,7 @@ export const oldOrphanIso = (): string => /** Every last-pruned stamp the scheduler tracks, one setter per table. */ const LAST_PRUNED_SETTERS = [ settings.update.lastPrunedPayments, + settings.update.lastPrunedCheckoutStages, settings.update.lastPrunedSessions, settings.update.lastPrunedLogins, settings.update.lastPrunedTokens, diff --git a/test/shared/db/prune/scheduler.test.ts b/test/shared/db/prune/scheduler.test.ts index 10766568de..d031228de3 100644 --- a/test/shared/db/prune/scheduler.test.ts +++ b/test/shared/db/prune/scheduler.test.ts @@ -56,6 +56,13 @@ describeWithEnv("db > prune scheduler", { db: true }, () => { ); }); + test("records fresh checkout-stage timestamp after running", async () => { + await clearAllLastPruned(); + await expectFreshPrunedTimestampAfterRun( + () => settings.lastPrunedCheckoutStages, + ); + }); + test("records fresh sessions timestamp after running", async () => { await clearAllLastPruned(); await expectFreshPrunedTimestampAfterRun( diff --git a/test/shared/db/prune/tables.test.ts b/test/shared/db/prune/tables.test.ts index 80eb396bc3..0520415a52 100644 --- a/test/shared/db/prune/tables.test.ts +++ b/test/shared/db/prune/tables.test.ts @@ -1,6 +1,8 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; +import { getDb } from "#shared/db/client.ts"; import { + pruneCheckoutStages, pruneContacts, pruneLoginAttempts, pruneOrphanAttendees, @@ -12,32 +14,131 @@ import { import { createSession, getAllSessions } from "#shared/db/sessions.ts"; import { settings } from "#shared/db/settings.ts"; import { + PRUNE_CHECKOUT_STAGES_RETENTION_MS, PRUNE_CONTACTS_RETENTION_MS, PRUNE_LOGINS_RETENTION_MS, + PRUNE_PAYMENTS_RETENTION_MS, PRUNE_SESSIONS_RETENTION_MS, PRUNE_SUMUP_RETENTION_MS, PRUNE_TOKENS_RETENTION_MS, PRUNE_UNUSED_STRINGS_RETENTION_MS, + STALE_RESERVATION_MS, } from "#shared/limits.ts"; import { nowMs } from "#shared/now.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { attendeeExists, + checkoutStageExists, contactPreferenceExists, insertContactPreference, insertLoginAttempt, insertOrphanAttendee, + insertPendingCheckoutStage, insertString, insertSumupCheckout, insertTokenAttempt, + insertUnfinalizedPayment, loginAttemptExists, oldOrphanIso, + paymentExists, stringExists, sumupCheckoutExists, tokenAttemptExists, } from "./helpers.ts"; describeWithEnv("db > table pruning", { db: true }, () => { + describe("pruneCheckoutStages", () => { + test("deletes pending checkout PII older than retention", async () => { + const old = new Date( + nowMs() - PRUNE_CHECKOUT_STAGES_RETENTION_MS - 60_000, + ).toISOString(); + const attendeeId = await insertPendingCheckoutStage("stage_old", old); + + expect(await pruneCheckoutStages()).toBe(1); + expect(await attendeeExists(attendeeId)).toBe(false); + }); + + test("keeps pending checkout PII within retention", async () => { + const attendeeId = await insertPendingCheckoutStage( + "stage_recent", + new Date(nowMs() - 1000).toISOString(), + ); + + expect(await pruneCheckoutStages()).toBe(0); + expect(await attendeeExists(attendeeId)).toBe(true); + }); + + test("deletes an old stage after clearing its stale payment claim", async () => { + const oldStage = new Date( + nowMs() - PRUNE_CHECKOUT_STAGES_RETENTION_MS - 60_000, + ).toISOString(); + const staleClaim = new Date( + nowMs() - STALE_RESERVATION_MS - 60_000, + ).toISOString(); + const attendeeId = await insertPendingCheckoutStage( + "stage_stale_claim", + oldStage, + ); + await insertUnfinalizedPayment("stage_stale_claim", staleClaim); + + expect(await pruneCheckoutStages()).toBe(1); + expect(await attendeeExists(attendeeId)).toBe(false); + expect(await checkoutStageExists("stage_stale_claim")).toBe(false); + expect(await paymentExists("stage_stale_claim")).toBe(false); + }); + + test("keeps an old stage while its payment claim is fresh", async () => { + const oldStage = new Date( + nowMs() - PRUNE_CHECKOUT_STAGES_RETENTION_MS - 60_000, + ).toISOString(); + const attendeeId = await insertPendingCheckoutStage( + "stage_fresh_claim", + oldStage, + ); + await insertUnfinalizedPayment( + "stage_fresh_claim", + new Date(nowMs() - 1000).toISOString(), + ); + + expect(await pruneCheckoutStages()).toBe(0); + expect(await attendeeExists(attendeeId)).toBe(true); + expect(await checkoutStageExists("stage_fresh_claim")).toBe(true); + }); + + test("deletes resolved stage replay guards after payment retention", async () => { + const old = new Date( + nowMs() - PRUNE_PAYMENTS_RETENTION_MS - 60_000, + ).toISOString(); + const attendeeId = await insertPendingCheckoutStage( + "stage_old_resolved", + old, + ); + await getDb().execute( + "UPDATE checkout_stages SET state = 'failed', ticket_tokens = '' WHERE payment_session_id = ?", + ["stage_old_resolved"], + ); + + expect(await pruneCheckoutStages()).toBe(1); + expect(await checkoutStageExists("stage_old_resolved")).toBe(false); + expect(await attendeeExists(attendeeId)).toBe(true); + }); + + test("keeps resolved stage replay guards within payment retention", async () => { + const attendeeId = await insertPendingCheckoutStage( + "stage_recent_resolved", + new Date(nowMs() - 1000).toISOString(), + ); + await getDb().execute( + "UPDATE checkout_stages SET state = 'booked', ticket_tokens = '' WHERE payment_session_id = ?", + ["stage_recent_resolved"], + ); + + expect(await pruneCheckoutStages()).toBe(0); + expect(await checkoutStageExists("stage_recent_resolved")).toBe(true); + expect(await attendeeExists(attendeeId)).toBe(true); + }); + }); + describe("pruneSumupCheckouts", () => { test("deletes checkout metadata older than retention window", async () => { const old = new Date( diff --git a/test/shared/limits.test.ts b/test/shared/limits.test.ts index 5f7207572a..7c34386f1a 100644 --- a/test/shared/limits.test.ts +++ b/test/shared/limits.test.ts @@ -5,7 +5,9 @@ import { ADDRESS_CACHE_MS, ADDRESS_LOOKUP_LOCKOUT_MS, ATTACHMENT_URL_MAX_AGE_S, + assertCheckoutExpiryValid, assertPaymentsRetentionSafe, + CHECKOUT_SESSION_EXPIRY_MINUTES, FORM_STASH_MAX_BYTES, FORM_STASH_MAX_ENTRIES, FORM_STASH_TTL_MS, @@ -122,6 +124,29 @@ describe("limits", () => { }); }); + describe("assertCheckoutExpiryValid", () => { + test("returns values inside the skew-buffered range", () => { + expect(assertCheckoutExpiryValid(35)).toBe(35); + expect(assertCheckoutExpiryValid(60)).toBe(60); + expect(assertCheckoutExpiryValid(24 * 60 - 5)).toBe(24 * 60 - 5); + }); + + test("throws outside the skew-buffered range", () => { + // Stripe judges expires_at by its own clock on arrival, so an + // exact-boundary value could land out of range and fail every checkout + // at creation time — a bad configured value must fail at startup instead. + expect(() => assertCheckoutExpiryValid(34)).toThrow("between 35"); + expect(() => assertCheckoutExpiryValid(24 * 60 - 4)).toThrow( + "between 35", + ); + }); + + test("the live expiry constant satisfies its own range", () => { + expect(CHECKOUT_SESSION_EXPIRY_MINUTES).toBeGreaterThanOrEqual(35); + expect(CHECKOUT_SESSION_EXPIRY_MINUTES).toBeLessThanOrEqual(24 * 60 - 5); + }); + }); + describe("ADDRESS_CACHE_DAYS", () => { test("defaults to 90 days and derives the millisecond window", () => { expect(ADDRESS_CACHE_DAYS).toBe(90); @@ -143,6 +168,7 @@ describe("limits", () => { "APIKEY_LOCKOUT_MS", "ATTACHMENT_URL_MAX_AGE_S", "BOOKING_LOCKOUT_MS", + "CHECKOUT_SESSION_EXPIRY_MINUTES", "FORM_STASH_MAX_BYTES", "FORM_STASH_MAX_ENTRIES", "FORM_STASH_TTL_MS", @@ -158,6 +184,7 @@ describe("limits", () => { "MAX_LOGIN_ATTEMPTS", "MAX_TEXTAREA_LENGTH", "MAX_TOKEN_404S", + "PRUNE_CHECKOUT_STAGES_RETENTION_DAYS", "PRUNE_CONTACTS_RETENTION_DAYS", "PRUNE_INTERVAL_HOURS", "PRUNE_LOGINS_RETENTION_DAYS", diff --git a/test/shared/merge/attendee-merge/repoint.test.ts b/test/shared/merge/attendee-merge/repoint.test.ts index ef9fc6a92d..ec70234c40 100644 --- a/test/shared/merge/attendee-merge/repoint.test.ts +++ b/test/shared/merge/attendee-merge/repoint.test.ts @@ -5,14 +5,12 @@ import { transfersByAccount } from "#shared/accounting/queries.ts"; import { createAttendeeAtomic } from "#shared/db/attendees/api.ts"; import { queryAll } from "#shared/db/client.ts"; import { getRefundPaymentReferences } from "#shared/db/payment-references.ts"; -import { - finalizeSession, - reserveSession, -} from "#shared/db/processed-payments.ts"; +import { reserveSession } from "#shared/db/processed-payments.ts"; import { getTestPrivateKey } from "#test-utils/crypto.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { createTestGroup } from "#test-utils/db-helpers/groups.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { finalizeTestPaymentSession as finalizeSession } from "#test-utils/db-helpers/processed-payments.ts"; import { createAttendee, getBookings, diff --git a/test/shared/refund-ledger.test.ts b/test/shared/refund-ledger.test.ts index c0586320e4..8e9a956336 100644 --- a/test/shared/refund-ledger.test.ts +++ b/test/shared/refund-ledger.test.ts @@ -21,8 +21,12 @@ import { getQueryLog, runWithQueryLogContext, } from "#shared/db/query-log.ts"; +import { balanceOf } from "#shared/ledger/project.ts"; import type { AccountRef } from "#shared/ledger/types.ts"; -import { recordAttendeeRefund } from "#shared/refund-ledger.ts"; +import { + recordAttendeeRefund, + recordPlaceholderRefund, +} from "#shared/refund-ledger.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { setupErrorSpy } from "#test-utils/error-spy.ts"; import { @@ -191,6 +195,30 @@ describeWithEnv("refund-ledger > recordAttendeeRefund", { db: true }, () => { await expectRefundNeedsManualAdjustment(); }); + test("reverses pure held cash — a conflict's payment with no sale", async () => { + // A staged conflict holds the customer's charge as a lone payment leg + // (no sale — the booking was never honoured through payment). The + // operator's in-app refund must be ledgerable: reversing returns exactly + // what we hold, so it never strands as a manual adjustment. + await recordPlaceholderRefund( + { + amount: 5000, + attendeeId: ATTENDEE, + eventId: "sess-1", + listingId: 1, + occurredAt: BOOKING_AT, + }, + "stage_active", + false, + ); + expect( + await recordAttendeeRefund(ATTENDEE, [sessionReference("sess-1")]), + ).toEqual({ posted: true }); + const legs = await transfersByAccount(attendeeAccount(ATTENDEE)); + expect(legs.filter((l) => l.kind === "refund_cash").length).toBe(1); + expect(balanceOf(attendeeAccount(ATTENDEE))(legs)).toBe(0); + }); + test("is idempotent — a second refund writes nothing but still reports posted", async () => { await postBooking(); await recordAttendeeRefund(ATTENDEE, [sessionReference("sess-1")]); diff --git a/test/shared/settings/registry.test.ts b/test/shared/settings/registry.test.ts index 3aa99534c5..c6cc771fe3 100644 --- a/test/shared/settings/registry.test.ts +++ b/test/shared/settings/registry.test.ts @@ -69,6 +69,7 @@ const EXPECTED_CONFIG_KEY_NAMES = lines(` LAST_ACTIVITY_LOG_BACKFILL LAST_PRUNED_ADDRESSES LAST_PRUNED_CONTACTS + LAST_PRUNED_CHECKOUT_STAGES LAST_PRUNED_INVITES LAST_PRUNED_LOGINS LAST_PRUNED_ORPHANS @@ -101,6 +102,7 @@ const EXPECTED_CONFIG_KEY_NAMES = lines(` SQUARE_WEBHOOK_SIGNATURE_KEY STRIPE_SECRET_KEY STRIPE_WEBHOOK_ENDPOINT_ID + STRIPE_WEBHOOK_EVENTS_VERSION STRIPE_WEBHOOK_SECRET SUMUP_API_KEY SUMUP_MERCHANT_CODE @@ -133,6 +135,7 @@ const EXPECTED_SETTING_ROWS = [ ["WRAPPED_PRIVATE_KEY", "plaintext", "wrappedPrivateKey", "readOnly"], ["SQUARE_LOCATION_ID", "plaintext", null], ["STRIPE_WEBHOOK_ENDPOINT_ID", "plaintext", null], + ["STRIPE_WEBHOOK_EVENTS_VERSION", "plaintext", null], ["SUMUP_MERCHANT_CODE", "plaintext", null], ["LATEST_SCRIPT_VERSION", "plaintext", "latestScriptVersion"], ["LATEST_SCRIPT_VERSION_NAME", "plaintext", "latestScriptVersionName"], @@ -147,6 +150,12 @@ const EXPECTED_SETTING_ROWS = [ ["ATTENDEE_COLUMN_ORDER", "plaintext", "attendeeColumnOrder"], ["LAST_PRUNED_PAYMENTS", "plaintext", "lastPrunedPayments"], ["LAST_PRUNED_SESSIONS", "plaintext", "lastPrunedSessions"], + [ + "LAST_PRUNED_CHECKOUT_STAGES", + "plaintext", + "lastPrunedCheckoutStages", + "prune", + ], ["LAST_PRUNED_SUMUP", "plaintext", "lastPrunedSumup"], ["LAST_PRUNED_STRINGS", "plaintext", "lastPrunedStrings", "prune"], ["LAST_PRUNED_LOGINS", "plaintext", "lastPrunedLogins", "prune"], diff --git a/test/shared/slug.test.ts b/test/shared/slug.test.ts index f964e80134..43f0aaa816 100644 --- a/test/shared/slug.test.ts +++ b/test/shared/slug.test.ts @@ -45,13 +45,20 @@ describe("slug", () => { } }); - test("generates different slugs on multiple calls", () => { - const slugs = new Set(); - for (let i = 0; i < 20; i++) { - slugs.add(generateSlug()); - } - // With ~1.15M combinations, 20 slugs should all be unique - expect(slugs.size).toBe(20); + test("generates different slugs when the randomness differs", () => { + // Deterministic: two disjoint Math.random sequences must give two + // different slugs — a cached or constant slug would ignore the + // randomness. Replaces a birthday-flaky "20 random draws never collide" + // assertion, which genuinely collided about once per 6,000 CI runs. + const first = withRandomSequence( + [0.35, 0.75, 0.3, 0.7, 0.56, 0.1, 0.9, 0.5, 0.1], + () => generateSlug(), + ); + const second = withRandomSequence( + [0.05, 0.15, 0.8, 0.2, 0.06, 0.6, 0.4, 0.01, 0.9], + () => generateSlug(), + ); + expect(first).not.toBe(second); }); test("shuffles the guaranteed digits/letters via a real Fisher-Yates pass", () => { diff --git a/test/shared/sumup-provider.test.ts b/test/shared/sumup-provider.test.ts index cd38743c2a..cce33a0b53 100644 --- a/test/shared/sumup-provider.test.ts +++ b/test/shared/sumup-provider.test.ts @@ -129,6 +129,21 @@ describe("sumup-provider", () => { }, ); }); + + test("maps EXPIRED to failed (lapsed checkout)", async () => { + await stageCheckout(); + // SumUp hosted checkouts lapse after ~30 minutes. EXPIRED is terminal: + // mapping it to "failed" routes the redirect through the same + // discard-and-cancel-page path a declined card takes, so the staged + // details are removed the moment the checkout can no longer be paid. + await withFetched( + checkout({ status: "EXPIRED", transactionId: "" }), + async () => { + const result = await sumupPaymentProvider.retrieveSession("ref"); + expect(result!.paymentStatus).toBe("failed"); + }, + ); + }); }); describe("isPaymentRefunded", () => { diff --git a/test/test-utils/db-helpers/attendees.ts b/test/test-utils/db-helpers/attendees.ts index a52f3c1374..ed680735e8 100644 --- a/test/test-utils/db-helpers/attendees.ts +++ b/test/test-utils/db-helpers/attendees.ts @@ -2,7 +2,6 @@ import { expect } from "@std/expect"; import { parseFlashValue } from "#shared/cookies.ts"; import { signCsrfToken } from "#shared/csrf.ts"; import { createAttendeeAtomic } from "#shared/db/attendees/api.ts"; -import { ensureAllBookings } from "#shared/db/attendees/create.ts"; import { decryptAttendees } from "#shared/db/attendees/pii.ts"; import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; import type { ListingInput } from "#shared/db/listings/table.ts"; @@ -20,30 +19,15 @@ export const bookTestAttendee = async ( name = "Alice", email?: string, ): Promise => { - // Record the booking (and, on rollback, reverse its contact-activity count) - // under one source so the greedy create and the cleanup can't drift. - const source = "public"; const result = await createAttendeeAtomic({ bookings: listingIds.map((listingId) => ({ listingId })), email: email ?? `${name.toLowerCase()}@test.com`, name, - source, + source: "public", }); if (!result.success) { throw new Error(`Failed to create attendee: ${result.reason}`); } - // createAttendeeAtomic is greedy: it reports success once any booking lands, - // so a full/blocked listing would silently leave the attendee booked onto - // fewer listings than asked for. Reuse production's "no half-saved attendee" - // rule, which rolls the partial attendee back (reversing the same source's - // booking count) and reports failure, then fail the setup loudly rather than - // leaving stray bookings to skew the test. - const check = await ensureAllBookings(result, listingIds.length, source); - if (!check.ok) { - throw new Error( - `Failed to book test attendee onto all ${listingIds.length} listing(s): ${check.reason}`, - ); - } return result.attendees[0]!; }; @@ -121,6 +105,18 @@ export const insertOrphanAttendee = async ( return Number(result.lastInsertRowid); }; +/** The quantities of an attendee's booking rows, for asserting that a guarded + * mutation left staged (quantity-0) lines untouched. */ +export const getAttendeeQuantities = async ( + attendeeId: number, +): Promise<{ quantity: number }[]> => { + const { queryAll } = await import("#shared/db/client.ts"); + return queryAll<{ quantity: number }>( + "SELECT quantity FROM listing_attendees WHERE attendee_id = ? ORDER BY id", + [attendeeId], + ); +}; + /** Check whether an attendee row exists by id. Returns true when the row is * present, false when it has been purged. */ export const attendeeExists = async (id: number): Promise => { diff --git a/test/test-utils/db-helpers/contacts.ts b/test/test-utils/db-helpers/contacts.ts new file mode 100644 index 0000000000..5d225655d2 --- /dev/null +++ b/test/test-utils/db-helpers/contacts.ts @@ -0,0 +1,13 @@ +import { recordBookingActivity } from "#shared/db/contact-tokens.ts"; + +/** Seed contact visits through the same atomic activity write production uses. */ +export const seedContactVisits = async ( + hash: string, + count = 1, +): Promise => { + await Promise.all( + Array.from({ length: count }, (_, index) => + recordBookingActivity(hash, "public", `test-visit-${index}`), + ), + ); +}; diff --git a/test/test-utils/db-helpers/processed-payments.ts b/test/test-utils/db-helpers/processed-payments.ts new file mode 100644 index 0000000000..620219a32b --- /dev/null +++ b/test/test-utils/db-helpers/processed-payments.ts @@ -0,0 +1,50 @@ +import { stageCheckout } from "#shared/db/checkout-stages.ts"; +import { execute } from "#shared/db/client.ts"; +import { batchFinalizeStatement } from "#shared/db/payment-finalize.ts"; +import { checkoutIntent, checkoutItem } from "#test-utils/checkout.ts"; + +/** Create the standard one-listing staged checkout used by DB-backed tests. */ +export const stageTestCheckout = ( + sessionId: string, + listing: { id: number; name: string; slug: string }, +) => + stageCheckout( + sessionId, + "stripe", + checkoutIntent({ + items: [ + checkoutItem({ + listingId: listing.id, + name: listing.name, + slug: listing.slug, + }), + ], + }), + ); + +/** Put a reserved payment into the finalized state needed by a test fixture, + * through the REAL guarded finalize statement — so fixtures can only reach + * states production can write, and finalizing an already-resolved session + * fails loudly instead of silently overwriting it. The token list joins with + * "+" exactly as the stored form does, so [] and ["tok"] behave as before. */ +export const finalizeTestPaymentSession = async ( + sessionId: string, + attendeeId: number, + ticketTokens: string[], + paymentReference: string, +): Promise => { + const statement = await batchFinalizeStatement( + sessionId, + "?", + attendeeId, + { args: [], sql: "1 = 1" }, + paymentReference, + ticketTokens.join("+"), + ); + const result = await execute(statement.sql, statement.args); + if (result.rowsAffected !== 1) { + throw new Error( + `finalizeTestPaymentSession: session ${sessionId} was not an unresolved reservation`, + ); + } +}; diff --git a/test/test-utils/factories.ts b/test/test-utils/factories.ts index 012e4f81ad..a79e0f356a 100644 --- a/test/test-utils/factories.ts +++ b/test/test-utils/factories.ts @@ -102,6 +102,7 @@ export const testAttendee = (overrides: Partial = {}): Attendee => ({ name: "John Doe", package_group_id: 0, payment_id: "", + pending_checkout: 0, phone: "", pii_blob: "", price_paid: "0", diff --git a/test/test-utils/ledger.ts b/test/test-utils/ledger.ts index b6c721b240..03248a167b 100644 --- a/test/test-utils/ledger.ts +++ b/test/test-utils/ledger.ts @@ -102,6 +102,35 @@ export const postListingSale = async ({ }); }; +/** + * Post a payment leg with NO sale — the "held cash" a stage_active conflict + * leaves: world funds the attendee for `amount`, and with `gross` 0 mapBooking + * drops the sale leg, so the attendee account ends with a positive balance + * (cash we hold but never applied to a booking). Used to set up the state the + * no-quantity guard must refuse to strand. + */ +export const postHeldPayment = async ({ + listingId, + attendeeId, + amount, + eventId = `held-${listingId}-${attendeeId}`, +}: { + listingId: number; + attendeeId: number; + amount: number; + eventId?: string; +}): Promise => { + await postTransfers( + await oneListingBookingLegs({ + amountPaid: amount, + attendeeId, + eventId, + gross: 0, + listingId, + }), + ); +}; + /** * Post a booking whose only money is one modifier leg, so `balanceOf(modifier:M)` * — which a modifier's projected `total_revenue` reads directly — reflects that diff --git a/test/test-utils/settings.ts b/test/test-utils/settings.ts index 0573817026..345530c50e 100644 --- a/test/test-utils/settings.ts +++ b/test/test-utils/settings.ts @@ -3,6 +3,7 @@ import { stub } from "@std/testing/mock"; import type { SettingsData } from "#shared/db/settings.ts"; import { CONFIG_KEYS, settings } from "#shared/db/settings.ts"; import { setDemoModeForTest } from "#shared/demo/mode.ts"; +import { STRIPE_WEBHOOK_EVENTS_VERSION } from "#shared/stripe-webhook-events.ts"; import { describeWithEnv } from "./db.ts"; /** The standard outer describe for admin-settings tests: scoped to @@ -67,6 +68,7 @@ export const testWithSetting = ( export const setupStripe = async (key = "sk_test_mock"): Promise => { const { settings: s } = await import("#shared/db/settings.ts"); await s.update.stripe.secretKey(key); + await s.update.stripe.webhookEventsVersion(STRIPE_WEBHOOK_EVENTS_VERSION); await s.update.paymentProvider("stripe"); }; diff --git a/test/ui/templates/admin/attendee-detail.test.ts b/test/ui/templates/admin/attendee-detail.test.ts index 3884783f86..1711b82411 100644 --- a/test/ui/templates/admin/attendee-detail.test.ts +++ b/test/ui/templates/admin/attendee-detail.test.ts @@ -21,6 +21,7 @@ const booking = ( checkedIn: false, endAt: null, listingActive: true, + listingDeleted: false, listingId: 1, listingName: "Test Listing", parentListingId: 0, @@ -39,6 +40,7 @@ const renderDetail = ( attendee = testAttendee(), phonePrefix = "44", hasRealLine = true, + pendingCheckout = false, ): string => String( renderSection({ @@ -47,6 +49,7 @@ const renderDetail = ( allowedDomain: ALLOWED_DOMAIN, attendee, hasRealLine, + pendingCheckout, phonePrefix, }), }), @@ -79,6 +82,20 @@ describe("attendee summary section", () => { expect(html).toContain("No quantity"); }); + test("says payment in progress instead of no quantity for a mid-payment record", () => { + // A staged checkout's record must explain that a payment is under way — + // the "No quantity" wording invites the operator edit the guard refuses. + const html = renderDetail( + testAttendee({ ticket_token: "tok-mid-payment" }), + "44", + false, + true, + ); + expect(html).not.toContain("/t/tok-mid-payment"); + expect(html).toContain("Payment in progress"); + expect(html).not.toContain("No quantity"); + }); + test("renders email as a mailto link when present", () => { const html = renderDetail(testAttendee({ email: "jane@example.com" })); expect(html).toContain('href="mailto:jane@example.com"'); @@ -209,6 +226,24 @@ describe("AttendeeBookingsTable", () => { ); }); + test("shows a deleted listing's row as plain text, never a dead link", () => { + const html = renderBookings([ + booking({ + listingActive: false, + listingDeleted: true, + listingId: 42, + listingName: "Deleted listing", + quantity: 0, + }), + ]); + // The row still shows what the customer booked (the placeholder name and + // quantity), but with no link (the listing page would 404) and no + // "(Inactive)" note ("Deleted listing" already says why there's no name). + expect(html).toContain("Deleted listing"); + expect(html).not.toContain('href="/admin/listing/42"'); + expect(html).not.toContain("(Inactive)"); + }); + test("annotates a folded child row with the parent it was chosen under", () => { // The child's parentListingId points at the parent, which is booked in the // same order — so its name resolves from the sibling row. diff --git a/test/ui/templates/admin/attendee-notes.test.tsx b/test/ui/templates/admin/attendee-notes.test.tsx index 4e39ee58d3..14343d1428 100644 --- a/test/ui/templates/admin/attendee-notes.test.tsx +++ b/test/ui/templates/admin/attendee-notes.test.tsx @@ -69,7 +69,6 @@ describe("AttendeeNotesSection", () => { ); expect(ownerHtml).toContain('href="/admin/attendees/5/ledger"'); }); - test("renders an owner note without the alert styling", () => { const html = String( attendeeBanner({ attendee: testAttendee({ status_id: 1 }), isOwner, notes, + pendingCheckout, statuses, }); @@ -66,6 +68,7 @@ describe("attendee page blocks", () => { phone: null, }, isOwner: false, + pendingCheckout: false, previousBookings: [], }), ); @@ -123,11 +126,17 @@ describe("attendee page blocks", () => { expect(staffHtml).not.toContain('href="/admin/ledger/attendee/1"'); const ownerHtml = String( - renderBanner([attendeeStatus()], [ledgerNote], true), + renderBanner([attendeeStatus()], [ledgerNote], false, true), ); expect(ownerHtml).toContain('href="/admin/ledger/attendee/1"'); }); + test("shows the checkout-pending alert even with one status and no notes", () => { + const html = String(renderBanner([attendeeStatus()], [], true)); + + expect(html).toContain("Checkout pending"); + expect(html).toContain('role="alert"'); + }); test("omits the payment block when the attendee has no payment", () => { expect( PaymentDetails({ diff --git a/test/ui/templates/admin/dashboard.test.ts b/test/ui/templates/admin/dashboard.test.ts index 142d8ebcb8..309d936131 100644 --- a/test/ui/templates/admin/dashboard.test.ts +++ b/test/ui/templates/admin/dashboard.test.ts @@ -206,7 +206,7 @@ describe("adminDashboardPage", () => { ); }); - test("newest attendees not shown when all attendees have unknown listing_id", () => { + test("newest attendees shows a retained booking for a deleted listing", () => { const listings = [testListingWithCount({ id: 1 })]; const attendees = [testAttendee({ id: 1, listing_id: 999 })]; const html = adminDashboardPage( @@ -215,11 +215,12 @@ describe("adminDashboardPage", () => { undefined, attendees, ); - expect(html).not.toContain("Newest"); - expect(html).not.toContain("
"); + expect(html).toContain("Deleted listing"); + expect(html).not.toContain('href="/admin/listing/999"'); }); - test("newest attendees skips attendees with unknown listing_id", () => { + test("newest attendees keeps live and deleted-listing bookings", () => { const listings = [testListingWithCount({ id: 1, name: "Known Listing" })]; const attendees = [ testAttendee({ id: 1, listing_id: 1, name: "Valid" }), @@ -232,8 +233,9 @@ describe("adminDashboardPage", () => { attendees, ); expect(html).toContain("Valid"); - expect(html).not.toContain("Orphan"); - expect(html).toContain("Newest 1 Attendee"); + expect(html).toContain("Orphan"); + expect(html).toContain("Deleted listing"); + expect(html).toContain("Newest 2 Attendees"); }); });