Appearance
Sales Referrals — Deploy Runbook
One-time / per-environment steps to ship the sales-referral overhaul. Run staging first, verify, then prod — never straight to prod. For the day-to-day console see Sales Referrals — Admin Setup.
This change reshapes the data model (splits Referral into SalesReferrer + attribution Referral), adds a payout state machine, a payout-release job, and new Stripe webhook events. So a deploy is code + webhook config + data migration + job scheduling, in that dependency order.
0. Pre-deploy audit — PR #387 Connect-account overlap ⚠️
PR #387 (referral Connect fix) shipped to main after this branch was cut. For zero-referral users it persisted the referral-payout Connect account id to Organization.connectSettings.stripeConnectAccountId. The new model keeps the referrer's account on SalesReferrer.referrerStripeConnectAccountId, and the migration does not copy from connectSettings — that field is shared with the storefront Connect feature, so a blind copy would mis-attribute a storefront account as a referral-payout account.
Before deploying to a given environment, audit (read-only) for anyone who onboarded via #387 and would otherwise appear "disconnected":
- Find orgs where
connectSettings.stripeConnectAccountIdis set and the owner/referrer'sSalesReferrer.referrerStripeConnectAccountIdis empty. - For each, confirm by hand whether that Connect account is a referral-payout account (vs a storefront account) before copying it onto the
SalesReferrer.
As of 2026-07-08 this is expected to be ~0 rows (#387 is not on prod yet and the referral feature is unlaunched). If the audit is empty, there's nothing to reconcile. The migration script header documents this too.
1. Ship code to staging
bash
cd platform && bash deploy-staging.sh(Full deploy — the change touches API, worker, and web. Don't use --api-only/--web-only here.)
2. Add the new Stripe webhook events (staging endpoint)
On the staging Stripe webhook endpoint, subscribe these. The first is easy to get wrong — the engine listens for invoice.paid, not invoice.payment_succeeded:
invoice.paid(commission creation trigger)invoice.payment_failedcustomer.subscription.updatedcustomer.subscription.deletedcharge.refundedcharge.dispute.created
invoice.payment_failed and the subscription/charge events are new for this change. Add them via the Stripe Dashboard (test mode) or the CLI, pointing at the staging endpoint.
3. Migrate the data (dry-run, then apply) — staging
The migration splits legacy Referral docs into SalesReferrer + attribution Referral, preserving _id so ReferralCommission references survive. It's idempotent.
bash
# DRY RUN first — writes nothing, prints the plan + any FK-integrity flags
MONGODB_URI="$(gcloud secrets versions access latest --secret=STAGING_MONGODB_URI)" \
pnpm --filter @gbp/api exec tsx src/scripts/migrate-referral-model.ts --dry-run
# Review the report. Then apply:
MONGODB_URI="$(gcloud secrets versions access latest --secret=STAGING_MONGODB_URI)" \
pnpm --filter @gbp/api exec tsx src/scripts/migrate-referral-model.tsRe-running is safe — converted docs lose referralCode and are skipped.
4. Verify on staging
- Console loads:
/admin/sales-referrersshows the roster with canonical totals. - Referrer flow: as a test user, Settings → Referrals → Generate code → Connect Stripe (Express test onboarding) → status resolves to Connected.
- Release job scheduled: confirm the worker registered the hourly
release_referral_payoutsjob.bashgcloud run services logs read gbp-worker-staging --region=us-central1 --limit=100 \ | grep -Ei "release_referral_payouts|referral-payout" - End-to-end (the real test): pay a test invoice for an attributed org → a
heldcommission appears. Temporarily lowerreferralPayoutHoldDays(or advance the hold) and confirm the release job transfers it and it flips topaidwith astripeTransferId. Refund the invoice on a still-heldcommission and confirm itvoids(full) or recomputes (partial).
5. Prod — repeat after sign-off
Only after staging is verified and the owner signs off:
bash
# (repeat the §0 audit against PROD_MONGODB_URI first)
cd platform && bash deploy-production.sh
# Add the SAME webhook events to the PROD Stripe webhook endpoint (live mode).
# Migrate prod (dry-run then apply):
MONGODB_URI="$(gcloud secrets versions access latest --secret=PROD_MONGODB_URI)" \
pnpm --filter @gbp/api exec tsx src/scripts/migrate-referral-model.ts --dry-run
MONGODB_URI="$(gcloud secrets versions access latest --secret=PROD_MONGODB_URI)" \
pnpm --filter @gbp/api exec tsx src/scripts/migrate-referral-model.ts6. Post-deploy verification (prod)
/admin/sales-referrersloads with real data; totals look sane.- The
release_referral_payoutsjob is scheduled ongbp-worker-prod. - Watch the first real
invoice.paidafter deploy create aheldcommission:bashgcloud run services logs read gbp-api-prod --region=us-central1 --limit=100 \ | grep -Ei "referral|commission" - After the 30-day hold on the first commission elapses, confirm it releases and pays out.
Config reference
| Setting | Where | Default |
|---|---|---|
| Payout hold window (days) | PlatformConfig.referralPayoutHoldDays | 30 |
| Default commission rate (%) | REFERRAL_DEFAULT_COMMISSION_PERCENT (per-rep override in console) | 7 |
| Attribution window (days) | REFERRAL_DEFAULT_ATTRIBUTION_WINDOW_DAYS (per-rep override in console) | 60 |
| Admin allowlist | PLATFORM_ADMIN_USER_IDS (+ PLATFORM_ADMIN_ENFORCE=true) | — |
| Capture cookie signing | reuses ENCRYPTION_KEY | — |
| Cross-subdomain capture cookie | REFERRAL_COOKIE_DOMAIN (optional, e.g. .localmarketing.so) | host-scoped |
| Release job cadence | release_referral_payouts cron | 15 * * * * (hourly) |
Rollback
Code rolls back by redeploying the previous revision (gcloud run services update-traffic ... --to-revisions=<prev>=100). The migration is not auto-reversible — it converts docs in place. If you must roll back after migrating, restore the referrals collection from the pre-migration Atlas snapshot. Because the migration preserves _id, ReferralCommission references stay valid either way.
Next: Failed Jobs Dashboard