Skip to content

ADR-008 Completion: Deploy Checklist

This document is the source of truth for deploying PR #651 (ADR-008 completion) to production. It tracks:

  • Migrations that must run, in order
  • Backfill scripts that must run before destructive migrations
  • Data-loss risks that need pre-deploy auditing
  • Verification steps after deploy

The migrations included in this PR are destructive (DROP TABLE, DROP COLUMN). Some require backfill scripts to be run BEFORE the destructive step, otherwise data is permanently lost.


Order of operations

# Step Type Mutates prod Reversible
0 GATE — Run pre-deploy audit (must exit 0) Audit No Yes
1 Run audit script: lockout-proration backfill (dry-run) Audit No Yes
2 Run lockout-proration backfill (execute) Backfill Yes Yes (manual)
3 Run audit script: orphan Transaction stripe-IDs (dry-run) Audit No Yes
4 Run orphan-Transaction backfill (execute) Backfill Yes Yes (manual)
5 Apply Prisma migrations (deploy) Schema Yes No
6 Manual e2e verification (see below) Verify No N/A

Each "execute" step requires Aaron's explicit approval. Run dry-runs first.


Step 0 — GATE: pre-deploy audit

Why: Three of this PR's destructive migrations will abort mid-deploy if specific preconditions aren't met. This script runs the precondition checks as a single hard gate (non-zero exit on any blocker). Without it, the deploy plan relies on humans remembering to inspect each constraint manually.

Checks run:

  1. Payment FK mutex (migration 20260426110000) — payments rows with status='PAID' must have exactly one of {reservation_id, credit_balance_id, waitlist_entry_id, organization_id} set. Any violator causes ALTER TABLE ADD CONSTRAINT to fail.
  2. Orphan Transactions with stripe IDs (migration 20260426130000) — transactions rows with stripe_invoice_id or stripe_payment_intent_id set and no linked Payment. Migration drops these columns; without Step 3–4 backfill the linkage is permanently lost. Auto-skipped if columns already dropped (post-migration state).
  3. Enum mapping 'purchase' → CREDIT_GRANT (migration 20260426140000) — transactions.reason='purchase' rows are assumed to be type=CREDIT/unit=CREDITS. Any with other shape will be silently misclassified as CREDIT_GRANT after the column type change.
  4. Direct-PI lockout charges (migration 20260426160000) — direct PI charges (stripe_invoice_id IS NULL) carrying charge_metadata.purchaseType='lockout' no longer get attribution from metadata in the rewritten view. If any exist with no matching Payment.reservationId, they classify as unattributed. Informational only.

Run:

cd apps/api
vercel env pull .env.production.vercel --environment production
pnpm tsx scripts/adr008-pre-deploy-audit.ts --production
rm .env.production.vercel

Block on non-zero exit. Resolve blockers before proceeding.


Step 1–2: Lockout-proration backfill

Why: LockoutManagementService.updateLockout produced manual invoices (billing_reason: 'manual') between 2026-03-26 (ADR-008 cutover) and PR #651's handleSubscriptionUpdate fix. The webhook silently dropped them — Stripe collected real money but no Payment row was created.

Script: apps/api/scripts/payment.ts backfill-lockout-prorations

Dry-run:

cd apps/api
vercel env pull .env.production.vercel --environment production
pnpm tsx scripts/payment.ts backfill-lockout-prorations --production
rm .env.production.vercel

