Skip to content

Implementation Plan — finalizeLockout reconciler

Status: Phase 1 in progress — reconciler built + all triggers wired + legacy retired; remaining: full test matrix + verify Spec: spec.md Branch: feat/consolidate-lockout-side-effects

TDD throughout: each checkbox lands with its failing-test-first commit. Existing coverage to build on: tests/api/stripe/lockout-setup-intent-side-effects.api.test.ts, tests/integration/services/payment/StripeWebhookService.activation.test.ts, tests/integration/api/checkout/lockout-invitation-fulfill.integration.test.ts, tests/integration/api/stripe/webhooks/async-flow.integration.test.ts.

Phase 1 — the reconciler (behavior-preserving for money)

  • [x] Migration: reservation_side_effects (reservationId, effectKey, claimedAt, completedAt, @@unique([reservationId, effectKey])) via db-migration flow — index shaped (completed_at, claimed_at) for the sweep
  • [x] SideEffectLedger claim helper: atomic claim-before-act (INSERT ON CONFLICT DO NOTHING — safe inside interactive transactions), re-claim window for claimed-never-completed (Sentry warning on reclaim), complete() stamp
  • [x] LockoutFinalizeService.finalizeLockout(reservationId, { prismaClient, subscription? }):
  • [x] DB convergence early-exit before any Stripe call (renewal invoice.finalized costs two queries)
  • [x] Payment-evidence gate: sub status active/trialing/past_due, PAID payment row, PI processing/succeeded, setup intent succeeded, or microdeposit requires_action — fixes the pre-payment studio-ready email + throwaway-code behavior (RSV595) as a deliberate change
  • [x] Record phase: INVITED→CONFIRMED + inviteExpiresAt clear, billingStartDate/startTime/stripeSubscriptionStatus stamping, updateResourceAvailability, waitlist fulfill, referral code, attribution (guarded by existing-row check)
  • [x] Effects as (precondition, claim, action): access provisioning (honors staff-set custom PIN — fixes the activation throwaway bug), welcome email, studio-ready email (separate key; fires only for future-dated signups at activation), staff notification (first-ever guard), Meta purchase event
  • [x] Wire triggers: handleLockoutSubscriptionCreate, handleLockoutSetupIntent, both fulfill routes, handleInvoiceFinalized + subscription_cycle (tryActivateReservationbuildLockoutFinalizeResult)
  • [x] ~~staff/comp lockout creation~~ — verified no non-Stripe path ever reached the monthly branch; nothing to wire (spec corrected)
  • [x] Backfill migration: pre-existing lockouts marked converged (creation keys where started-or-welcomed; studio-ready only where started) so renewals never retro-send
  • [x] Retire the monthly branch of executeCreationSideEffects + executeActivationSideEffects + welcomeFlowEmailSentAt claim (column deprecated, dropped in a later cleanup migration) + finalizeLockoutPostPayment
  • [x] ACH sequence test (client fulfill while PI processing → recoverable payment_failed → payment_succeeded days later: one welcome email, payment PAID)
  • [x] Reviewer findings addressed (8bc3c6512): deleted-user guard restored in authed fulfill; attribution behind ledger claim (race closed; key in convergence + backfill); backdated-anchor timing test; two loosely-typed unit test files repaired
  • [x] Concurrency: covered at service level (concurrent claims exactly-one-winner, concurrent finalize attribution) — webhook ingress already serializes same-event delivery via event claim, and same-subscription events via the advisory lock; a webhook-level race test in the __testPrisma harness would share one client and not exercise real concurrency
  • [x] $0-first-invoice via setup_intent through the webhook route (incomplete sub + succeeded SI → CONFIRMED, welcome claimed, no startTime, studio-ready withheld)
  • [x] isProrated coverage note: the prorate=true branch of deriveBillingTiming IS exercised route-level by "sets startTime to now for prorated future-dated subscription" in checkout-fulfill-lockout.api.test.ts (reviewer's zero-coverage claim was wrong)
  • [x] PR-review finding (round 5): concurrent triggers could send a code-less welcome (loser of the access-provisioning claim wins the welcome-email claim before the winner's code lands) — coded emails (welcome when startTime set, studio-ready always) now gate on a completed access-provisioning ledger row; withheld sends converge on a later trigger/sweep. Tradeoff accepted: permanently failing provisioning withholds the email instead of sending it code-less.
  • [ ] Stale doc: architecture/stripe/subscriptions.md still describes tryActivateReservation + period-start algorithm — update with the post-ship docs pass
  • [x] Verify: drove a real local checkout end-to-end (public invitation page → on-demand subscription → Stripe test-mode ACH → optimistic fulfill → live webhooks via stripe listen). Result: full convergence (CONFIRMED, one welcome + one staff email, PAID payment, active access code, attribution, all ledger keys completed) AND caught a real regression — backdated same-day signups classified as future-dated (startTime null, code-less welcome). Fixed in 83f9cc65c (start_date vs created detection; wasFutureDated from startTime); the drive's own reservation self-healed on a fulfill retry.

Phase 2 — sweep cron

  • [x] Non-convergence query: MONTHLY reservations from the last 60 days with unmet effects, overdue future-dated activations (any age), or claimed-never-completed ledger rows; unpaid INVITED (no subscription) excluded in SQL; batch cap 100
  • [x] Cron route via createCronHandler (/api/cron/sweep-lockout-finalize, hourly at :30), re-invokes finalizeLockout; preconditions hardened — subscription-less evidence now requires status past INVITED, so the sweep can never promote an unpaid invitation
  • [x] Alerting: repairs diffed per reservation (completed keys, promotion, startTime) and reported via captureError warning sweep-repairs
  • [x] Tests: dropped welcome email, stale claim past reclaim window, unpaid-INVITED untouched, converged skipped without a Stripe call, overdue activation older than the window, Sentry reporting

Phase 3 — reservation-scoped money effects

  • [ ] transferPendingWaitlistDeposit into finalize (key via depositTransferredAt natural state; precondition: PAID payment exists); remove the two trigger-side call sites
  • [ ] Referrer reward into finalize (ledger key referrer-reward — first guard for the member-referrer invoiceItems.create; partner path keeps PartnerReferralPayout natural check)
  • [ ] Tests: deposit transfer fires exactly once across all trigger orders; never before PAID

Post-ship

  • [ ] Docs: update domains/reservations/lockouts.md (side-effect flow, dedup section), architecture/stripe/webhooks.md, log entry (document agent)
  • [ ] Drop welcomeFlowEmailSentAt after a full billing cycle of ledger stability