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]
SideEffectLedgerclaim 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.finalizedcosts 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 +
inviteExpiresAtclear,billingStartDate/startTime/stripeSubscriptionStatusstamping,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(tryActivateReservation→buildLockoutFinalizeResult) - [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+welcomeFlowEmailSentAtclaim (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.mdstill 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-invokesfinalizeLockout; 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
- [ ]
transferPendingWaitlistDepositinto finalize (key viadepositTransferredAtnatural 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-referrerinvoiceItems.create; partner path keepsPartnerReferralPayoutnatural 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
welcomeFlowEmailSentAtafter a full billing cycle of ledger stability