feat(billing): mirror Deploy plan into workspaces.deploy_plan via Stripe webhook#6397
Conversation
97a2336 to
08168b3
Compare
08168b3 to
7a51769
Compare
453ea1b to
cd4b05e
Compare
7a51769 to
c4eb5b0
Compare
cd4b05e to
6aa771c
Compare
c4eb5b0 to
4d61583
Compare
6aa771c to
530f9da
Compare
4d61583 to
0563d4d
Compare
4ab65cf to
eb31b36
Compare
48d2d98 to
9da5acd
Compare
eb31b36 to
680c0e6
Compare
9da5acd to
c1052a0
Compare
680c0e6 to
bb6971f
Compare
c1052a0 to
e3fcc8c
Compare
bb6971f to
ce728cf
Compare
e3fcc8c to
ecbbffa
Compare
ce728cf to
f4593e8
Compare
There was a problem hiding this comment.
Important
The customer.subscription.created mirror is skipped in the common case because the workspace isn't linked to the subscription yet when the webhook fires. New Deploy subscriptions may not get deployPlan populated promptly, undercutting the create path of this PR's goal.
Reviewed changes — mirrors the workspace's Deploy plan into a new workspaces.deploy_plan column via the customer.subscription.* Stripe webhooks so the deploy gate and dashboard can read entitlement from the local DB instead of Stripe.
- Add
deployPlancolumn — nullablevarchar(64)on the drizzleworkspacesschema; DDL is applied viadrizzle-kit push(themigratescript wired into.depot/workflows/job_pscale_pr.yaml), so no SQL migration file is needed. - New
detectDeployPlanhelper — scanssub.items.data[].price.metadata.plan(trimmed), returns the first recognized plan fromDEPLOY_PLANS = [starter, pro, business], fails closed with aconsole.warnon an unrecognized value, and reads only the webhook payload (no extra Stripe calls). Solid unit coverage indeployPlan.test.ts. - Wire
mirrorDeployPlaninto the webhook — writesdeployPlanonly when it changed; called insubscription.updated(ahead of every skip-path),subscription.deleted(setsnull), andsubscription.created(best-effort lookup bystripeSubscriptionId).
⚠️ subscription.created mirror loses the race with the workspace→subscription link
The created handler looks up the workspace by stripeSubscriptionId == sub.id, but the row isn't linked to the subscription at that moment. The createSubscription mutation calls Stripe first and only writes stripeSubscriptionId to the DB after the create call returns, while Stripe fires customer.subscription.created the instant the subscription exists. The webhook commonly wins the race, so wsForDeploy is null and the mirror is skipped.
The inline comment leans on "a later subscription.updated syncs it," but Stripe only emits subscription.updated on an actual change. A brand-new Deploy subscription that is never modified produces no such event, so deployPlan stays null until some unrelated future billing change happens to fire an updated webhook. The createSubscription mutation itself does not set deployPlan, so there is no in-band write to fall back on.
Technical details
# `subscription.created` mirror loses the race with the workspace→subscription link
## Affected sites
- `web/apps/dashboard/app/api/webhooks/stripe/route.ts:440-446` — looks up `wsForDeploy` by `stripeSubscriptionId == sub.id`; null in the common case at create time.
- `web/apps/dashboard/lib/trpc/routers/stripe/createSubscription.ts:158-165` — writes `stripeSubscriptionId: sub.id` to the DB only *after* `stripe.subscriptions.create` returns, so the link doesn't exist when the `created` webhook arrives.
## Required outcome
- A workspace that creates a Deploy subscription has `deployPlan` populated promptly (within the create flow), not contingent on a future unrelated `subscription.updated` event.
## Suggested approach (optional)
- Set `deployPlan: detectDeployPlan(sub)` inline in the `createSubscription` transaction at `createSubscription.ts:158-165`, alongside `stripeSubscriptionId`/`tier`. That makes the create path authoritative and reduces the webhook to a redundant best-effort safety net. The `created` webhook block can stay as-is for paths that create subscriptions outside this mutation.
## Open questions for the human
- Are there subscription-creation paths other than the `createSubscription` mutation (e.g. Stripe-dashboard-created subs, imports) that rely solely on the webhook? If so, the webhook race still leaves those unmirrored until the next `updated`, and a backfill/reconcile may be warranted.ℹ️ Go-side workspaces schema has no deploy_plan column yet
The PR's stated goal is that the deploy gate reads entitlement from the local DB, but the deploy gate is a Go service whose schema-of-record for sqlc codegen is pkg/mysql/schema/workspaces.sql, which does not define deploy_plan. This is expected for the write-only half of ENG-2872 and there's no live drift — drizzle-kit push owns the real column on PlanetScale, and the Go .sql is a codegen input, not applied DDL. Flagging only so the follow-up "read half" PR remembers to add the column there before the Go reader can select it.
Claude Opus | 𝕏
There was a problem hiding this comment.
ℹ️ No critical issues — one minor suggestion inline. The created-case race flagged in the prior review remains open (the
.createdblock is unchanged), but it is already on record so I am not re-raising it here.
Reviewed changes — the branch was rebased onto newer main since the prior review at a204b01; the only content change is a temporary debug log added inside mirrorDeployPlan.
- Temporary
console.infoinmirrorDeployPlan— logsworkspaceId,subscriptionId,detectedPlan,previousPlan, andchangedon every webhook call (subscription.created/updated/deleted), guarded by aRemove once verifiedcomment. Detection, schema, deletion-clears-plan, and best-effort.createdlookup are otherwise unchanged from the prior review.
Claude Opus | 𝕏
…ipe webhook Adds a local deploy_plan signal so the deploy gate and dashboard never call Stripe in the hot path. The Stripe customer.subscription.* webhook detects the Deploy plan-fee price on the subscription's items and writes workspaces.deploy_plan (starter|pro|business), clearing it when no Deploy item is present or the subscription is deleted. Data only: no gate, no UI, no enforcement yet. ENG-2872

Mirrors the workspace's Compute plan into
workspaces.deploy_planvia the Stripecustomer.subscription.*webhook, so the deploy gate and dashboard read entitlement from the local DB instead of calling Stripe in the hot path.Part of ENG-2872 (the "reflect Deploy on the subscription locally" half).