Skip to content

feat(billing): mirror Deploy plan into workspaces.deploy_plan via Stripe webhook#6397

Merged
Flo4604 merged 1 commit into
mainfrom
deploy-plan-signal
Jun 17, 2026
Merged

feat(billing): mirror Deploy plan into workspaces.deploy_plan via Stripe webhook#6397
Flo4604 merged 1 commit into
mainfrom
deploy-plan-signal

Conversation

@Flo4604

@Flo4604 Flo4604 commented Jun 9, 2026

Copy link
Copy Markdown
Member

Mirrors the workspace's Compute plan into workspaces.deploy_plan via the Stripe customer.subscription.* webhook, so the deploy gate and dashboard read entitlement from the local DB instead of calling Stripe in the hot path.

  • Detection based on the subscribed plan metadata plan key
  • Synced on created / updated / deleted; only writes when the value actually changes.

Part of ENG-2872 (the "reflect Deploy on the subscription locally" half).

@Flo4604

Flo4604 commented Jun 9, 2026

Copy link
Copy Markdown
Member Author

This stack of pull requests is managed by jj-ryu.

@vercel

vercel Bot commented Jun 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
dashboard Ready Ready Preview, Comment Jun 16, 2026 6:40pm
design Ready Ready Preview, Comment Jun 16, 2026 6:40pm

Request Review

@Flo4604 Flo4604 force-pushed the deploy-plan-signal branch from 97a2336 to 08168b3 Compare June 9, 2026 14:30
@Flo4604 Flo4604 force-pushed the deploy-plan-signal branch from 08168b3 to 7a51769 Compare June 9, 2026 14:35
@Flo4604 Flo4604 force-pushed the deploy-billing-flag branch from 453ea1b to cd4b05e Compare June 9, 2026 21:17
@Flo4604 Flo4604 force-pushed the deploy-plan-signal branch from 7a51769 to c4eb5b0 Compare June 9, 2026 21:17
@Flo4604 Flo4604 force-pushed the deploy-billing-flag branch from cd4b05e to 6aa771c Compare June 9, 2026 21:28
@Flo4604 Flo4604 force-pushed the deploy-plan-signal branch from c4eb5b0 to 4d61583 Compare June 9, 2026 21:28
@linear-code

linear-code Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

ENG-2872

@Flo4604 Flo4604 force-pushed the deploy-plan-signal branch from 4ab65cf to eb31b36 Compare June 11, 2026 10:38
@Flo4604 Flo4604 force-pushed the deploy-billing-flag branch from 48d2d98 to 9da5acd Compare June 11, 2026 11:55
@Flo4604 Flo4604 force-pushed the deploy-plan-signal branch from eb31b36 to 680c0e6 Compare June 11, 2026 11:55
@Flo4604 Flo4604 force-pushed the deploy-billing-flag branch from 9da5acd to c1052a0 Compare June 11, 2026 12:01
@Flo4604 Flo4604 force-pushed the deploy-plan-signal branch from 680c0e6 to bb6971f Compare June 11, 2026 12:01
@Flo4604 Flo4604 force-pushed the deploy-billing-flag branch from c1052a0 to e3fcc8c Compare June 11, 2026 15:51
@Flo4604 Flo4604 force-pushed the deploy-plan-signal branch from bb6971f to ce728cf Compare June 11, 2026 15:51
@Flo4604 Flo4604 force-pushed the deploy-billing-flag branch from e3fcc8c to ecbbffa Compare June 11, 2026 15:52
@Flo4604 Flo4604 force-pushed the deploy-plan-signal branch from ce728cf to f4593e8 Compare June 11, 2026 15:52

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 deployPlan column — nullable varchar(64) on the drizzle workspaces schema; DDL is applied via drizzle-kit push (the migrate script wired into .depot/workflows/job_pscale_pr.yaml), so no SQL migration file is needed.
  • New detectDeployPlan helper — scans sub.items.data[].price.metadata.plan (trimmed), returns the first recognized plan from DEPLOY_PLANS = [starter, pro, business], fails closed with a console.warn on an unrecognized value, and reads only the webhook payload (no extra Stripe calls). Solid unit coverage in deployPlan.test.ts.
  • Wire mirrorDeployPlan into the webhook — writes deployPlan only when it changed; called in subscription.updated (ahead of every skip-path), subscription.deleted (sets null), and subscription.created (best-effort lookup by stripeSubscriptionId).

⚠️ 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.

Pullfrog  | Fix it ➔View workflow run | Using Claude Opus𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — one minor suggestion inline. The created-case race flagged in the prior review remains open (the .created block 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.info in mirrorDeployPlan — logs workspaceId, subscriptionId, detectedPlan, previousPlan, and changed on every webhook call (subscription.created / updated / deleted), guarded by a Remove once verified comment. Detection, schema, deletion-clears-plan, and best-effort .created lookup are otherwise unchanged from the prior review.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread web/apps/dashboard/app/api/webhooks/stripe/route.ts Outdated
…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
Comment thread web/apps/dashboard/app/api/webhooks/stripe/route.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants