From c856b945f1dd0d10bf2e97aa10b945bdfcfdcf50 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 00:52:08 -0800 Subject: [PATCH] feat(cloud): ENTITLEMENTS-B2 metadata.orca_plan on Stripe prices is the catalog join key Neil's Q1 answer: a plan id on the Stripe Price/Product maps to the repo catalog's entitlements. seatPriceMatch reads price.metadata.orca_plan (after env ids and price history, before lookup key and product); the catalog sync logs a lookup-key/tag mismatch; stripe-test-setup tags every price. --- cloud/apps/billing/scripts/stripe-test-setup.ts | 11 ++++++++--- cloud/apps/billing/src/catalog-sync.ts | 17 ++++++++++++++++- cloud/apps/billing/src/stripe.ts | 2 ++ cloud/apps/billing/test/lifecycle.test.ts | 12 ++++++++++++ cloud/apps/billing/test/stripe-fake.ts | 5 +++-- 5 files changed, 41 insertions(+), 6 deletions(-) diff --git a/cloud/apps/billing/scripts/stripe-test-setup.ts b/cloud/apps/billing/scripts/stripe-test-setup.ts index 4b4c5efbee0..9189ee73a64 100644 --- a/cloud/apps/billing/scripts/stripe-test-setup.ts +++ b/cloud/apps/billing/scripts/stripe-test-setup.ts @@ -1,4 +1,5 @@ -// One-time TEST MODE catalog setup: the Team product with monthly + annual seat prices, the +// One-time TEST MODE catalog setup (every Product and Price carries metadata.orca_plan, the join key +// to catalog/catalog.json): the Team product with monthly + annual seat prices, the // sandbox-seconds meter with its metered overage price, and a Customer Portal configuration // (monthly <-> annual switch, cancel; seats follow membership). Refuses any key that is not // sk_test_/rk_test_ and stops if Stripe ever reports a live-mode object. Prints the Worker vars. @@ -53,7 +54,9 @@ const monthly = await post( unit_amount: prices.teamMonthly, recurring: { interval: 'month' }, lookup_key: 'orca_team_seat_monthly', - transfer_lookup_key: true + transfer_lookup_key: true, + // The join key to the catalog (catalog/catalog.json plan id). + metadata: { orca_plan: 'team' } }, `price:team:month:${suffix}` ) @@ -65,7 +68,8 @@ const annual = await post( unit_amount: prices.teamAnnual, recurring: { interval: 'year' }, lookup_key: 'orca_team_seat_annual', - transfer_lookup_key: true + transfer_lookup_key: true, + metadata: { orca_plan: 'team' } }, `price:team:year:${suffix}` ) @@ -95,6 +99,7 @@ const overage = await post( transform_quantity: { divide_by: OVERAGE_SECONDS_PER_BILLED_UNIT, round: 'up' }, lookup_key: 'orca_sandbox_overage_per_minute', transfer_lookup_key: true, + metadata: { orca_plan: 'team', orca_role: 'overage' }, nickname: 'Sandbox overage, billed by the minute' }, `price:overage:minute:${suffix}` diff --git a/cloud/apps/billing/src/catalog-sync.ts b/cloud/apps/billing/src/catalog-sync.ts index 78cf50c6808..1062b4d5135 100644 --- a/cloud/apps/billing/src/catalog-sync.ts +++ b/cloud/apps/billing/src/catalog-sync.ts @@ -4,7 +4,8 @@ // - records every seat price a subscription is seen on (price history), so a subscriber on an // older price keeps mapping to its plan after the lookup key moves to a new price; // - maps a subscription item's price to a plan + interval: the configured env ids, any price we -// have seen for the plan, a price holding the plan's lookup key, or one on the plan's product. +// have seen for the plan, the price's metadata.orca_plan (the join key every Orca price carries), +// a price holding the plan's lookup key, or one on the plan's product. import { invalidateCatalog, loadCatalog, type Catalog, type PlanPriceRow } from './catalog' import type { Env } from './env' import { BILLING_INTERVALS, PLAN_IDS, planForPrice, type BillingInterval, type PlanId } from './plans' @@ -64,6 +65,15 @@ export function seatPriceMatch(env: Env, catalog: Catalog | null, price: StripeP if (!interval) { return null } + // The join key: metadata.orca_plan on the Price (Stripe metadata is ours; customers can't edit it). + const tagged = price.metadata?.orca_plan + if (tagged) { + const plan = catalog.plans.get(tagged) + const id = plan?.kind === 'self_serve' ? asPlan(plan.id) : null + if (id) { + return { plan: id, interval } + } + } for (const plan of catalog.plans.values()) { const id = asPlan(plan.id) if (!id || plan.kind !== 'self_serve') { @@ -193,6 +203,11 @@ export async function syncCatalogPrices( missing.push(entry.key) continue } + if (price.metadata?.orca_plan && price.metadata.orca_plan !== entry.planId) { + // The lookup key says one plan, the price's own tag another: a Stripe setup mistake. The + // lookup key wins for Checkout; say so loudly. + console.error(JSON.stringify({ error: 'catalog_price_plan_mismatch', lookupKey: entry.key, plan: entry.planId, tagged: price.metadata.orca_plan })) + } statements.push( priceStatement(db, { price, planId: entry.planId, role: entry.role, interval: entry.interval, checkout: true, setCheckout: true, now }), // The key moved: the previous holder stays as history only. diff --git a/cloud/apps/billing/src/stripe.ts b/cloud/apps/billing/src/stripe.ts index aed96504e96..992b26a96b1 100644 --- a/cloud/apps/billing/src/stripe.ts +++ b/cloud/apps/billing/src/stripe.ts @@ -249,6 +249,8 @@ export type StripePrice = { unit_amount?: number | null unit_amount_decimal?: string | null lookup_key?: string | null + /** `orca_plan` names the catalog plan this price sells (the join key, set on every price we create). */ + metadata?: Record | null product?: string | { id: string } | null recurring?: { interval?: string; usage_type?: string } | null } diff --git a/cloud/apps/billing/test/lifecycle.test.ts b/cloud/apps/billing/test/lifecycle.test.ts index e8dc7c4a39f..4c4af5d2b32 100644 --- a/cloud/apps/billing/test/lifecycle.test.ts +++ b/cloud/apps/billing/test/lifecycle.test.ts @@ -68,6 +68,18 @@ describe('a price change never orphans existing subscribers (planForPrice)', () expect(h.raw.prepare("SELECT plan_id, role, interval FROM plan_prices WHERE stripe_price_id = 'price_team_month_v3'").get()).toEqual({ plan_id: 'team', role: 'seat', interval: 'month' }) }) + it('maps a price by its metadata.orca_plan join key (no lookup key, unknown product)', async () => { + const h = await createHarness({ ENTITLEMENTS_MODE: 'on' }) + h.stripe.prices.set('price_team_tagged', { id: 'price_team_tagged', active: true, currency: 'usd', unit_amount: 4000, product: 'prod_team_promo', metadata: { orca_plan: 'team' }, recurring: { interval: 'year', usage_type: 'licensed' } }) + const token = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner', email: 'a@example.com' }) + const auth = { authorization: `Bearer ${token}` } + await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team', seats: 1 }, headers: auth }) + const customer = [...h.stripe.customers.keys()][0]! + const subscription = h.stripe.putSubscription({ customer, accountId: 'org:o1', basePrice: 'price_team_tagged', quantity: 1, periodStart: PERIOD_START, periodEnd: PERIOD_END }) + expect((await h.webhook(checkoutCompleted({ customer, subscription: subscription.id, accountId: 'org:o1' }))).body).toEqual({ received: true }) + expect(await entitlement(h, auth)).toMatchObject({ plan: 'team', interval: 'year', canStartSandbox: true }) + }) + it('still applies status for a known subscriber on an unmappable price', async () => { const { h, auth, subscription } = await subscribed('on') subscription.items.data[0]!.price = { id: 'price_mystery', recurring: { usage_type: 'licensed' } } diff --git a/cloud/apps/billing/test/stripe-fake.ts b/cloud/apps/billing/test/stripe-fake.ts index 3e0e28f296f..659241cd6fe 100644 --- a/cloud/apps/billing/test/stripe-fake.ts +++ b/cloud/apps/billing/test/stripe-fake.ts @@ -23,6 +23,7 @@ type FakePrice = { unit_amount?: number | null unit_amount_decimal?: string | null lookup_key?: string | null + metadata?: Record product?: string recurring: { interval?: string; usage_type: 'licensed' | 'metered' } } @@ -38,8 +39,8 @@ type FakeItem = { /** The test-mode catalog the harness env names (STRIPE_PRICE_*), like stripe-test-setup.ts makes. */ export function defaultFakePrices(): FakePrice[] { return [ - { id: 'price_team_month', active: true, currency: 'usd', unit_amount: 5000, unit_amount_decimal: '5000', lookup_key: 'orca_team_seat_monthly', product: 'prod_team', recurring: { interval: 'month', usage_type: 'licensed' } }, - { id: 'price_team_year', active: true, currency: 'usd', unit_amount: 48000, unit_amount_decimal: '48000', lookup_key: 'orca_team_seat_annual', product: 'prod_team', recurring: { interval: 'year', usage_type: 'licensed' } }, + { id: 'price_team_month', active: true, currency: 'usd', unit_amount: 5000, unit_amount_decimal: '5000', lookup_key: 'orca_team_seat_monthly', metadata: { orca_plan: 'team' }, product: 'prod_team', recurring: { interval: 'month', usage_type: 'licensed' } }, + { id: 'price_team_year', active: true, currency: 'usd', unit_amount: 48000, unit_amount_decimal: '48000', lookup_key: 'orca_team_seat_annual', metadata: { orca_plan: 'team' }, product: 'prod_team', recurring: { interval: 'year', usage_type: 'licensed' } }, { id: 'price_overage', active: true, currency: 'usd', unit_amount: null, unit_amount_decimal: '1.666666666667', lookup_key: 'orca_sandbox_overage_per_minute', product: 'prod_overage', recurring: { interval: 'month', usage_type: 'metered' } } ] }