From f0826107909c8ba7a4081649ca58471c91ea9d8e Mon Sep 17 00:00:00 2001 From: Eli Robinson Date: Wed, 29 Jul 2026 15:33:46 -0700 Subject: [PATCH 1/2] fix: release holds on TTL, add sweeper, and Done after purchase Expire lapsed sessions with releaseHold on touch and via an in-process sweeper, label the header Done only after a completed order, and update README/CONTEXT/ADR honesty notes. Also folds shared checkout action and policy refactors from the quality pass. Co-authored-by: Cursor --- CONTEXT.md | 12 +- README.md | 314 ++++++------------ apps/api/src/context.ts | 21 +- apps/api/src/domain/checkout-service.test.ts | 126 ++++++- apps/api/src/domain/checkout-service.ts | 96 ++++-- .../src/domain/session-expiry-sweeper.test.ts | 53 +++ apps/api/src/domain/session-expiry-sweeper.ts | 62 ++++ apps/api/src/domain/session-store.ts | 6 + apps/api/src/index.ts | 23 +- apps/api/src/router.test.ts | 3 +- apps/api/src/routers/checkout.ts | 59 ++-- .../app/__tests__/checkout.test.tsx | 65 ++-- apps/mobile-web/app/checkout/[id].tsx | 133 +++----- apps/web/app/checkout-leave-mode.tsx | 40 +++ .../checkout/[id]/checkout-client.test.tsx | 68 ++-- .../web/app/checkout/[id]/checkout-client.tsx | 188 ++++------- apps/web/app/checkout/[id]/page.ssr.test.tsx | 34 +- apps/web/app/checkout/[id]/page.tsx | 2 +- apps/web/app/layout.tsx | 7 +- apps/web/app/site-header.test.tsx | 53 ++- apps/web/app/site-header.tsx | 14 +- apps/web/src/format.ts | 1 - apps/web/src/resume-session.ts | 12 +- docs/decisions.md | 31 +- ...ion_2026-07-29_1430_code-quality-review.md | 29 ++ ...sion_2026-07-29_1445_code-quality-fixes.md | 40 +++ ...026-07-29_1515_post-purchase-done-label.md | 25 ++ ...2026-07-29_1520_ttl-lapse-releases-hold.md | 28 ++ ..._2026-07-29_1530_session-expiry-sweeper.md | 22 ++ ...026-07-29-session-expiry-sweeper-design.md | 38 +++ packages/api-contracts/src/demo-catalog.ts | 71 ++++ packages/api-contracts/src/index.ts | 1 + .../api-contracts/src/schemas/checkout.ts | 11 +- .../features/checkout/DemoPriceCountdown.tsx | 22 +- .../checkout/TicketProtectionCard.tsx | 78 ----- .../ui/src/features/checkout/checkout.copy.ts | 4 + .../checkout/checkout.policy.util.test.ts | 61 ++++ .../features/checkout/checkout.policy.util.ts | 40 +++ .../checkout/checkoutShare.util.test.ts | 1 - .../features/checkout/checkoutShare.util.ts | 10 +- packages/ui/src/features/checkout/index.ts | 23 +- .../checkout/mapCheckoutPresentation.util.ts | 13 +- .../checkout/mapCheckoutView.util.test.ts | 37 ++- .../features/checkout/mapCheckoutView.util.ts | 16 + .../features/checkout/useCheckoutActions.ts | 106 ++++++ .../features/listings/listings.fixtures.ts | 72 ++-- packages/ui/src/server.ts | 8 + 47 files changed, 1426 insertions(+), 753 deletions(-) create mode 100644 apps/api/src/domain/session-expiry-sweeper.test.ts create mode 100644 apps/api/src/domain/session-expiry-sweeper.ts create mode 100644 apps/web/app/checkout-leave-mode.tsx create mode 100644 docs/session_2026-07-29_1430_code-quality-review.md create mode 100644 docs/session_2026-07-29_1445_code-quality-fixes.md create mode 100644 docs/session_2026-07-29_1515_post-purchase-done-label.md create mode 100644 docs/session_2026-07-29_1520_ttl-lapse-releases-hold.md create mode 100644 docs/session_2026-07-29_1530_session-expiry-sweeper.md create mode 100644 docs/superpowers/specs/2026-07-29-session-expiry-sweeper-design.md create mode 100644 packages/api-contracts/src/demo-catalog.ts delete mode 100644 packages/ui/src/features/checkout/TicketProtectionCard.tsx create mode 100644 packages/ui/src/features/checkout/checkout.policy.util.test.ts create mode 100644 packages/ui/src/features/checkout/checkout.policy.util.ts create mode 100644 packages/ui/src/features/checkout/useCheckoutActions.ts diff --git a/CONTEXT.md b/CONTEXT.md index 10c07e3..be4eced 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -13,8 +13,7 @@ people (or two devices for the same person) can both resume the same session. The checkout session moves through a single state machine (`status` field): -- **created** — session just made for a selected listing; inventory hold placed. -- **active** — fan is viewing/resuming the session; inventory still held, price/inventory +- **active** — session created with inventory held; fan is viewing/resuming; price/inventory can still change underneath it. - **pending_payment** — fan submitted payment; awaiting provider result. This is the window where a second device resuming the same session is the actual duplicate-order hazard the @@ -22,8 +21,8 @@ The checkout session moves through a single state machine (`status` field): - **completed** — order placed successfully. Terminal. - **expired** — inventory hold lapsed before completion. Terminal unless the fan starts a fresh session (re-priced/re-held). -- **failed** — payment or completion failed. Fan may retry (transitions back to **active**) - or the session expires. +- **failed** — payment or completion failed. Fan may retry (claims again through + **pending_payment**) or the session expires. ## Price Reconfirmation @@ -50,3 +49,8 @@ own `expiresAt` implies the hold is still good — the hold can lapse independen shorter TTL than the session, or inventory reclaimed for other reasons). A session can be unexpired but reference inventory that is no longer held; that combination surfaces to the fan as a distinct "listing no longer available" state, not the same as "session expired." +Conversely, when the session clock itself lapses, checkout also releases the hold +(request-time via `expireIfNeeded`, or in the background via `expireLapsedSessions` / +`SessionExpirySweeper`) so inventory isn't stranded — `expiryReason` stays +`session_lapsed` (why the session died), not `hold_released` (hold disappeared while the +session was still live). diff --git a/README.md b/README.md index 9f5383d..633d5cd 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,50 @@ # Notes about the demo -- Cross-surface resume in this demo is **web ↔ web** (copy the link into another - tab/browser) and **mobile → web** (native share/copy hands the fan a web - checkout URL). I did not build a seamless **web → mobile** handoff: a browser - cannot detect whether an iOS/Android simulator (or Expo) is running on the - reviewer’s machine, and custom-scheme deep links (`mobileweb://…`) only work - when that OS has registered the scheme. An earlier **Open in app** control on - web (navigate / `window.open` to `mobileweb://…`) proved too volatile — it - either navigated the checkout page away or failed silently depending on the - browser and whether Expo had claimed the scheme — so it was removed. Use - `xcrun simctl openurl` (below) for local deep-link demos instead. -- I also wanted to pressure-test a quickly built React Native demo alongside - shared UI and a monorepo with multiple apps. This started from a GitHub - template I maintain, with a decent amount of cleanup so the shared packages - actually show code reuse between the React Native app and the web app. -- The core sharing/resume design came together quickly; I spent extra time on - presentation so the demo feels attractive and demonstrates mobile ↔ web - parity, not just a working handoff. -- **Why go past a “minimal UI” slice?** The prompt’s time box (~2–3 hours) - prefers a focused end-to-end continuity path over a broad checkout clone. I - still invested past that floor on purpose: a bare curl-and-JSON demo proves - the state machine, but a product-shaped shell (selection → checkout, shared - tokens/UI, Gametime-like visual hierarchy) is a better signal of how I attack - _complicated_ product + systems problems — domain boundaries, cross-surface - consistency, and reviewer-friendly UX — not only whether `resume` returns - `200`. Treat the polish as intentional flex, not scope creep that replaced the - continuity core. - -**Time trade-offs (kept the continuity slice honest, deferred production depth):** - -- In-memory session store and deterministic payment/inventory fakes — fine per - the prompt, zero reviewer setup, and the CAS race is still real in one - process. No Prisma session model, no real Stripe/inventory SDKs, no auth. -- Surfaces discover changes by acting (resume / complete), not via push — with - one demo exception: **Sec 118 · Row 8 · 10s price demo** (`listing_3`) ages its _held_ - price after 10 seconds so you can watch reconfirmation live. Catalog price - stays at the seed if you leave and browse again; a new session resets the - timer. Other failure modes (decline, sold-out) still need tests / - `forceOutcome` / `releaseListing`. -- No automated create-on-web / complete-on-mobile E2E; unit tests cover the - state machine and conflict path, and the handoff is verified by hand. -- Fixed 10-minute session TTL, two-tap price confirm then buy (not one-tap), - and share via opaque session id only — deliberate scope cuts, not unfinished - stubs. - -**Beyond the basic ask (to raise demo quality):** - -- Ticket landing with listing selection and a static stadium map so the flow - starts like a product, not a bare “create session” curl. -- Shared design tokens + `@repo/ui` checkout/listings so web (Next SSR) and - native actually share components — not two parallel UIs with the same API. -- Visual shell aligned to Gametime-style mocks (layout, hierarchy, share UX) - while keeping payment/promo chrome decorative and non-functional. -- Domain write-up (`CONTEXT.md`), decision log (`docs/decisions.md`), and - structured instrumentation so the _why_ of the state machine is reviewable. - -**AI-assisted engineering trail:** session notes under [`docs/`](./docs/) -(`session__