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:
- Payment FK mutex (migration
20260426110000) —paymentsrows withstatus='PAID'must have exactly one of{reservation_id, credit_balance_id, waitlist_entry_id, organization_id}set. Any violator causesALTER TABLE ADD CONSTRAINTto fail. - Orphan Transactions with stripe IDs (migration
20260426130000) —transactionsrows withstripe_invoice_idorstripe_payment_intent_idset 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). - Enum mapping
'purchase' → CREDIT_GRANT(migration20260426140000) —transactions.reason='purchase'rows are assumed to be type=CREDIT/unit=CREDITS. Any with other shape will be silently misclassified asCREDIT_GRANTafter the column type change. - Direct-PI lockout charges (migration
20260426160000) — direct PI charges (stripe_invoice_id IS NULL) carryingcharge_metadata.purchaseType='lockout'no longer get attribution from metadata in the rewritten view. If any exist with no matchingPayment.reservationId, they classify asunattributed. 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
20260426130000is applied. That migration dropstransactions.stripe_invoice_idand.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
fulfillCreditPackagefix (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:
- Find every Transaction with
stripeInvoiceIdorstripePaymentIntentIdset, noRefundreason, no Payment linked viatransactionIdor by stripe-ID match - For each: query Stripe API to get the invoice/PI, follow to subscription, look up our entity (CreditBalance / Reservation by
stripeSubscriptionId) - If entity found: create
Payment(creditBalanceId | reservationId, transactionId, status=PAID, full Stripe linkage) - If entity not found and Stripe still has the invoice: create Payment with status=FAILED + diagnostic note
- 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
paymentsrow +user_credit_balancesrow both have correct linkage; ledger entry showsreason: CREDIT_PURCHASE(DEBIT) +CREDIT_GRANT(CREDIT, withbalanceIdset) - [ ] 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 —
paymentsPENDING row created; ledger entry showsreason: LOCKOUT_INITIAL; Stripe Connect transfer fires within seconds, destination matches location'sstripe_account_id,payments.stripe_charge_idpopulated - [ ] Lockout monthly renewal —
reason: LOCKOUT_RENEWAL; Stripe Connect transfer fires (triggered from invoice handler, not webhook race),payments.stripe_charge_idpopulated - [ ] Lockout add-insurance with
timing: immediate—paymentsrow created from manual invoice - [ ] Lockout room transfer —
paymentsrow created fromLockoutManagementService.updateLockout - [ ] Lockout proration —
reason: LOCKOUT_PRORATION - [ ] Org dedicated room subscription —
paymentsrow withorganizationIdset;reason: ORGANIZATION_ROOM - [ ] Hourly direct booking —
paymentsrow created withcheckoutInvitationId;reason: RESERVATION_PURCHASE - [ ] Hourly batch booking (multiple slots in one PI) — multiple
paymentsrows, all with the samecheckoutInvitationId; confirmation page polls/api/payments/by-checkout-invitation/:idand resolves to all reservations - [ ] Hourly cancellation refund —
reason: CANCELLATION_REFUND(USD_CENTS) orCREDIT_RESTORATION(CREDITS) - [ ] Waitlist deposit —
reason: WAITLIST_DEPOSIT; staff cancels with refund —reason: CANCELLATION_REFUND - [ ] Promo redemption —
reason: PROMO_REDEMPTIONwith offer title innotes - [ ] Staff manual stripe-balance adjustment —
reason: STAFF_ADJUSTMENTwith description innotes - [ ] Refund a charge —
Transactioncreated withreason: REFUNDandstripeRefundIdset - [ ] Confirm dual-path transfer trigger: Stripe Connect → Transfers shows transfers for the verification flows above. Each Payment with a
reservationIdset should havestripe_charge_idpopulated within ~5s of charge time. (Transfers are now triggered from invoice handlers, not justcharge.succeeded— see [[architecture/stripe/transfers]]. Idempotency keytransfer-${charge.id}prevents double-charges if both paths execute.) - [ ] Bookkeeping dashboard loads without errors;
bookkeeping_attributed_ledgerrows 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:
- Revert the application code (Vercel rollback)
- Migrations stay applied — the new schema is forward-compatible with the old code only for the additive migrations (
20260426110000,20260426113000) - 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.