Consolidate lockout signup side effects (finalizeLockout reconciler)
Problem
Lockout signup side effects are split across two code paths with arbitrary allocation:
- Creation bundle (
fulfillLockout/finalizeLockoutPostPayment→executeCreationSideEffects): welcome email, staff notification, referral code, access code, plus tx-side record work (billingStartDate/startTime/status, availability, waitlist fulfill, attribution, Meta tracking). - Activation bundle (
tryActivateReservation→executeActivationSideEffects): studio-ready email, access code again.
The split is justified for future-dated lockouts (sign up now, access starts later), but which side effect lives in which bucket is arbitrary. When one path breaks silently, critical side effects disappear with no error.
Incidents this pattern has caused
setup_intent.succeededpath skipped side effects for future-dated / $0-invoice signups (7 members affected, remediated manually).- Guard race where
subscription.updatedfired beforeinvoice.payment_succeeded, tripping the idempotency guard and dropping all side effects for a Cherry City member. - ADR-008 (2026-04-14) inverted an invariant
fulfill-lockoutrelied on, silently breaking ACH optimistic fulfillment — every ACH signup until the fix missed the welcome email, staff notification, and referral code. - Waitlist deposit transfer gated on
billing_reason='subscription_create'never fired for future-start lockouts that pay viasubscription_cycle(#837) — same disease, money-shaped.
All share one mechanism, deeper than "one path broke": the idempotency guard commits inside the webhook transaction, but side effects execute after commit, fire-and-forget (after() / dispatchSideEffects, .catch(captureError), no retry). Once the guard trips, nothing ever re-attempts the work. The whole creation bundle is gated on a single field (reservation.billingStartDate !== null, FulfillmentService.finalizeLockoutPostPayment), which conflates "already finalized" with "billingStartDate got set by anything."
Decision
One idempotent finalizeLockout(reservationId) reconciler owns all reservation-scoped signup work. Every trigger converges on it; it re-derives state from the DB (plus Stripe when needed), and each effect is an independent (precondition, idempotency claim, action) — effects fire whenever finalize runs and their precondition has become true. Safe to call any number of times, from any trigger, in any order.
A sweep cron re-invokes finalize for recent lockouts that haven't converged, converting every silent-loss failure mode (crash after commit, email-provider blip, a future guard bug) into delayed delivery.
Trigger inventory (all converge on finalize)
The table below is the problem-statement baseline as originally written (the split state that motivated this reconciler). As shipped, every row's "Today's path" now reads → lockoutFinalize marker → finalizeLockout: fulfillLockout and finalizeLockoutPostPayment are both fully retired from the codebase, and the two creation-time webhook triggers (subscription_create, setup_intent.succeeded) no longer branch on whether a pre-created reservation/payment exists — they always return the marker.
Creation-time (5):
| Trigger | Today's path |
|---|---|
invoice.payment_succeeded (billing_reason subscription_create) |
handleSubscriptionCreate → handleLockoutSubscriptionCreate → finalizeLockoutPostPayment or fulfillLockout fallback |
customer.subscription.updated (active + unsettled payment + paid create-invoice) |
reconcileActiveSubscriptionPayment → handleSubscriptionCreate |
setup_intent.succeeded (lockout; $0-first-invoice / future-dated ACH) |
handleLockoutSetupIntent → finalizeLockoutPostPayment |
POST /checkout/fulfill-lockout (authed, client-fired on ACH processing/pendingVerification) |
finalizeLockoutPostPayment |
POST /checkout/lockout-invitation/[id]/fulfill (public mirror) |
finalizeLockoutPostPayment |
Activation-time (2):
| Trigger | Today's path |
|---|---|
invoice.finalized |
tryActivateReservation → executeActivationSideEffects |
invoice.payment_succeeded (billing_reason subscription_cycle) |
tryActivateReservation |
Non-Stripe creators: none reach finalize as a trigger, but the inventory claim "none exist" was wrong — MigrationService creates MONTHLY reservations with no subscription (status CONFIRMED by schema default, startTime set) that sit in that state through the staff-review window; the subscription only attaches at .approve(). Staff (POST /api/reservations), comp, and quick-signup creation paths only build HOURLY/TOUR side-effect payloads. Consequence for the sweep: subscription-less reservations are excluded from sweep candidates entirely — the sweep must never finalize a migration awaiting approval. Finalize itself still supports subscription-less reservations (evidence = status CONFIRMED) so an explicit future non-Stripe lockout creator can converge by calling it directly; sweep coverage for such reservations widens only when that creator exists and is distinguishable from pre-approval migrations.
Note: Acuity appointments are not a lockout side effect — the lockout paths never pass acuityFields, so payload.acuity is always null. Acuity belongs to hourly and is out of scope.
Scope split: reservation-scoped vs invoice-scoped
The line is scope, not "money vs not-money":
- Moves into finalize (reservation-scoped, once-ever): INVITED→CONFIRMED promotion +
inviteExpiresAtclear, billingStartDate/startTime stamping, resource availability refresh, welcome email, studio-ready email, staff notification, access provisioning (incl. UniFi members group), referral promotion code, referrer reward, waitlist entry fulfill, waitlist deposit transfer, attribution write, Meta purchase event. - Stays trigger-side (invoice-scoped, per-event): payment PENDING→PAID reconciliation, DEBIT transaction rows,
processChargeTransferForInvoice,transferCustomerBalancePortion, payment-failed handling, renewal accounting.
transferPendingWaitlistDeposit needs no invoice (source_transaction is the waitlist charge; amount is waitlist.netAmountCents) — it is reservation/waitlist-scoped and moves, with a PAID-payment precondition. This supersedes the per-bucket patch shape of #837.
Per-effect idempotency
- Natural-state checks where the effect creates a durable record: referral code (
ReferralPromotionCoderow), access codes (AccessCoderows), partner payout (PartnerReferralPayoutrow), deposit transfer (WaitlistEntry.depositTransferredAt+ Stripe idempotency key), attribution (existing carry-forward semantics). - Claim ledger for send-only effects (no natural record): new
reservation_side_effectstable —reservationId,effectKey,claimedAt,completedAt, unique on (reservationId, effectKey). Claim-before-send (atomic insert/conditional update); a claim older than the re-claim window with nullcompletedAtis re-claimable, generalizing today's 10-minutewelcomeFlowEmailSentAtwindow. Adding an effect = a new key string, no migration. SentEmailis log-after-send and best-effort — audit trail only, never an idempotency guard.- Welcome and studio-ready become separate keys (today they share
welcomeFlowEmailSentAt, making which-email-wins nondeterministic for immediate activations). Product behavior made explicit: immediate → welcome-with-code only; future-dated → welcome-without-code at signup, studio-ready-with-code at activation. - The member-referrer reward (
invoiceItems.create) currently has no guard — it gains a ledger key.
Transaction boundary
Trigger-side webhook transactions stay minimal (invoice-scoped reconciliation only). Finalize owns its own short transaction for record finalization, then executes external effects (Stripe, email, UniFi) post-commit — never inside the 25s webhook tx. Optional subscription parameter as a fast path for callers that already hold it (webhooks); fetched from Stripe when absent (client fulfill routes, sweep). Cheap DB early-exit before any Stripe call — finalize also runs on every renewal's invoice.finalized.
Optimistic-fulfillment contract (must preserve)
- Finalize is callable while payment is still
processing— never gated on a PAID payment overall (only individual effects like the deposit transfer carry that precondition). - Access is granted optimistically for ACH (fires on both
achProcessingandpendingVerificationfromuseCheckoutFlow); access revocation remains a subscription-lifecycle concern (dunning →subscription.deleted), untouched here. - The invite-expiry cron's skip-if-payment-active guard (#851) remains load-bearing during the INVITED-with-processing-payment window; the sweep must respect the same preconditions (never promote INVITED without a subscription in good standing or a comp/staff origin).
Acceptance
- All seven Stripe/client trigger paths converge on
finalizeLockout. - Each side effect has a local precondition + idempotency check; finalize is safe to call repeatedly and concurrently.
- No duplicate or missing welcome/studio-ready emails for immediate-activation lockouts; deterministic which email sends.
- Sweep cron repairs any dropped effect within one sweep interval.
- Adding a new signup-time side effect = one file edit + one ledger key.
- Test matrix: each trigger path × {immediate, future-dated, $0-first-invoice, ACH-optimistic} → same terminal state (records, ledger rows, emails claimed) for the same input; ACH sequence covered end-to-end (client fulfill → optional
payment_failed→payment_succeededdays later).
Out of scope / intentional
- Hourly, tour, and cancellation side effects (
executeCreationSideEffectsnon-monthly branches,executeCancellationSideEffects) — untouched. - Invoice-scoped money reconciliation and transfers — stay where they are.
- Webhook ingress (claim/lease/retry) — already handled by stripe-webhook-ingress.
- Access revocation / subscription-deletion lifecycle.
Phasing
- Reconciler —
finalizeLockout+reservation_side_effectsmigration; existing creation+activation effects moved; all triggers wired; money behavior unchanged. - Sweep cron — via
createCronHandler; non-convergence detection + re-invoke. - Money effects — deposit transfer + referrer reward (with new guard) move in behind preconditions. (#837 already merged and un-parked the owed transfers, so this phase carries no special deploy timing.)