Execute (requires Aaron's approval):

cd apps/api
vercel env pull .env.production.vercel --environment production
pnpm tsx scripts/payment.ts backfill-lockout-prorations --production --execute
rm .env.production.vercel

What it does: Lists Stripe paid invoices since 2026-03-26 with billing_reason in {manual, subscription_update} for lockout subscriptions, finds those with no matching Payment row, creates Transaction(DEBIT) + Payment(PAID, reservationId, ...) together.

Expected scope: Small. Only staff-initiated room transfers (likely single-digit count).


Step 3–4: Orphan Transaction stripe-ID backfill

⚠️ Must run before migration 20260426130000 is applied. That migration drops transactions.stripe_invoice_id and .stripe_payment_intent_id; this script queries those columns. The Step 0 audit script blocks deploy if orphans exist while the columns are still present, so following the order automatically enforces this.

Why: Migration 20260426130000_drop_transaction_stripe_ids_add_refund_id removes Transaction.stripeInvoiceId and Transaction.stripePaymentIntentId. Most Transactions have a corresponding Payment row carrying the same Stripe IDs — the link survives via Payment.transactionId.

But some legacy Transactions exist with stripe IDs and no companion Payment. Examples:

  • Credit-subscription INITIAL purchases between 2026-03-26 cutover and PR #650's fulfillCreditPackage fix (Stripe charged, no Payment created)
  • Other historical edge cases

If these orphans don't get a Payment row before the migration, the Stripe ID linkage is permanently lost (Transaction still exists; just unable to trace back to Stripe via DB).

Recoverable via Stripe API for invoices/payment_intents Stripe still has. Older invoices Stripe may have purged — those become true data loss (Transaction row persists, Stripe link is broken).

Script: apps/api/scripts/payment.ts backfill-orphan-transaction-stripe-ids

Dry-run:

cd apps/api
vercel env pull .env.production.vercel --environment production
pnpm tsx scripts/payment.ts backfill-orphan-transaction-stripe-ids --production
rm .env.production.vercel

Execute (requires Aaron's approval):

cd apps/api
vercel env pull .env.production.vercel --environment production
pnpm tsx scripts/payment.ts backfill-orphan-transaction-stripe-ids --production --execute
rm .env.production.vercel

Behavior:

  1. Find every Transaction with stripeInvoiceId or stripePaymentIntentId set, no Refund reason, no Payment linked via transactionId or by stripe-ID match
  2. For each: query Stripe API to get the invoice/PI, follow to subscription, look up our entity (CreditBalance / Reservation by stripeSubscriptionId)
  3. If entity found: create Payment(creditBalanceId | reservationId, transactionId, status=PAID, full Stripe linkage)
  4. If entity not found and Stripe still has the invoice: create Payment with status=FAILED + diagnostic note
  5. If Stripe doesn't have the invoice: log unrecoverable orphan (Transaction kept, Stripe link lost)

Expected scope: Likely dozens of orphans, mostly credit-subscription initials from late March / early April 2026.


Step 5: Apply migrations

cd apps/api
vercel env pull .env.production.vercel --environment production
pnpm tsx scripts/check-pending-migrations.ts --production
# Aaron approves
pnpm tsx scripts/deploy-migrations.ts --production
rm .env.production.vercel

Migrations in this PR (in order):

Timestamp Migration Notes
20260426110000 payment_fk_mutex_check Adds CHECK constraint. Will fail if any PAID Payment violates the FK mutex — blocked by Step 0 audit gate.
20260426113000 add_credit_balance_package_and_confirmed_at Additive only. Safe.
20260426120000 a8_drop_credit_purchase_table DROP TABLE credit_purchases. Backfills first (sets confirmed_at on existing balances, copies creditPackageId). Rewrites 5 analytics views.
20260426130000 drop_transaction_stripe_ids_add_refund_id DROP COLUMN stripe_invoice_id, stripe_payment_intent_id. Adds stripe_refund_id (backfilled from refund Transactions). Rewrites bookkeeping_attributed_ledger view. Requires Step 4 backfill first.
20260426140000 enum_transaction_reason Converts transactions.reason from free-form text to TransactionReason enum. Preserves any unrecognized prose into transactions.notes before mapping. Rewrites bookkeeping_credit_activity view.
20260426150000 add_payment_checkout_invitation_id Additive: adds payments.checkout_invitation_id FK so the confirmation page can poll Payment status directly without going through CheckoutInvitation.params. Safe.
20260426160000 attributed_ledger_db_only Rewrites bookkeeping_attributed_ledger to derive revenue_type and location_id purely from Payment FK shape. Drops all charge_metadata/invoice_metadata/transfer_metadata classification paths.
20260426170000 rename_payment_status_enum ALTER TYPE "ReservationPaymentStatus" RENAME TO "PaymentStatus". Metadata-only operation; views/columns updated atomically. Cleans up the last legacy DB-name carry-over from the unified Payment model.

Step 6: Manual verification

After deploy, exercise each path in production with a test customer:

  • [ ] Credit subscription INITIAL purchase (no existing subscription) — confirm payments row + user_credit_balances row both have correct linkage; ledger entry shows reason: CREDIT_PURCHASE (DEBIT) + CREDIT_GRANT (CREDIT, with balanceId set)
  • [ ] Credit subscription tier UPDATE — same
  • [ ] Credit subscription RENEWAL — reason: CREDIT_RENEWAL (DEBIT) + CREDIT_GRANT_RENEWAL (CREDIT)
  • [ ] Credit one-time purchase — reason: CREDIT_PURCHASE (DEBIT) + CREDIT_GRANT (CREDIT)
  • [ ] Lockout init — payments PENDING row created; ledger entry shows reason: LOCKOUT_INITIAL; Stripe Connect transfer fires within seconds, destination matches location's stripe_account_id, payments.stripe_charge_id populated
  • [ ] Lockout monthly renewal — reason: LOCKOUT_RENEWAL; Stripe Connect transfer fires (triggered from invoice handler, not webhook race), payments.stripe_charge_id populated
  • [ ] Lockout add-insurance with timing: immediatepayments row created from manual invoice
  • [ ] Lockout room transfer — payments row created from LockoutManagementService.updateLockout
  • [ ] Lockout proration — reason: LOCKOUT_PRORATION
  • [ ] Org dedicated room subscription — payments row with organizationId set; reason: ORGANIZATION_ROOM
  • [ ] Hourly direct booking — payments row created with checkoutInvitationId; reason: RESERVATION_PURCHASE
  • [ ] Hourly batch booking (multiple slots in one PI) — multiple payments rows, all with the same checkoutInvitationId; confirmation page polls /api/payments/by-checkout-invitation/:id and resolves to all reservations
  • [ ] Hourly cancellation refund — reason: CANCELLATION_REFUND (USD_CENTS) or CREDIT_RESTORATION (CREDITS)
  • [ ] Waitlist deposit — reason: WAITLIST_DEPOSIT; staff cancels with refund — reason: CANCELLATION_REFUND
  • [ ] Promo redemption — reason: PROMO_REDEMPTION with offer title in notes
  • [ ] Staff manual stripe-balance adjustment — reason: STAFF_ADJUSTMENT with description in notes
  • [ ] Refund a charge — Transaction created with reason: REFUND and stripeRefundId set
  • [ ] Confirm dual-path transfer trigger: Stripe Connect → Transfers shows transfers for the verification flows above. Each Payment with a reservationId set should have stripe_charge_id populated within ~5s of charge time. (Transfers are now triggered from invoice handlers, not just charge.succeeded — see [[architecture/stripe/transfers]]. Idempotency key transfer-${charge.id} prevents double-charges if both paths execute.)
  • [ ] Bookkeeping dashboard loads without errors; bookkeeping_attributed_ledger rows for the new payments classify correctly

Watch Sentry for new error patterns from StripeWebhookService, PaymentIntentHandler, FulfillmentService for the first 48 hours.


Data-loss risks (running tally)

Things this PR might lose if backfills aren't run first:

Risk Source Mitigation
Lockout proration revenue records handleSubscriptionUpdate lockout branch missed billing_reason: manual invoices pre-PR #651 Step 1–2 backfill
Credit-sub initial Payment records fulfillCreditPackage didn't create Payment pre-PR #650 Step 3–4 backfill
Transaction → Stripe ID linkage for orphan Transactions Drop columns in 20260426130000 Step 3–4 backfill
Free-form reason text on staff-entered Transactions Migration 20260426140000 enum-ifies the column Migration auto-preserves unrecognized prose into transactions.notes before mapping
purchaseType/locationId charge_metadata attribution View 20260426160000 no longer reads from Stripe metadata Backfill scripts ensure every chargeable Payment row exists; metadata lookups become legitimately unused
Confirmation page polling for legacy in-flight checkouts Payment.checkoutInvitationId introduced in 20260426150000 is null for pre-deploy Payments Drains within 30 seconds (POLLING_INTERVAL_MS × TIMEOUT_MS) — any in-flight checkout that crosses the deploy boundary will time out and the user reloads to /account/overview
Credit-paid Payment.amount_cents has credit count instead of NULL (legacy bug) ReservationCreationService.createWithCredits previously stored credit counts in amount_cents (a column named for USD cents). Fixed in this PR to write NULL. Existing rows still hold credit values. Non-blocking: the staff Payments UI checks transaction.unit === 'CREDITS' first and renders correctly with the bolt icon. Bookkeeping views don't read payments.amount_cents for credit-unit Transactions, so no analytics drift. Optional one-time SQL cleanup: UPDATE payments p SET amount_cents = NULL FROM transactions t WHERE p.transaction_id = t.id AND t.unit = 'CREDITS' AND p.amount_cents IS NOT NULL
Connect transfer reliability for renewals/initial-setup Phase 7 removed Stripe-metadata fallback in handleChargeUpdated. Transfers were silently skipped when charge.succeeded arrived before the Payment row existed — Stripe doesn't reliably fire charge.updated to retry. Fixed in this PR: invoice handlers (renewal + initial-setup) call processChargeTransferForInvoice which delegates to handleChargeUpdated after the Payment is finalized. Idempotency-key safe with the existing webhook path. Verify by watching Stripe → Connect → Transfers during Step 6 lockout flows.

If new risks surface as ADR-008 work continues, append them here.


Rollback

The schema migrations are not designed to be rolled back. If a deploy fails:

  1. Revert the application code (Vercel rollback)
  2. Migrations stay applied — the new schema is forward-compatible with the old code only for the additive migrations (20260426110000, 20260426113000)
  3. Destructive migrations (20260426120000, 20260426130000) are forward-only. Old code can't run against the new schema.

If we need to abort mid-deploy, the safest fallback is to fix forward.