From 23cc81bf767baa1d1369fe1511cd6ca4aa730a71 Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:23:49 +0300 Subject: [PATCH 1/4] feat(billing): companion monthly usage subscription for yearly plans, flag off by default A second free-base monthly subscription carries the metered prices and monthly meter credits beside a yearly plan, so overage is invoiced monthly. Behind NUXT_POLAR_COMPANION_USAGE (default off). --- nuxt.config.ts | 5 + scripts/polar-sync.ts | 77 +++++++- server/api/billing/webhook/[provider].post.ts | 108 +++++++++- .../workspaces/[workspaceId]/index.delete.ts | 6 +- server/plugins/companion-reconciler.ts | 34 ++++ server/providers/database.ts | 3 + server/providers/payment/plugins/polar.ts | 60 +++++- server/providers/payment/types.ts | 42 ++++ .../providers/postgres-db/payment-accounts.ts | 18 ++ .../providers/supabase-db/payment-accounts.ts | 14 ++ server/utils/companion-subscription.ts | 134 +++++++++++++ server/utils/migrate-revoke.ts | 6 + server/utils/overage-lock.ts | 29 ++- ...ling-webhook-companion.integration.test.ts | 186 ++++++++++++++++++ .../billing-webhook.integration.test.ts | 2 +- .../unit/companion-subscription-util.test.ts | 123 ++++++++++++ tests/unit/companion-subscription.test.ts | 140 +++++++++++++ tests/unit/migrate-revoke-route.test.ts | 20 ++ tests/unit/overage-lock.test.ts | 39 ++++ 19 files changed, 1027 insertions(+), 19 deletions(-) create mode 100644 server/plugins/companion-reconciler.ts create mode 100644 server/utils/companion-subscription.ts create mode 100644 tests/integration/billing-webhook-companion.integration.test.ts create mode 100644 tests/unit/companion-subscription-util.test.ts create mode 100644 tests/unit/companion-subscription.test.ts diff --git a/nuxt.config.ts b/nuxt.config.ts index 0135819b..9983d273 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -123,6 +123,11 @@ export default defineNuxtConfig({ proBundleProductId: '', // NUXT_POLAR_PRO_BUNDLE_PRODUCT_ID starterYearlyProductId: '', // NUXT_POLAR_STARTER_YEARLY_PRODUCT_ID proYearlyProductId: '', // NUXT_POLAR_PRO_YEARLY_PRODUCT_ID + // Monthly usage subscription opened beside a yearly plan (overage billed monthly). Off unless the flag is true + // AND the plan's companion product is set. + starterCompanionProductId: '', // NUXT_POLAR_STARTER_COMPANION_PRODUCT_ID + proCompanionProductId: '', // NUXT_POLAR_PRO_COMPANION_PRODUCT_ID + companionUsage: false, // NUXT_POLAR_COMPANION_USAGE }, migrate: { // NUXT_MIGRATE_CLAIM_PUBLIC_KEY — Contentrain Migrate's Ed25519 public key diff --git a/scripts/polar-sync.ts b/scripts/polar-sync.ts index 36fa72d4..5d5e8fbc 100644 --- a/scripts/polar-sync.ts +++ b/scripts/polar-sync.ts @@ -308,7 +308,7 @@ function findExistingProduct( * credits grant per billing cycle, so their units are a pricing decision (see the warning this step prints). */ const VARIANT_SLUGS = ['bundle', 'yearly'] as const -type VariantSlug = (typeof VARIANT_SLUGS)[number] +type VariantSlug = (typeof VARIANT_SLUGS)[number] | 'companion' function findVariantProduct(products: ProductSummary[], slug: BillablePlan, variant: VariantSlug): ProductSummary | undefined { return products.find(p => p.metadata?.contentrain_slug === slug && p.metadata?.contentrain_catalog === CATALOG_VERSION @@ -466,8 +466,8 @@ function creditDescription(slug: BillablePlan, item: { units: number, meterName: } /** Stable identity for a benefit this script owns. */ -function creditBenefitKey(slug: BillablePlan, meterName: string): string { - return `contentrain:${slug}:${meterName}` +function creditBenefitKey(slug: BillablePlan, meterName: string, variant: 'monthly' | 'companion' = 'monthly'): string { + return variant === 'companion' ? `contentrain:${slug}:companion:${meterName}` : `contentrain:${slug}:${meterName}` } interface CreditBenefitPlan { @@ -494,6 +494,7 @@ async function syncMeterCredits( productId: string, meterIdByName: Map, existingBenefits: Array>, + variant: 'monthly' | 'companion' = 'monthly', ): Promise { const planned: CreditBenefitPlan[] = [] @@ -524,7 +525,7 @@ async function syncMeterCredits( continue } - const key = creditBenefitKey(slug, meterDef.name) + const key = creditBenefitKey(slug, meterDef.name, variant) const found = existingBenefits.find(b => (b.metadata as Record | undefined)?.contentrain_key === key, ) @@ -578,7 +579,7 @@ async function syncMeterCredits( const created = await polar.benefits.create({ type: 'meter_credit', description: creditDescription(slug, item), - metadata: { contentrain_key: creditBenefitKey(slug, item.meterName) }, + metadata: { contentrain_key: creditBenefitKey(slug, item.meterName, variant) }, properties: { units: item.units, rollover: false, meterId: item.meterId }, } as never) benefitIds.push((created as unknown as { id: string }).id) @@ -795,27 +796,85 @@ async function syncProduct( } } +/** + * The monthly usage product a yearly plan's companion subscription is opened on (`ensureCompanionSubscription` in + * the Polar plugin): a free base price, the plan's metered prices and its monthly meter credits, so overage is billed + * monthly whatever the plan's billing frequency. Create-if-missing; an existing product only has its meter credits + * reconciled. Metered price drift is not rotated here: it is reported, then fixed in the dashboard. + * Nothing on a subscription uses it until `NUXT_POLAR_COMPANION_USAGE` is on. + */ +async function syncCompanionProduct( + slug: BillablePlan, + meterIdByName: Map, + existingProducts: ProductSummary[], +): Promise { + const name = `Studio ${PLAN_PRICING[slug].name} Usage (monthly)` + const blueprint = buildMeteredPriceBlueprint(meterIdByName) + const existing = findVariantProduct(existingProducts, slug, 'companion') + if (existing) { + summary.products[`${slug}#companion`] = existing.id + const attached = new Set(existing.prices.filter(p => !isPriceArchived(p)).filter(p => getPriceType(p) === 'metered_unit').map(p => getMeteredPriceMeterId(p))) + const missing = blueprint.filter(m => !attached.has(m.meterId)) + if (missing.length > 0) { + summary.warnings.push(`"${name}" has no metered price for: ${missing.map(m => m.meterName).join(', ')}. Add them in the Polar dashboard (archive-and-recreate is not automated for the companion).`) + } + else { + console.log(` ✓ product "${name}" in sync (${existing.id})`) + } + await syncMeterCredits(slug, existing.id, meterIdByName, existing.benefits ?? [], 'companion') + return + } + if (!APPLY) { + console.log(` + product "${name}" would be created — free base + ${blueprint.length} metered prices, monthly`) + await syncMeterCredits(slug, 'dry-run-product', meterIdByName, [], 'companion') + return + } + try { + const created = await polar.products.create({ + recurringInterval: 'month', + name, + description: `Monthly usage of ${PLAN_PRICING[slug].name}: the plan's included allowance each month, then metered overage. Opened beside the yearly plan.`, + metadata: { contentrain_slug: slug, contentrain_catalog: CATALOG_VERSION, contentrain_variant: 'companion' }, + prices: [ + { amountType: 'free' }, + ...blueprint.map(m => ({ amountType: 'metered_unit' as const, meterId: m.meterId, unitAmount: m.unitAmountCents })), + ], + }) + summary.products[`${slug}#companion`] = created.id + console.log(` + product "${name}" created (${created.id}) — free base + ${blueprint.length} metered prices`) + await syncMeterCredits(slug, created.id, meterIdByName, [], 'companion') + } + catch (err) { + const msg = err instanceof Error ? err.message : String(err) + summary.warnings.push(`Failed to create product "${name}": ${msg}`) + console.error(` ✗ product "${name}" failed: ${msg}`) + } +} + // ─── Main ──────────────────────────────────────────────────────────────── async function main() { console.log(`[polar-sync] server=${server} mode=${APPLY ? 'APPLY' : 'dry-run'}${ROTATE_PRICES ? ' rotate-prices' : ''}`) if (!APPLY) console.log('[polar-sync] dry run — nothing will be written. Re-run with --apply to perform it.') - console.log('\n[polar-sync] step 1/3 — syncing meters') + console.log('\n[polar-sync] step 1/4 — syncing meters') const existingMeters = await listAllMeters() const meterIdByName = await syncMeters(existingMeters) - console.log('\n[polar-sync] step 2/3 — syncing products, prices + included units') + console.log('\n[polar-sync] step 2/4 — syncing products, prices + included units') const existingProducts = await listAllProducts() for (const slug of BILLABLE_PLAN_SLUGS) { await syncProduct(slug, meterIdByName, existingProducts) } - console.log('\n[polar-sync] step 3/3 — syncing the Migrate-with-Studio bundle + yearly products (fixed price only)') + console.log('\n[polar-sync] step 3/4 — syncing the Migrate-with-Studio bundle + yearly products (fixed price only)') for (const slug of BILLABLE_PLAN_SLUGS) { for (const variant of VARIANT_SLUGS) await syncVariantProduct(slug, variant, existingProducts) } - console.log(' ! metered prices and meter credits are not attached to these products yet: a yearly period grants credits per billing cycle, so the units are a pricing decision.') + console.log(' ! metered prices and meter credits are not attached to these products: a yearly period grants credits per billing cycle. Overage on a yearly plan is billed monthly by the companion usage product below.') + + console.log('\n[polar-sync] step 4/4 — syncing the monthly usage (companion) products') + for (const slug of BILLABLE_PLAN_SLUGS) await syncCompanionProduct(slug, meterIdByName, existingProducts) console.log('\n[polar-sync] summary') if (summary.warnings.length > 0) { diff --git a/server/api/billing/webhook/[provider].post.ts b/server/api/billing/webhook/[provider].post.ts index d1278004..3becaa97 100644 --- a/server/api/billing/webhook/[provider].post.ts +++ b/server/api/billing/webhook/[provider].post.ts @@ -17,8 +17,10 @@ import { bootstrapPaymentPlugins, resolvePlugin } from '../../../providers/payme import type { PaymentPluginConfig } from '../../../providers/payment' import { PLAN_PRICING, normalizePlan } from '../../../../shared/utils/license' import { emailTemplate } from '../../../utils/content-strings' -import { BILLABLE_METERS_KEY, reconcileOverageLock } from '../../../utils/overage-lock' +import { BILLABLE_METERS_KEY, COMPANION_METERS_KEY, COMPANION_SUBSCRIPTION_KEY, reconcileOverageLock } from '../../../utils/overage-lock' import type { OverageLockAccount } from '../../../utils/overage-lock' +import { cancelCompanionSubscription, companionSubscriptionIdOf, openCompanionSubscription } from '../../../utils/companion-subscription' +import type { WebhookResult } from '../../../providers/payment/types' type Db = ReturnType @@ -39,8 +41,11 @@ const ACTIVATION_EMAIL_KEY = 'activation_email' */ const RECOVERY_EMAIL_KEY = 'recovery_email' -/** Keys only `setPaymentAccountMetadataKey` writes; upserts built from a read keep them. */ -const CLAIMED_METADATA_KEYS = [ACTIVATION_EMAIL_KEY, RECOVERY_EMAIL_KEY] +/** + * Keys only `setPaymentAccountMetadataKey` writes; upserts built from a read keep them. The companion usage + * subscription's keys are among them: the plan subscription's events must not wipe what its companion recorded. + */ +const CLAIMED_METADATA_KEYS = [ACTIVATION_EMAIL_KEY, RECOVERY_EMAIL_KEY, COMPANION_SUBSCRIPTION_KEY, COMPANION_METERS_KEY] /** The past-due episode a read row is in, as its recovery claim value. */ function pastDueEpisode(row: Record | null | undefined): string { @@ -243,6 +248,77 @@ function formatFriendlyDate(iso: string): string { }) } +/** + * A companion usage subscription's event (`WebhookResult.companion`). It is recorded beside the account and + * never writes the account's subscription, status, period, plan or workspace: the plan subscription owns those. + * What it changes is which meters the account can bill, so the overage lock is lined up again afterwards. + */ +async function applyCompanionEvent(db: Db, result: WebhookResult): Promise { + if (!result.workspaceId || !result.subscriptionId) { + // eslint-disable-next-line no-console -- a companion event that names no workspace cannot be placed + console.error('[companion] ALARM event without a workspace or subscription id; nothing recorded', { event: result.event, subscriptionId: result.subscriptionId }) + return + } + const workspaceId = result.workspaceId + const account = await db.getActivePaymentAccount(workspaceId) + if (!account) return + const recorded = companionSubscriptionIdOf(account.plugin_metadata) + // A late event about a companion the account has since replaced says nothing about the current one. + if (recorded && recorded !== result.subscriptionId) return + + if (result.event === 'subscription.canceled') { + await db.setPaymentAccountMetadataKey({ workspaceId, key: COMPANION_METERS_KEY, value: '', when: 'different' }) + await db.setPaymentAccountMetadataKey({ workspaceId, key: COMPANION_SUBSCRIPTION_KEY, value: '', when: 'different' }) + } + else if (result.event === 'subscription.created' || result.event === 'subscription.updated') { + await db.setPaymentAccountMetadataKey({ workspaceId, key: COMPANION_SUBSCRIPTION_KEY, value: result.subscriptionId, when: 'different' }) + if (result.billableMeters) { + await db.setPaymentAccountMetadataKey({ workspaceId, key: COMPANION_METERS_KEY, value: result.billableMeters.join(','), when: 'different' }) + } + if (result.subscriptionStatus === 'past_due') { + // eslint-disable-next-line no-console -- the alarm: the usage invoice failed; the plan subscription's own state is untouched + console.error(`[companion] ALARM usage subscription ${result.subscriptionId} of workspace ${workspaceId} is past due`) + } + } + else { + return + } + + // Line the toggles up with the meters the account can bill now. Only `plugin_metadata`'s suspended list can + // change, and it is written back with the row's own values. + const fresh = await db.getActivePaymentAccount(workspaceId) + if (!fresh) return + const overageLock = await planOverageLock(db, { + workspaceId, + storedPluginMetadata: fresh.plugin_metadata ?? null, + account: { + subscription_status: (fresh.subscription_status as string | null) ?? null, + trial_ends_at: (fresh.trial_ends_at as string | null) ?? null, + current_period_start: (fresh.current_period_start as string | null) ?? null, + current_period_end: (fresh.current_period_end as string | null) ?? null, + }, + }) + if (overageLock.pluginMetadata) { + await db.upsertPaymentAccount({ + workspaceId, + provider: fresh.provider as string, + customerId: fresh.customer_id as string, + subscriptionId: (fresh.subscription_id as string | null) ?? null, + subscriptionStatus: (fresh.subscription_status as string | null) ?? null, + currentPeriodStart: (fresh.current_period_start as string | null) ?? null, + currentPeriodEnd: (fresh.current_period_end as string | null) ?? null, + trialEndsAt: (fresh.trial_ends_at as string | null) ?? null, + cancelAtPeriodEnd: Boolean(fresh.cancel_at_period_end), + gracePeriodEndsAt: (fresh.grace_period_ends_at as string | null) ?? null, + plan: (fresh.plan as string | null) ?? null, + pluginMetadata: overageLock.pluginMetadata, + preserveMetadataKeys: CLAIMED_METADATA_KEYS, + isActive: true, + }) + } + await overageLock.commit() +} + export default defineEventHandler(async (event) => { const providerKey = getRouterParam(event, 'provider') ?? '' @@ -275,6 +351,12 @@ export default defineEventHandler(async (event) => { const db = useDatabaseProvider() + // A companion usage subscription never reaches the plan subscription's branches below. + if (result.companion) { + await applyCompanionEvent(db, result) + return { received: true } + } + switch (result.event) { case 'subscription.created': { // Fresh subscription — upsert the active account for this workspace. @@ -321,6 +403,14 @@ export default defineEventHandler(async (event) => { isActive: true, }) await overageLock.commit() + // A yearly plan gets its monthly usage subscription (off unless configured; never fails this webhook). + await openCompanionSubscription(provider, db, { + workspaceId: result.workspaceId, + plan: result.plan, + customerId: result.customerId, + subscriptionId: result.subscriptionId, + productId: result.productId, + }) // A subscription started from a Migrate grant's checkout uses the // grant up: no second included trial after cancel-and-resubscribe. // Idempotent — whichever of created/updated arrives first marks it. @@ -433,6 +523,16 @@ export default defineEventHandler(async (event) => { await db.setPaymentAccountCreditUnit({ workspaceId: result.workspaceId, unit: updatedUnit, periodKey }) } await overageLock.commit() + // A subscription that predates the flag, or whose companion failed to open, gets it here. + if (!companionSubscriptionIdOf(existingAccount?.plugin_metadata)) { + await openCompanionSubscription(provider, db, { + workspaceId: result.workspaceId, + plan: result.plan, + customerId: result.customerId, + subscriptionId: result.subscriptionId, + productId: result.productId, + }) + } // A subscription started from a Migrate grant's checkout uses the // grant up: no second included trial after cancel-and-resubscribe. // Idempotent — whichever of created/updated arrives first marks it. @@ -486,6 +586,8 @@ export default defineEventHandler(async (event) => { const activeSubscriptionId = (priorAccount?.subscription_id as string | null | undefined) ?? null if (activeSubscriptionId && result.subscriptionId && activeSubscriptionId !== result.subscriptionId) break const canceledPlan = result.plan ?? (priorAccount?.plan as string | null) + // Its usage subscription ends with it: left running, it would bill the customer's usage with no plan. + await cancelCompanionSubscription(provider, priorAccount?.plugin_metadata, `plan subscription ${result.subscriptionId ?? 'unknown'} ended`) await db.archiveActivePaymentAccount(result.workspaceId) await db.updateWorkspace('', result.workspaceId, { plan: 'free', diff --git a/server/api/workspaces/[workspaceId]/index.delete.ts b/server/api/workspaces/[workspaceId]/index.delete.ts index 3c00b967..d4f54801 100644 --- a/server/api/workspaces/[workspaceId]/index.delete.ts +++ b/server/api/workspaces/[workspaceId]/index.delete.ts @@ -24,6 +24,8 @@ * github_installation_id rows, and the next billing-status read will * pick up provider-side cancellations. */ +import { cancelCompanionSubscription } from '../../../utils/companion-subscription' + export default defineEventHandler(async (event) => { const session = requireAuth(event) const workspaceId = getRouterParam(event, 'workspaceId') @@ -86,8 +88,10 @@ export default defineEventHandler(async (event) => { const subscriptionId = (account?.subscription_id as string | null) ?? null if (subscriptionId) { const payment = usePaymentProvider() - if (payment) + if (payment) { + await cancelCompanionSubscription(payment, account?.plugin_metadata, `workspace ${workspaceId} deleted`) await payment.cancelSubscription(subscriptionId) + } } } catch (err: unknown) { diff --git a/server/plugins/companion-reconciler.ts b/server/plugins/companion-reconciler.ts new file mode 100644 index 00000000..7461adc5 --- /dev/null +++ b/server/plugins/companion-reconciler.ts @@ -0,0 +1,34 @@ +/** + * Companion usage subscription reconciler — Nitro plugin. + * + * Every 6 hours, opens the monthly usage subscription for every active yearly plan that lacks one: a webhook + * that failed to open it, or a subscription that predates the flag (`reconcileCompanionSubscriptions`). Does + * nothing unless the provider has companions configured (its `ensureCompanionSubscription` returns null). + */ +import { reconcileCompanionSubscriptions } from '../utils/companion-subscription' +import { useDatabaseProvider, usePaymentProvider } from '../utils/providers' + +const INTERVAL_MS = 6 * 60 * 60 * 1000 + +export default defineNitroPlugin((nitroApp) => { + setTimeout(() => runReconcile().catch(logFailure), 120_000) + const interval = setInterval(() => { + runReconcile().catch(logFailure) + }, INTERVAL_MS) + nitroApp.hooks.hook('close', () => clearInterval(interval)) +}) + +function logFailure(err: unknown) { + // eslint-disable-next-line no-console -- scheduled background job; failure must surface somewhere + console.error('[companion] Scheduled reconcile failed:', err) +} + +async function runReconcile(): Promise { + const payment = usePaymentProvider() + if (!payment?.ensureCompanionSubscription) return + const summary = await reconcileCompanionSubscriptions(payment, useDatabaseProvider(), 'polar') + if (summary.checked > 0) { + // eslint-disable-next-line no-console -- scheduled job summary + console.info('[companion] reconcile', summary) + } +} diff --git a/server/providers/database.ts b/server/providers/database.ts index 4b098317..87b15784 100644 --- a/server/providers/database.ts +++ b/server/providers/database.ts @@ -1027,6 +1027,9 @@ export interface DatabaseProvider { /** Return the single active payment account for a workspace, if any. */ getActivePaymentAccount: (workspaceId: string) => Promise + /** Active payment accounts of one provider, oldest first (ops jobs; filter the rows in the caller). */ + listActivePaymentAccounts: (provider: string, limit: number) => Promise + /** * Upsert a payment account keyed on (workspace_id, provider, customer_id). * diff --git a/server/providers/payment/plugins/polar.ts b/server/providers/payment/plugins/polar.ts index 38be0b0e..9831b136 100644 --- a/server/providers/payment/plugins/polar.ts +++ b/server/providers/payment/plugins/polar.ts @@ -25,6 +25,8 @@ import type { CanonicalWebhookEvent, CheckoutInput, CheckoutResult, + CompanionSubscriptionInput, + CompanionSubscriptionResult, PaymentPluginConfig, PaymentProvider, PaymentProviderPlugin, @@ -49,6 +51,15 @@ interface PolarConfig { proBundleProductId?: string starterYearlyProductId?: string proYearlyProductId?: string + /** + * The monthly usage subscription opened beside a yearly plan (see + * `CompanionSubscriptionInput`): per plan, a $0-base monthly product with the + * plan's metered prices and monthly meter credits. Off unless `companionUsage` + * is true AND the plan's companion product is set. + */ + starterCompanionProductId?: string + proCompanionProductId?: string + companionUsage?: boolean | string /** 'sandbox' | 'production' — Polar SDK server mode. Defaults to 'production'. */ server?: string } @@ -90,6 +101,15 @@ function isoOrUndefined(value: Date | string | null | undefined): string | undef return value } +/** Metadata tag on every companion usage subscription Studio opens. */ +const COMPANION_METADATA_KEY = 'contentrain_companion' + +/** A subscription (or order) of a companion usage product, by its tag or its product. */ +function isCompanion(subject: { productId?: string, metadata?: Record | null }, companionProductIds: Set): boolean { + return subject.metadata?.[COMPANION_METADATA_KEY] === 'true' + || (typeof subject.productId === 'string' && companionProductIds.has(subject.productId)) +} + /** * Minimal shape of a Polar Subscription payload used by our webhook mapping. * Keeps the adapter loosely-coupled to the SDK type (which is large and @@ -145,6 +165,7 @@ function subscriptionToResult( canonicalEvent: CanonicalWebhookEvent, sub: PolarSubscriptionLike, productMap: Record, + companionProductIds: Set = new Set(), ): WebhookResult { const workspaceId = typeof sub.metadata?.workspace_id === 'string' ? sub.metadata.workspace_id : undefined const planFromMeta = typeof sub.metadata?.plan === 'string' ? sub.metadata.plan : undefined @@ -165,6 +186,7 @@ function subscriptionToResult( billableMeters: billableMetersOf(sub), accessEndsAt: sub.cancelAtPeriodEnd ? isoOrUndefined(sub.endsAt) : undefined, migrateGrantId: typeof sub.metadata?.migrate_grant_id === 'string' ? sub.metadata.migrate_grant_id : undefined, + ...(isCompanion(sub, companionProductIds) ? { companion: true } : {}), } } @@ -180,6 +202,11 @@ function createPolarProvider(config: PaymentPluginConfig): PaymentProvider { const webhookSecret = cfg.webhookSecret ?? '' const productMap = buildProductMap(cfg) const planMap = extendWithBundleProducts(cfg, productMap) + const companionProducts: Record = { starter: cfg.starterCompanionProductId, pro: cfg.proCompanionProductId } + const companionProductIds = new Set(Object.values(companionProducts).filter((id): id is string => Boolean(id))) + const companionEnabled = cfg.companionUsage === true || cfg.companionUsage === 'true' + // Only a yearly (or bundle) subscription gets a companion: a monthly one bills its overage monthly already. + const yearlyParentIds = new Set([cfg.starterBundleProductId, cfg.proBundleProductId, cfg.starterYearlyProductId, cfg.proYearlyProductId].filter((id): id is string => Boolean(id))) return { async createCheckoutSession(input: CheckoutInput): Promise { @@ -249,7 +276,7 @@ function createPolarProvider(config: PaymentPluginConfig): PaymentProvider { switch (event.type) { case 'subscription.created': - return subscriptionToResult('subscription.created', event.data as unknown as PolarSubscriptionLike, planMap) + return subscriptionToResult('subscription.created', event.data as unknown as PolarSubscriptionLike, planMap, companionProductIds) // Every subscription lifecycle event is mapped by the state it // carries (`hasEnded`): a cancellation scheduled for the period end @@ -265,13 +292,14 @@ function createPolarProvider(config: PaymentPluginConfig): PaymentProvider { if (hasEnded(sub)) { return { event: 'subscription.canceled', + ...(isCompanion(sub, companionProductIds) ? { companion: true } : {}), workspaceId: typeof sub.metadata?.workspace_id === 'string' ? sub.metadata.workspace_id : undefined, subscriptionId: sub.id, customerId: sub.customerId, subscriptionStatus: 'canceled', } } - const result = subscriptionToResult('subscription.updated', sub, planMap) + const result = subscriptionToResult('subscription.updated', sub, planMap, companionProductIds) return event.type === 'subscription.past_due' ? { ...result, subscriptionStatus: 'past_due' } : result } @@ -280,6 +308,7 @@ function createPolarProvider(config: PaymentPluginConfig): PaymentProvider { id: string customerId: string subscriptionId: string | null + productId?: string totalAmount?: number billingReason?: string metadata?: Record @@ -303,6 +332,8 @@ function createPolarProvider(config: PaymentPluginConfig): PaymentProvider { invoiceId: order.id, ...(typeof order.totalAmount === 'number' ? { amountPaid: order.totalAmount } : {}), ...(order.billingReason ? { billingReason: billingReasonOf(order.billingReason) } : {}), + // A companion's monthly usage invoice says nothing about the plan subscription's payment. + ...(isCompanion({ productId: order.productId, metadata: order.subscription?.metadata }, companionProductIds) ? { companion: true } : {}), } } @@ -360,6 +391,31 @@ function createPolarProvider(config: PaymentPluginConfig): PaymentProvider { return { productId: targetProductId, alreadyOnList: false } }, + async ensureCompanionSubscription(input: CompanionSubscriptionInput): Promise { + const productId = companionProducts[input.plan] + if (!companionEnabled || !productId) return null + const parentProductId = input.parentProductId ?? (await polar.subscriptions.get({ id: input.parentSubscriptionId })).productId + if (!yearlyParentIds.has(parentProductId)) return null + + const existing = await polar.subscriptions.list({ customerId: input.customerId, productId, active: true }) + for await (const page of existing) { + const first = page.result.items[0] + if (first) return { subscriptionId: first.id, created: false } + } + + const created = await polar.subscriptions.create({ + productId, + customerId: input.customerId, + metadata: { + [COMPANION_METADATA_KEY]: 'true', + workspace_id: input.workspaceId, + plan: input.plan, + parent_subscription_id: input.parentSubscriptionId, + }, + }) + return { subscriptionId: created.id, created: true } + }, + async cancelSubscription(subscriptionId: string): Promise<'canceled' | 'already_ended'> { try { await polar.subscriptions.revoke({ id: subscriptionId }) diff --git a/server/providers/payment/types.ts b/server/providers/payment/types.ts index 4feb80e9..c5a1d1c0 100644 --- a/server/providers/payment/types.ts +++ b/server/providers/payment/types.ts @@ -136,6 +136,39 @@ export interface WebhookResult { * gated by the trial (`server/utils/overage-lock.ts`). */ billableMeters?: string[] + /** + * The event is about a companion usage subscription (see + * `CompanionSubscriptionInput`), not the plan subscription. The webhook + * records it beside the account and never lets it write the account's own + * subscription fields, status, period or plan. + */ + companion?: boolean +} + +/** + * A monthly, $0-base usage subscription opened beside a yearly plan: the + * provider invoices metered usage on a subscription's own cycle, so a yearly + * subscription alone would bill overage once a year. The companion carries the + * plan's monthly meter credits and the metered prices; the plan subscription + * keeps the fixed yearly fee. Off unless configured (`companionUsage`). + */ +export interface CompanionSubscriptionInput { + workspaceId: string + plan: 'starter' | 'pro' + customerId: string + /** The plan subscription the companion belongs to. */ + parentSubscriptionId: string + /** + * The product the plan subscription is on now: only a yearly (or bundle) product gets a companion. Omitted by + * callers that do not know it (the reconciler): the provider reads it from the subscription. + */ + parentProductId?: string +} + +export interface CompanionSubscriptionResult { + subscriptionId: string + /** False when the customer already had an active companion (a repeat). */ + created: boolean } export interface UsageEventInput { @@ -195,6 +228,15 @@ export interface PaymentProvider { * and logs a warning (overage billing is no-op under Stripe). */ ingestUsageEvent: (input: UsageEventInput) => Promise + + /** + * Open the monthly usage subscription beside a yearly plan subscription + * (`CompanionSubscriptionInput`). Idempotent: an active companion for the + * customer is returned, not duplicated. Null when companions are off, the + * plan has no companion product, or the parent is not on a yearly product. + * Optional: a provider without it never has one. + */ + ensureCompanionSubscription?: (input: CompanionSubscriptionInput) => Promise } /** diff --git a/server/providers/postgres-db/payment-accounts.ts b/server/providers/postgres-db/payment-accounts.ts index 0173b832..e7a33ae3 100644 --- a/server/providers/postgres-db/payment-accounts.ts +++ b/server/providers/postgres-db/payment-accounts.ts @@ -15,6 +15,7 @@ import { getAdmin, throwDbError } from './helpers' type PaymentAccountMethods = Pick< DatabaseProvider, | 'getActivePaymentAccount' + | 'listActivePaymentAccounts' | 'upsertPaymentAccount' | 'setPaymentAccountMetadataKey' | 'setPaymentAccountCreditUnit' @@ -57,6 +58,23 @@ export function paymentAccountMethods(): PaymentAccountMethods { } }, + async listActivePaymentAccounts(provider, limit) { + try { + const rows = await getAdmin() + .selectFrom('payment_accounts') + .selectAll() + .where('provider', '=', provider) + .where('is_active', '=', true) + .orderBy('created_at', 'asc') + .limit(limit) + .execute() + return rows as DatabaseRow[] + } + catch (error) { + throwDbError(error) + } + }, + async upsertPaymentAccount(input) { const nowActive = input.isActive ?? true diff --git a/server/providers/supabase-db/payment-accounts.ts b/server/providers/supabase-db/payment-accounts.ts index 3526e7ac..7dc2e5e1 100644 --- a/server/providers/supabase-db/payment-accounts.ts +++ b/server/providers/supabase-db/payment-accounts.ts @@ -12,6 +12,7 @@ import { getAdmin } from './helpers' type PaymentAccountMethods = Pick< DatabaseProvider, | 'getActivePaymentAccount' + | 'listActivePaymentAccounts' | 'upsertPaymentAccount' | 'setPaymentAccountMetadataKey' | 'setPaymentAccountCreditUnit' @@ -38,6 +39,19 @@ export function paymentAccountMethods(): PaymentAccountMethods { return (data ?? null) as DatabaseRow | null }, + async listActivePaymentAccounts(provider, limit) { + const { data, error } = await getAdmin() + .from('payment_accounts') + .select('*') + .eq('provider', provider) + .eq('is_active', true) + .order('created_at', { ascending: true }) + .limit(limit) + + if (error) throw createError({ statusCode: 500, message: error.message }) + return (data ?? []) as DatabaseRow[] + }, + async upsertPaymentAccount(input) { const admin = getAdmin() const nowActive = input.isActive ?? true diff --git a/server/utils/companion-subscription.ts b/server/utils/companion-subscription.ts new file mode 100644 index 00000000..5ca976c6 --- /dev/null +++ b/server/utils/companion-subscription.ts @@ -0,0 +1,134 @@ +/** + * The monthly usage subscription opened beside a yearly plan. + * + * Polar invoices metered usage on a subscription's own cycle, so overage on a + * yearly subscription alone would be billed once a year. The founder's rule is + * that usage and overage are monthly whatever the billing frequency. A companion + * is a second subscription for the same customer on a $0-base monthly product + * that carries the plan's metered prices and monthly meter credits; the plan + * subscription keeps the fixed yearly fee. Verified in the Polar sandbox + * (2026-10-05): a free-plus-metered monthly product accepts `subscriptions.create` + * with no checkout, grants its meter credit, and counts overage against it. + * NOT verified: that the overage invoice is charged to the card saved at the + * yearly checkout (see the PR's open questions). + * + * Isolation: a companion's events never write the account's subscription + * fields. The webhook records two keys in `plugin_metadata` through + * `setPaymentAccountMetadataKey` (the companion's id and the meters it prices), + * which the plan subscription's own writes preserve. + * + * Everything here is best effort and silent when companions are off (the + * provider returns null): a failure never fails the plan subscription's webhook, + * it is logged as an ALARM and the reconciler retries. + */ +import type { DatabaseProvider, DatabaseRow } from '../providers/database' +import type { PaymentProvider } from '../providers/payment/types' +import { COMPANION_SUBSCRIPTION_KEY, isYearlyPeriod } from './overage-lock' + +type Db = Pick + +/** The companion's subscription id stored on an account, or null. */ +export function companionSubscriptionIdOf(pluginMetadata: unknown): string | null { + if (!pluginMetadata || typeof pluginMetadata !== 'object') return null + const id = (pluginMetadata as Record)[COMPANION_SUBSCRIPTION_KEY] + return typeof id === 'string' && id.length > 0 ? id : null +} + +/** Open (or find) the companion for a plan subscription and record it on the account. Never throws. */ +export async function openCompanionSubscription( + provider: PaymentProvider, + db: Db, + input: { workspaceId: string, plan: string | null | undefined, customerId: string, subscriptionId: string | null | undefined, productId: string | null | undefined }, +): Promise { + if (!provider.ensureCompanionSubscription) return + if ((input.plan !== 'starter' && input.plan !== 'pro') || !input.subscriptionId) return + try { + const companion = await provider.ensureCompanionSubscription({ + workspaceId: input.workspaceId, + plan: input.plan, + customerId: input.customerId, + parentSubscriptionId: input.subscriptionId, + ...(input.productId ? { parentProductId: input.productId } : {}), + }) + if (!companion) return + await db.setPaymentAccountMetadataKey({ workspaceId: input.workspaceId, key: COMPANION_SUBSCRIPTION_KEY, value: companion.subscriptionId, when: 'different' }) + } + catch (err) { + // eslint-disable-next-line no-console -- the alarm: watched by the platform's log alert; the reconciler retries + console.error(`[companion] ALARM could not open the usage subscription for workspace ${input.workspaceId} (plan subscription ${input.subscriptionId}):`, err) + } +} + +/** + * Cancel the companion recorded on an account. `throwOnFailure` is for callers that retry (revoke): there a + * failure must leave the grant live. Elsewhere it is logged, since the plan subscription's end matters more. + */ +export async function cancelCompanionSubscription( + provider: PaymentProvider, + pluginMetadata: unknown, + context: string, + throwOnFailure = false, +): Promise { + const id = companionSubscriptionIdOf(pluginMetadata) + if (!id) return false + try { + return (await provider.cancelSubscription(id)) === 'canceled' + } + catch (err) { + // eslint-disable-next-line no-console -- the alarm: an uncancelled companion keeps billing usage to a customer with no plan + console.error(`[companion] ALARM could not cancel usage subscription ${id} (${context}):`, err) + if (throwOnFailure) throw err + return false + } +} + +export interface CompanionReconcileSummary { + checked: number + opened: number + failed: number +} + +/** Yearly plan accounts that have no companion yet: the ones the reconciler opens one for. */ +export function accountsMissingCompanion(rows: DatabaseRow[]): DatabaseRow[] { + return rows.filter(row => + row.subscription_status === 'active' + && Boolean(row.subscription_id) && Boolean(row.customer_id) + && isYearlyPeriod({ current_period_start: row.current_period_start as string | null, current_period_end: row.current_period_end as string | null }) + && !companionSubscriptionIdOf(row.plugin_metadata), + ) +} + +/** + * Open the companion for every active yearly plan that lacks one (a webhook that failed to open it, or a + * subscription that predates the flag). The provider decides whether the account's product gets one at all. + */ +export async function reconcileCompanionSubscriptions( + provider: PaymentProvider, + db: Db & Pick, + providerKey: string, +): Promise { + const summary: CompanionReconcileSummary = { checked: 0, opened: 0, failed: 0 } + if (!provider.ensureCompanionSubscription) return summary + for (const row of accountsMissingCompanion(await db.listActivePaymentAccounts(providerKey, 500))) { + summary.checked++ + const workspaceId = String(row.workspace_id) + try { + // The account does not store the plan subscription's product: the provider reads it from the subscription. + const companion = await provider.ensureCompanionSubscription({ + workspaceId, + plan: row.plan === 'pro' ? 'pro' : 'starter', + customerId: String(row.customer_id), + parentSubscriptionId: String(row.subscription_id), + }) + if (!companion) continue + await db.setPaymentAccountMetadataKey({ workspaceId, key: COMPANION_SUBSCRIPTION_KEY, value: companion.subscriptionId, when: 'different' }) + if (companion.created) summary.opened++ + } + catch (err) { + summary.failed++ + // eslint-disable-next-line no-console -- the alarm: watched by the platform's log alert + console.error(`[companion] ALARM reconcile could not open the usage subscription for workspace ${workspaceId}:`, err) + } + } + return summary +} diff --git a/server/utils/migrate-revoke.ts b/server/utils/migrate-revoke.ts index bbbbf500..9fe9d030 100644 --- a/server/utils/migrate-revoke.ts +++ b/server/utils/migrate-revoke.ts @@ -10,6 +10,8 @@ * - The cancel happens before the grant is marked, so a Polar failure leaves the * grant live and Migrate can call again (idempotent). A repeated call on a * revoked grant answers `revoked` and cancels nothing. + * - The plan's monthly usage subscription (the companion of a yearly plan) is cancelled first, with the + * same retry rule: cancelling the plan alone would leave it billing usage to a customer with no plan. * - The cancellation reaches the billing webhook as `subscription.canceled`, which * drops the workspace plan the usual way. */ @@ -17,6 +19,7 @@ import type { MigrateRevokeReason, MigrateRevokeResponse } from '@contentrain/ty import { validateMigrateRevokeResponse } from '@contentrain/types' import type { DatabaseRow } from '../providers/database' import { migrateGrantInstallation } from './migrate-grant-status' +import { cancelCompanionSubscription } from './companion-subscription' export async function revokeMigrateGrant(grant: DatabaseRow, reason: MigrateRevokeReason): Promise { const db = useDatabaseProvider() @@ -28,7 +31,10 @@ export async function revokeMigrateGrant(grant: DatabaseRow, reason: MigrateRevo if (subscriptionId) { const payment = usePaymentProvider() if (!payment) throw createError({ statusCode: 503, message: errorMessage('generic.server_error') }) + // Read before anything is cancelled: the webhook archives the account when the plan subscription ends. + const account = grant.workspace_id ? await db.getActivePaymentAccount(String(grant.workspace_id)) : null try { + await cancelCompanionSubscription(payment, account?.plugin_metadata, `revoke of grant ${String(grant.id)}`, true) // An already ended subscription is the goal met, not a failure: the grant is still marked below. canceled = (await payment.cancelSubscription(subscriptionId)) === 'canceled' } diff --git a/server/utils/overage-lock.ts b/server/utils/overage-lock.ts index 58088fd2..d8d8353d 100644 --- a/server/utils/overage-lock.ts +++ b/server/utils/overage-lock.ts @@ -52,6 +52,14 @@ export interface OverageLockAccount { /** Keys inside `payment_accounts.plugin_metadata`. */ export const BILLABLE_METERS_KEY = 'billable_meters' export const OVERAGE_SUSPENDED_KEY = 'overage_suspended' +/** + * The monthly usage subscription opened beside a yearly plan (see + * `server/utils/companion-subscription.ts`): its id, and the meters it prices + * (comma-joined). Written only through `setPaymentAccountMetadataKey`, never by + * the plan subscription's own events, so neither can overwrite the other. + */ +export const COMPANION_SUBSCRIPTION_KEY = 'companion_subscription_id' +export const COMPANION_METERS_KEY = 'companion_billable_meters' const METER_NAME_BY_SETTINGS_KEY: Record = Object.fromEntries( USAGE_METER_LIST.map(m => [m.settingsKey, m.name]), @@ -80,11 +88,24 @@ export function isYearlyPeriod(account: Pick 35 * 24 * 60 * 60 * 1000 } -/** The meters the subscription prices, or null when none were recorded. */ +/** The meters the companion usage subscription prices (empty when there is none). */ +export function readCompanionMeters(pluginMetadata: unknown): string[] { + if (!pluginMetadata || typeof pluginMetadata !== 'object') return [] + const raw = (pluginMetadata as Record)[COMPANION_METERS_KEY] + return typeof raw === 'string' ? raw.split(',').map(m => m.trim()).filter(Boolean) : [] +} + +/** + * The meters the account can bill: what the plan subscription prices plus what + * its companion usage subscription prices, or null when neither was recorded. + */ export function readBillableMeters(pluginMetadata: unknown): string[] | null { if (!pluginMetadata || typeof pluginMetadata !== 'object') return null const list = (pluginMetadata as Record)[BILLABLE_METERS_KEY] - return Array.isArray(list) ? list.filter((m): m is string => typeof m === 'string') : null + const own = Array.isArray(list) ? list.filter((m): m is string => typeof m === 'string') : null + const companion = readCompanionMeters(pluginMetadata) + if (!companion.length) return own + return [...new Set([...(own ?? []), ...companion])].toSorted() } /** Locked overage settings keys, each with why and until when. */ @@ -103,7 +124,9 @@ export function resolveOverageLocks(account: OverageLockAccount | null | undefin // Credit overage is priced on the meters of the subscription's own unit: // a pre-v2 subscription prices `ai_credits`, a v2 one `ai_credits_1c`. const creditMeters = creditTermsFor(creditUnitFromMeters(billable) ?? CURRENT_CREDIT_UNIT).meters - const reason: OverageLockReason = isYearlyPeriod(account) ? 'yearly_plan' : 'not_in_subscription' + // `yearly_plan` says "a yearly subscription bills nothing metered": only when the account prices no meter at all. + // A yearly account whose companion prices some meters but not this one is a plain `not_in_subscription`. + const reason: OverageLockReason = billable.length === 0 && isYearlyPeriod(account) ? 'yearly_plan' : 'not_in_subscription' for (const key of OVERAGE_SETTINGS_KEYS) { const meter = key === USAGE_METERS.AI_MESSAGES.settingsKey ? creditMeters.ai diff --git a/tests/integration/billing-webhook-companion.integration.test.ts b/tests/integration/billing-webhook-companion.integration.test.ts new file mode 100644 index 00000000..5edf6a63 --- /dev/null +++ b/tests/integration/billing-webhook-companion.integration.test.ts @@ -0,0 +1,186 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +function createErrorLike(input: { statusCode: number, message: string }) { + return Object.assign(new Error(input.message), input) +} + +/** + * A yearly plan's companion usage subscription (server/utils/companion-subscription.ts) through the billing + * webhook: opened with the plan subscription, recorded beside the account, never writing the account's own + * fields, and ended with the plan. + */ +describe('billing webhook: companion usage subscription', () => { + const upsertPaymentAccount = vi.fn().mockResolvedValue({}) + const archiveActivePaymentAccount = vi.fn().mockResolvedValue(undefined) + const updateWorkspace = vi.fn().mockResolvedValue({}) + const getActivePaymentAccount = vi.fn() + const getWorkspaceById = vi.fn() + const markWorkspaceTrialConsumed = vi.fn().mockResolvedValue(undefined) + const setPaymentAccountMetadataKey = vi.fn(async ({ when }: { when: unknown }) => when === 'different') + const setPaymentAccountCreditUnit = vi.fn().mockResolvedValue(false) + const ensureCompanionSubscription = vi.fn() + const cancelSubscription = vi.fn() + let handleWebhookMock: ReturnType + + const yearlyAccount = (metadata: Record = {}) => ({ + workspace_id: 'ws-1', provider: 'polar', customer_id: 'cus_1', subscription_id: 'sub_1', subscription_status: 'active', plan: 'pro', + current_period_start: '2026-10-01T00:00:00Z', current_period_end: '2027-10-01T00:00:00Z', trial_ends_at: null, cancel_at_period_end: false, + grace_period_ends_at: null, plugin_metadata: metadata, + }) + + beforeEach(() => { + vi.resetModules() + handleWebhookMock = vi.fn() + vi.stubGlobal('redeemMigrateGrant', vi.fn()) + vi.stubGlobal('isDuplicateBundleSubscription', vi.fn().mockResolvedValue(false)) + vi.stubGlobal('defineEventHandler', (handler: unknown) => handler) + vi.stubGlobal('createError', createErrorLike) + vi.stubGlobal('readRawBody', vi.fn().mockResolvedValue('{}')) + vi.stubGlobal('getRequestHeaders', vi.fn().mockReturnValue({})) + vi.stubGlobal('getRouterParam', vi.fn().mockReturnValue('polar')) + vi.stubGlobal('useRuntimeConfig', vi.fn().mockReturnValue({ polar: {} })) + vi.stubGlobal('useEmailProvider', vi.fn().mockReturnValue(null)) + vi.stubGlobal('useDatabaseProvider', vi.fn().mockReturnValue({ + upsertPaymentAccount, archiveActivePaymentAccount, updateWorkspace, getActivePaymentAccount, getWorkspaceById, + markWorkspaceTrialConsumed, setPaymentAccountMetadataKey, setPaymentAccountCreditUnit, + })) + getWorkspaceById.mockResolvedValue({ id: 'ws-1', overage_settings: { ai_messages: false } }) + ensureCompanionSubscription.mockReset().mockResolvedValue({ subscriptionId: 'sub_c1', created: true }) + cancelSubscription.mockReset().mockResolvedValue('canceled') + }) + + afterEach(() => { + vi.unstubAllGlobals() + for (const fn of [upsertPaymentAccount, archiveActivePaymentAccount, updateWorkspace, getActivePaymentAccount, getWorkspaceById, markWorkspaceTrialConsumed, setPaymentAccountCreditUnit]) fn.mockReset() + upsertPaymentAccount.mockResolvedValue({}) + setPaymentAccountMetadataKey.mockClear() + }) + + async function load() { + const paymentModule = await import('../../server/providers/payment') + paymentModule.bootstrapPaymentPlugins() + const { __resetRegistryForTests } = await import('../../server/providers/payment/registry') + __resetRegistryForTests() + paymentModule.registerPlugin({ + key: 'polar', + label: 'Polar', + isConfigured: () => true, + create: () => ({ + createCheckoutSession: vi.fn(), createPortalSession: vi.fn(), handleWebhook: handleWebhookMock, cancelSubscription, + createBundleCheckout: vi.fn(), moveBundleSubscriptionToList: vi.fn(), ingestUsageEvent: vi.fn(), ensureCompanionSubscription, + }), + }) + return (await import('../../server/api/billing/webhook/[provider].post.ts')).default + } + const post = async () => (await load())({ context: {} } as never) + + const planCreated = { + event: 'subscription.created', workspaceId: 'ws-1', plan: 'pro', productId: 'prod_pro_y', customerId: 'cus_1', subscriptionId: 'sub_1', + subscriptionStatus: 'active', currentPeriodStart: '2026-10-01T00:00:00Z', currentPeriodEnd: '2027-10-01T00:00:00Z', billableMeters: [], + } + + it('opens the companion when a yearly plan subscription is created, and records it on the account', async () => { + handleWebhookMock.mockResolvedValue(planCreated) + await post() + expect(ensureCompanionSubscription).toHaveBeenCalledWith({ + workspaceId: 'ws-1', plan: 'pro', customerId: 'cus_1', parentSubscriptionId: 'sub_1', parentProductId: 'prod_pro_y', + }) + expect(setPaymentAccountMetadataKey).toHaveBeenCalledWith({ workspaceId: 'ws-1', key: 'companion_subscription_id', value: 'sub_c1', when: 'different' }) + }) + + it('a companion that cannot be opened never fails the plan subscription\'s webhook', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + handleWebhookMock.mockResolvedValue(planCreated) + ensureCompanionSubscription.mockRejectedValue(new Error('polar down')) + expect(await post()).toEqual({ received: true }) + expect(upsertPaymentAccount).toHaveBeenCalled() + log.mockRestore() + }) + + it('records a companion event beside the account and writes none of the account\'s own fields', async () => { + getActivePaymentAccount.mockResolvedValue(yearlyAccount({ overage_suspended: ['ai_messages'], billable_meters: [] })) + handleWebhookMock.mockResolvedValue({ + event: 'subscription.created', companion: true, workspaceId: 'ws-1', plan: 'pro', productId: 'prod_pro_c', customerId: 'cus_1', + subscriptionId: 'sub_c1', subscriptionStatus: 'active', currentPeriodStart: '2026-10-05T00:00:00Z', currentPeriodEnd: '2026-11-05T00:00:00Z', + billableMeters: ['ai_credits', 'api_credits'], + }) + await post() + expect(setPaymentAccountMetadataKey).toHaveBeenCalledWith({ workspaceId: 'ws-1', key: 'companion_subscription_id', value: 'sub_c1', when: 'different' }) + expect(setPaymentAccountMetadataKey).toHaveBeenCalledWith({ workspaceId: 'ws-1', key: 'companion_billable_meters', value: 'ai_credits,api_credits', when: 'different' }) + // No plan write, no archive, no trial bookkeeping, no creation of a second companion. + expect(updateWorkspace).not.toHaveBeenCalledWith('', 'ws-1', expect.objectContaining({ plan: expect.anything() })) + expect(archiveActivePaymentAccount).not.toHaveBeenCalled() + expect(markWorkspaceTrialConsumed).not.toHaveBeenCalled() + expect(ensureCompanionSubscription).not.toHaveBeenCalled() + }) + + it('lines the overage toggles up again once the companion prices the meters, keeping the row\'s own values', async () => { + getActivePaymentAccount + .mockResolvedValueOnce(yearlyAccount({ overage_suspended: ['ai_messages'], billable_meters: [] })) + .mockResolvedValueOnce(yearlyAccount({ overage_suspended: ['ai_messages'], billable_meters: [], companion_subscription_id: 'sub_c1', companion_billable_meters: 'ai_credits,api_credits' })) + handleWebhookMock.mockResolvedValue({ event: 'subscription.updated', companion: true, workspaceId: 'ws-1', subscriptionId: 'sub_c1', customerId: 'cus_1', subscriptionStatus: 'active', billableMeters: ['ai_credits', 'api_credits'] }) + await post() + expect(updateWorkspace).toHaveBeenCalledWith('', 'ws-1', { overage_settings: { ai_messages: true } }) + const written = upsertPaymentAccount.mock.calls[0]![0] + expect(written).toMatchObject({ subscriptionId: 'sub_1', subscriptionStatus: 'active', plan: 'pro', customerId: 'cus_1', currentPeriodEnd: '2027-10-01T00:00:00Z' }) + expect(written.preserveMetadataKeys).toEqual(expect.arrayContaining(['companion_subscription_id', 'companion_billable_meters'])) + expect(written.pluginMetadata.overage_suspended).toBeUndefined() + }) + + it('ignores a late event about a companion the account no longer has', async () => { + getActivePaymentAccount.mockResolvedValue(yearlyAccount({ companion_subscription_id: 'sub_c_current' })) + handleWebhookMock.mockResolvedValue({ event: 'subscription.canceled', companion: true, workspaceId: 'ws-1', subscriptionId: 'sub_c_old', customerId: 'cus_1' }) + await post() + expect(setPaymentAccountMetadataKey).not.toHaveBeenCalled() + expect(upsertPaymentAccount).not.toHaveBeenCalled() + }) + + it('an ended companion is forgotten without ending the plan', async () => { + getActivePaymentAccount.mockResolvedValue(yearlyAccount({ companion_subscription_id: 'sub_c1', companion_billable_meters: 'ai_credits' })) + handleWebhookMock.mockResolvedValue({ event: 'subscription.canceled', companion: true, workspaceId: 'ws-1', subscriptionId: 'sub_c1', customerId: 'cus_1', subscriptionStatus: 'canceled' }) + await post() + expect(setPaymentAccountMetadataKey).toHaveBeenCalledWith({ workspaceId: 'ws-1', key: 'companion_subscription_id', value: '', when: 'different' }) + expect(setPaymentAccountMetadataKey).toHaveBeenCalledWith({ workspaceId: 'ws-1', key: 'companion_billable_meters', value: '', when: 'different' }) + expect(archiveActivePaymentAccount).not.toHaveBeenCalled() + expect(updateWorkspace).not.toHaveBeenCalledWith('', 'ws-1', { plan: 'free', trial_reminder_stage: 0 }) + }) + + it('a companion\'s usage invoice is not the plan\'s payment', async () => { + getActivePaymentAccount.mockResolvedValue(yearlyAccount({ companion_subscription_id: 'sub_c1' })) + handleWebhookMock.mockResolvedValue({ event: 'invoice.paid', companion: true, workspaceId: 'ws-1', subscriptionId: 'sub_c1', customerId: 'cus_1', invoiceId: 'ord_9' }) + expect(await post()).toEqual({ received: true }) + expect(upsertPaymentAccount).not.toHaveBeenCalled() + expect(setPaymentAccountMetadataKey).not.toHaveBeenCalled() + }) + + it('a plan subscription update keeps what its companion recorded, and opens the companion when there is none', async () => { + getActivePaymentAccount.mockResolvedValue(yearlyAccount({ companion_subscription_id: 'sub_c1', companion_billable_meters: 'ai_credits' })) + handleWebhookMock.mockResolvedValue({ ...planCreated, event: 'subscription.updated' }) + await post() + expect(upsertPaymentAccount.mock.calls[0]![0].preserveMetadataKeys).toEqual(expect.arrayContaining(['companion_subscription_id', 'companion_billable_meters'])) + expect(ensureCompanionSubscription).not.toHaveBeenCalled() + + getActivePaymentAccount.mockResolvedValue(yearlyAccount({})) + await post() + expect(ensureCompanionSubscription).toHaveBeenCalledTimes(1) + }) + + it('cancels the companion when the plan subscription ends', async () => { + getActivePaymentAccount.mockResolvedValue(yearlyAccount({ companion_subscription_id: 'sub_c1' })) + handleWebhookMock.mockResolvedValue({ event: 'subscription.canceled', workspaceId: 'ws-1', subscriptionId: 'sub_1', customerId: 'cus_1', subscriptionStatus: 'canceled' }) + await post() + expect(cancelSubscription).toHaveBeenCalledWith('sub_c1') + expect(archiveActivePaymentAccount).toHaveBeenCalledWith('ws-1') + }) + + it('a companion that will not cancel is an ALARM, and the plan still ends', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + getActivePaymentAccount.mockResolvedValue(yearlyAccount({ companion_subscription_id: 'sub_c1' })) + cancelSubscription.mockRejectedValue(new Error('polar down')) + handleWebhookMock.mockResolvedValue({ event: 'subscription.canceled', workspaceId: 'ws-1', subscriptionId: 'sub_1', customerId: 'cus_1', subscriptionStatus: 'canceled' }) + await post() + expect(log).toHaveBeenCalledWith(expect.stringContaining('[companion] ALARM could not cancel'), expect.any(Error)) + expect(archiveActivePaymentAccount).toHaveBeenCalledWith('ws-1') + log.mockRestore() + }) +}) diff --git a/tests/integration/billing-webhook.integration.test.ts b/tests/integration/billing-webhook.integration.test.ts index f8d139d2..6251f375 100644 --- a/tests/integration/billing-webhook.integration.test.ts +++ b/tests/integration/billing-webhook.integration.test.ts @@ -550,7 +550,7 @@ describe('billing webhook integration', () => { expect(upsertPaymentAccount).toHaveBeenCalledWith(expect.objectContaining({ pluginMetadata: { billable_meters: LEGACY_PRICES, overage_suspended: ['ai_messages'] }, - preserveMetadataKeys: ['activation_email', 'recovery_email'], + preserveMetadataKeys: ['activation_email', 'recovery_email', 'companion_subscription_id', 'companion_billable_meters'], })) // Trial → active also marks the activation email owed (it goes out on the first paid order). expect(setPaymentAccountMetadataKey).toHaveBeenCalledWith({ workspaceId: 'ws-1', key: 'activation_email', value: 'pending', when: 'absent' }) diff --git a/tests/unit/companion-subscription-util.test.ts b/tests/unit/companion-subscription-util.test.ts new file mode 100644 index 00000000..c4dd1bb4 --- /dev/null +++ b/tests/unit/companion-subscription-util.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from 'vitest' +import { + accountsMissingCompanion, + cancelCompanionSubscription, + companionSubscriptionIdOf, + openCompanionSubscription, + reconcileCompanionSubscriptions, +} from '../../server/utils/companion-subscription' + +const yearlyRow = (over: Record = {}) => ({ + workspace_id: 'ws-1', customer_id: 'cus_1', subscription_id: 'sub_1', subscription_status: 'active', plan: 'pro', + current_period_start: '2026-10-01T00:00:00Z', current_period_end: '2027-10-01T00:00:00Z', plugin_metadata: {}, + ...over, +}) + +const provider = (over: Record = {}) => ({ + cancelSubscription: vi.fn().mockResolvedValue('canceled'), + ensureCompanionSubscription: vi.fn().mockResolvedValue({ subscriptionId: 'sub_c1', created: true }), + ...over, +}) as never + +describe('companionSubscriptionIdOf', () => { + it('reads the stored id and treats an emptied one as none', () => { + expect(companionSubscriptionIdOf({ companion_subscription_id: 'sub_c1' })).toBe('sub_c1') + expect(companionSubscriptionIdOf({ companion_subscription_id: '' })).toBeNull() + expect(companionSubscriptionIdOf(null)).toBeNull() + expect(companionSubscriptionIdOf({})).toBeNull() + }) +}) + +describe('openCompanionSubscription', () => { + const args = { workspaceId: 'ws-1', plan: 'pro', customerId: 'cus_1', subscriptionId: 'sub_1', productId: 'prod_pro_y' } + + it('records the companion on the account through the single-key write', async () => { + const db = { setPaymentAccountMetadataKey: vi.fn().mockResolvedValue(true) } + const p = provider() + await openCompanionSubscription(p, db, args) + expect((p as { ensureCompanionSubscription: ReturnType }).ensureCompanionSubscription).toHaveBeenCalledWith({ + workspaceId: 'ws-1', plan: 'pro', customerId: 'cus_1', parentSubscriptionId: 'sub_1', parentProductId: 'prod_pro_y', + }) + expect(db.setPaymentAccountMetadataKey).toHaveBeenCalledWith({ workspaceId: 'ws-1', key: 'companion_subscription_id', value: 'sub_c1', when: 'different' }) + }) + + it('does nothing when the provider has none for this subscription (off, monthly plan)', async () => { + const db = { setPaymentAccountMetadataKey: vi.fn() } + await openCompanionSubscription(provider({ ensureCompanionSubscription: vi.fn().mockResolvedValue(null) }), db, args) + expect(db.setPaymentAccountMetadataKey).not.toHaveBeenCalled() + }) + + it('does nothing for a provider without companions, and for a plan that is not billable', async () => { + const db = { setPaymentAccountMetadataKey: vi.fn() } + await openCompanionSubscription(provider({ ensureCompanionSubscription: undefined }), db, args) + await openCompanionSubscription(provider(), db, { ...args, plan: 'free' }) + expect(db.setPaymentAccountMetadataKey).not.toHaveBeenCalled() + }) + + it('never throws: a failure is an ALARM in the log and the reconciler retries', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + const db = { setPaymentAccountMetadataKey: vi.fn() } + await expect(openCompanionSubscription(provider({ ensureCompanionSubscription: vi.fn().mockRejectedValue(new Error('polar down')) }), db, args)).resolves.toBeUndefined() + expect(log).toHaveBeenCalledWith(expect.stringContaining('[companion] ALARM'), expect.any(Error)) + log.mockRestore() + }) +}) + +describe('cancelCompanionSubscription', () => { + it('cancels the recorded companion', async () => { + const p = provider() + expect(await cancelCompanionSubscription(p, { companion_subscription_id: 'sub_c1' }, 'test')).toBe(true) + expect((p as { cancelSubscription: ReturnType }).cancelSubscription).toHaveBeenCalledWith('sub_c1') + }) + + it('has nothing to cancel without one', async () => { + const p = provider() + expect(await cancelCompanionSubscription(p, {}, 'test')).toBe(false) + expect((p as { cancelSubscription: ReturnType }).cancelSubscription).not.toHaveBeenCalled() + }) + + it('logs and carries on when it cannot, unless the caller retries', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + const p = provider({ cancelSubscription: vi.fn().mockRejectedValue(new Error('polar down')) }) + expect(await cancelCompanionSubscription(p, { companion_subscription_id: 'sub_c1' }, 'test')).toBe(false) + await expect(cancelCompanionSubscription(p, { companion_subscription_id: 'sub_c1' }, 'revoke', true)).rejects.toThrow('polar down') + log.mockRestore() + }) +}) + +describe('reconcileCompanionSubscriptions', () => { + it('picks the active yearly accounts with no companion', () => { + const rows = [ + yearlyRow(), + yearlyRow({ workspace_id: 'ws-has', plugin_metadata: { companion_subscription_id: 'sub_c9' } }), + yearlyRow({ workspace_id: 'ws-monthly', current_period_end: '2026-11-01T00:00:00Z' }), + yearlyRow({ workspace_id: 'ws-trial', subscription_status: 'trialing' }), + yearlyRow({ workspace_id: 'ws-nosub', subscription_id: null }), + ] as never + expect(accountsMissingCompanion(rows).map(r => r.workspace_id)).toEqual(['ws-1']) + }) + + it('opens the missing ones and records them; the provider reads the plan subscription\'s product itself', async () => { + const db = { listActivePaymentAccounts: vi.fn().mockResolvedValue([yearlyRow()]), setPaymentAccountMetadataKey: vi.fn().mockResolvedValue(true) } + const p = provider() + expect(await reconcileCompanionSubscriptions(p, db, 'polar')).toEqual({ checked: 1, opened: 1, failed: 0 }) + expect((p as { ensureCompanionSubscription: ReturnType }).ensureCompanionSubscription).toHaveBeenCalledWith({ + workspaceId: 'ws-1', plan: 'pro', customerId: 'cus_1', parentSubscriptionId: 'sub_1', + }) + expect(db.setPaymentAccountMetadataKey).toHaveBeenCalledWith(expect.objectContaining({ key: 'companion_subscription_id', value: 'sub_c1' })) + }) + + it('counts a failure and goes on with the rest', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + const db = { listActivePaymentAccounts: vi.fn().mockResolvedValue([yearlyRow(), yearlyRow({ workspace_id: 'ws-2' })]), setPaymentAccountMetadataKey: vi.fn().mockResolvedValue(true) } + const ensure = vi.fn().mockRejectedValueOnce(new Error('polar down')).mockResolvedValueOnce({ subscriptionId: 'sub_c2', created: true }) + expect(await reconcileCompanionSubscriptions(provider({ ensureCompanionSubscription: ensure }), db, 'polar')).toEqual({ checked: 2, opened: 1, failed: 1 }) + log.mockRestore() + }) + + it('is a no-op when companions are off (the provider answers null)', async () => { + const db = { listActivePaymentAccounts: vi.fn().mockResolvedValue([yearlyRow()]), setPaymentAccountMetadataKey: vi.fn() } + expect(await reconcileCompanionSubscriptions(provider({ ensureCompanionSubscription: vi.fn().mockResolvedValue(null) }), db, 'polar')).toEqual({ checked: 1, opened: 0, failed: 0 }) + expect(db.setPaymentAccountMetadataKey).not.toHaveBeenCalled() + }) +}) diff --git a/tests/unit/companion-subscription.test.ts b/tests/unit/companion-subscription.test.ts new file mode 100644 index 00000000..919597fa --- /dev/null +++ b/tests/unit/companion-subscription.test.ts @@ -0,0 +1,140 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const subscriptionsList = vi.fn() +const subscriptionsCreate = vi.fn() +const subscriptionsGet = vi.fn() +vi.mock('@polar-sh/sdk', () => ({ + Polar: class { + subscriptions = { list: subscriptionsList, create: subscriptionsCreate, get: subscriptionsGet } + }, +})) +const validateEvent = vi.fn() +vi.mock('@polar-sh/sdk/webhooks', () => ({ + validateEvent: (...args: unknown[]) => validateEvent(...args), + WebhookVerificationError: class extends Error {}, +})) + +const polarConfig = { + accessToken: 'tok', + webhookSecret: 'sec', + starterProductId: 'prod_starter_m', + proProductId: 'prod_pro_m', + starterBundleProductId: 'prod_starter_bundle', + proBundleProductId: 'prod_pro_bundle', + starterYearlyProductId: 'prod_starter_y', + proYearlyProductId: 'prod_pro_y', + starterCompanionProductId: 'prod_starter_c', + proCompanionProductId: 'prod_pro_c', + companionUsage: true, +} + +async function polar(config: Record = polarConfig) { + const { polarPlugin } = await import('../../server/providers/payment/plugins/polar') + return polarPlugin.create({ polar: config } as never) +} + +async function* pageGen(items: Array<{ id: string }>) { + yield { result: { items } } +} +const emptyPage = () => pageGen([]) +const pageOf = (...ids: string[]) => pageGen(ids.map(id => ({ id }))) + +const input = { workspaceId: 'ws-1', plan: 'pro' as const, customerId: 'cus_1', parentSubscriptionId: 'sub_1', parentProductId: 'prod_pro_y' } + +describe('polar companion subscription', () => { + beforeEach(() => { + subscriptionsList.mockReset().mockResolvedValue(emptyPage()) + subscriptionsCreate.mockReset().mockResolvedValue({ id: 'sub_c1' }) + subscriptionsGet.mockReset().mockResolvedValue({ productId: 'prod_pro_y' }) + }) + + it('opens the plan\'s companion product for the customer, tagged and tied to the plan subscription', async () => { + expect(await (await polar()).ensureCompanionSubscription!(input)).toEqual({ subscriptionId: 'sub_c1', created: true }) + expect(subscriptionsCreate).toHaveBeenCalledWith({ + productId: 'prod_pro_c', + customerId: 'cus_1', + metadata: { contentrain_companion: 'true', workspace_id: 'ws-1', plan: 'pro', parent_subscription_id: 'sub_1' }, + }) + }) + + it('uses the starter companion for a starter plan', async () => { + await (await polar()).ensureCompanionSubscription!({ ...input, plan: 'starter', parentProductId: 'prod_starter_bundle' }) + expect(subscriptionsCreate.mock.calls[0]![0]).toMatchObject({ productId: 'prod_starter_c' }) + }) + + it('is off unless the flag is on: no call to Polar at all', async () => { + const off = await polar({ ...polarConfig, companionUsage: false }) + expect(await off.ensureCompanionSubscription!(input)).toBeNull() + expect(subscriptionsList).not.toHaveBeenCalled() + expect(subscriptionsCreate).not.toHaveBeenCalled() + }) + + it('accepts the flag as the string an env var gives', async () => { + expect(await (await polar({ ...polarConfig, companionUsage: 'true' })).ensureCompanionSubscription!(input)).toMatchObject({ created: true }) + }) + + it('is off for a plan with no companion product', async () => { + expect(await (await polar({ ...polarConfig, proCompanionProductId: '' })).ensureCompanionSubscription!(input)).toBeNull() + expect(subscriptionsCreate).not.toHaveBeenCalled() + }) + + it('never gives a monthly plan a companion: it bills its overage monthly already', async () => { + expect(await (await polar()).ensureCompanionSubscription!({ ...input, parentProductId: 'prod_pro_m' })).toBeNull() + expect(subscriptionsList).not.toHaveBeenCalled() + }) + + it('reads the parent\'s product from the subscription when the caller does not know it', async () => { + const { parentProductId: _drop, ...withoutProduct } = input + expect(await (await polar()).ensureCompanionSubscription!(withoutProduct)).toMatchObject({ created: true }) + expect(subscriptionsGet).toHaveBeenCalledWith({ id: 'sub_1' }) + subscriptionsGet.mockResolvedValue({ productId: 'prod_pro_m' }) + subscriptionsCreate.mockClear() + expect(await (await polar()).ensureCompanionSubscription!(withoutProduct)).toBeNull() + expect(subscriptionsCreate).not.toHaveBeenCalled() + }) + + it('returns the active companion the customer already has instead of opening a second one', async () => { + subscriptionsList.mockResolvedValue(pageOf('sub_existing')) + expect(await (await polar()).ensureCompanionSubscription!(input)).toEqual({ subscriptionId: 'sub_existing', created: false }) + expect(subscriptionsList).toHaveBeenCalledWith({ customerId: 'cus_1', productId: 'prod_pro_c', active: true }) + expect(subscriptionsCreate).not.toHaveBeenCalled() + }) +}) + +describe('polar webhook: companion events are marked, plan events are not', () => { + const sub = (over: Record = {}) => ({ + id: 'sub_c1', status: 'active', customerId: 'cus_1', productId: 'prod_pro_c', checkoutId: null, + currentPeriodStart: '2026-10-05T00:00:00Z', currentPeriodEnd: '2026-11-05T00:00:00Z', trialEnd: null, cancelAtPeriodEnd: false, + metadata: { contentrain_companion: 'true', workspace_id: 'ws-1', plan: 'pro' }, + prices: [{ amountType: 'metered_unit', meter: { name: 'ai_credits_1c' } }, { amountType: 'metered_unit', meter: { name: 'api_credits_1c' } }], + ...over, + }) + const handle = async (type: string, data: unknown) => { + validateEvent.mockReturnValue({ type, data }) + return (await polar()).handleWebhook('{}', {}) + } + + it('marks a created companion and carries the meters it prices', async () => { + expect(await handle('subscription.created', sub())).toMatchObject({ event: 'subscription.created', companion: true, subscriptionId: 'sub_c1', billableMeters: ['ai_credits_1c', 'api_credits_1c'] }) + }) + + it('marks it by its product when the metadata is missing', async () => { + expect(await handle('subscription.updated', sub({ metadata: {} }))).toMatchObject({ companion: true }) + }) + + it('marks an ended companion', async () => { + expect(await handle('subscription.revoked', sub({ status: 'canceled', endedAt: '2026-11-01T00:00:00Z' }))).toMatchObject({ event: 'subscription.canceled', companion: true, subscriptionId: 'sub_c1' }) + }) + + it('marks a companion\'s order, so its usage invoice never reads as the plan\'s payment', async () => { + const result = await handle('order.paid', { id: 'ord_1', customerId: 'cus_1', subscriptionId: 'sub_c1', productId: 'prod_pro_c', totalAmount: 1200, billingReason: 'subscription_cycle', metadata: {}, subscription: { metadata: { contentrain_companion: 'true', workspace_id: 'ws-1' } } }) + expect(result).toMatchObject({ event: 'invoice.paid', companion: true }) + }) + + it('does not mark the plan subscription or its order', async () => { + const plan = await handle('subscription.created', sub({ id: 'sub_1', productId: 'prod_pro_y', metadata: { workspace_id: 'ws-1', plan: 'pro' }, prices: [] })) + expect(plan.companion).toBeUndefined() + const order = await handle('order.paid', { id: 'ord_2', customerId: 'cus_1', subscriptionId: 'sub_1', productId: 'prod_pro_y', totalAmount: 7200, metadata: { workspace_id: 'ws-1' } }) + expect(order.companion).toBeUndefined() + }) +}) diff --git a/tests/unit/migrate-revoke-route.test.ts b/tests/unit/migrate-revoke-route.test.ts index 462746e7..2f187ef0 100644 --- a/tests/unit/migrate-revoke-route.test.ts +++ b/tests/unit/migrate-revoke-route.test.ts @@ -49,6 +49,7 @@ describe('POST /api/migrate/grants/revoke', () => { getMigrateGrantByOrderId: vi.fn().mockResolvedValue(grant()), getWorkspaceById: vi.fn().mockResolvedValue({ id: 'ws-1', github_installation_id: null }), markMigrateGrantRevoked: vi.fn().mockResolvedValue(null), + getActivePaymentAccount: vi.fn().mockResolvedValue(null), releaseMigrateS2sJti: vi.fn().mockResolvedValue(undefined), claimMigrateS2sJti: vi.fn(async (jti: string, purpose: string) => { if (taken.has(`${purpose}:${jti}`)) return false @@ -64,6 +65,25 @@ describe('POST /api/migrate/grants/revoke', () => { vi.stubGlobal('errorMessage', (key: string) => key) }) + it('cancels the plan\'s usage subscription (the companion of a yearly plan) before the plan itself', async () => { + db.getActivePaymentAccount.mockResolvedValue({ plugin_metadata: { companion_subscription_id: 'sub_companion' } }) + await request() + expect(await call()).toEqual({ state: 'revoked', installed: false, subscription_canceled: true }) + expect(payment!.cancelSubscription.mock.calls.map(c => c[0])).toEqual(['sub_companion', 'sub_bound']) + expect(db.markMigrateGrantRevoked).toHaveBeenCalledWith('grant-1', 'refund_before_delivery') + }) + + it('a companion that cannot be cancelled leaves the grant live and the plan untouched, so Migrate can call again', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + db.getActivePaymentAccount.mockResolvedValue({ plugin_metadata: { companion_subscription_id: 'sub_companion' } }) + payment!.cancelSubscription.mockRejectedValueOnce(new Error('polar down')) + await request() + await expect(call()).rejects.toMatchObject({ statusCode: 502 }) + expect(payment!.cancelSubscription).toHaveBeenCalledTimes(1) + expect(db.markMigrateGrantRevoked).not.toHaveBeenCalled() + log.mockRestore() + }) + it('cancels the subscription the grant is bound to, marks the grant, and keeps the installed fact', async () => { db.getWorkspaceById.mockResolvedValue({ id: 'ws-1', github_installation_id: 4242 }) await request() diff --git a/tests/unit/overage-lock.test.ts b/tests/unit/overage-lock.test.ts index 62589460..2843a1a9 100644 --- a/tests/unit/overage-lock.test.ts +++ b/tests/unit/overage-lock.test.ts @@ -166,3 +166,42 @@ describe('isYearlyPeriod', () => { expect(isYearlyPeriod({})).toBe(false) }) }) + +describe('companion usage subscription meters', () => { + const yearly = { subscription_status: 'active', current_period_start: '2026-10-01T00:00:00Z', current_period_end: '2027-10-01T00:00:00Z' } + + it('a yearly plan that prices nothing is locked as yearly_plan', () => { + expect(resolveOverageLocks({ ...yearly, plugin_metadata: { billable_meters: [] } }).ai_messages?.reason).toBe('yearly_plan') + }) + + it('the companion meters lift the lock on what they price', () => { + const locks = resolveOverageLocks({ ...yearly, plugin_metadata: { billable_meters: [], companion_billable_meters: CURRENT_PRICES.join(',') } }) + expect(locks.ai_messages).toBeUndefined() + expect(locks.api_messages).toBeUndefined() + expect(locks.form_submissions).toBeUndefined() + }) + + it('a meter the companion does not price reads as not_in_subscription, not yearly_plan', () => { + const locks = resolveOverageLocks({ ...yearly, plugin_metadata: { billable_meters: [], companion_billable_meters: 'ai_credits_1c,api_credits_1c' } }) + expect(locks.ai_messages).toBeUndefined() + expect(locks.mcp_calls).toEqual({ reason: 'not_in_subscription', until: null }) + }) + + it('a companion recorded without any own list still counts as a recorded price list', () => { + expect(resolveOverageLocks({ ...yearly, plugin_metadata: { companion_billable_meters: 'ai_credits' } }).ai_messages).toBeUndefined() + }) + + it('no companion keeps a yearly subscription locked exactly as before', () => { + const locks = resolveOverageLocks({ ...yearly, plugin_metadata: { billable_meters: [], companion_subscription_id: '', companion_billable_meters: '' } }) + expect(locks.ai_messages?.reason).toBe('yearly_plan') + }) + + it('the companion turning up gives suspended toggles back, a subscription event without it would not', () => { + const result = reconcileOverageLock({ + settings: { ai_messages: false }, + pluginMetadata: { billable_meters: [], overage_suspended: ['ai_messages'], companion_billable_meters: 'ai_credits,api_credits,form_submissions,mcp_calls' }, + account: yearly, + }) + expect(result.settings).toEqual({ ai_messages: true }) + }) +}) From 3dace879b7d1d56b962910731d2e01b5d3da07d3 Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:23:10 +0300 Subject: [PATCH 2/4] fix(billing): serialize companion opening, fail loudly on cancel, replace on product switch One conditional claim per workspace decides who opens the companion, so created, updated and reconciler cannot open two. Workspace delete and the plan-cancel webhook now fail when the companion cannot be cancelled. The overage toggle write touches only its own key. A plan moved to another product replaces its companion; an ended one is forgotten on the row. --- server/api/billing/webhook/[provider].post.ts | 57 ++++----- .../workspaces/[workspaceId]/index.delete.ts | 30 +++-- server/providers/database.ts | 11 ++ server/providers/payment/plugins/polar.ts | 2 + server/providers/payment/types.ts | 3 + .../providers/postgres-db/payment-accounts.ts | 23 ++++ .../providers/supabase-db/payment-accounts.ts | 25 ++++ server/utils/companion-subscription.ts | 113 +++++++++++++--- server/utils/overage-lock.ts | 2 + .../payment-accounts.contract.test.ts | 19 +++ ...ling-webhook-companion.integration.test.ts | 55 ++++++-- .../billing-webhook.integration.test.ts | 2 +- .../delete-routes.integration.test.ts | 39 ++++++ .../unit/companion-subscription-util.test.ts | 121 ++++++++++++++---- 14 files changed, 410 insertions(+), 92 deletions(-) diff --git a/server/api/billing/webhook/[provider].post.ts b/server/api/billing/webhook/[provider].post.ts index 3becaa97..60ffd5af 100644 --- a/server/api/billing/webhook/[provider].post.ts +++ b/server/api/billing/webhook/[provider].post.ts @@ -17,7 +17,7 @@ import { bootstrapPaymentPlugins, resolvePlugin } from '../../../providers/payme import type { PaymentPluginConfig } from '../../../providers/payment' import { PLAN_PRICING, normalizePlan } from '../../../../shared/utils/license' import { emailTemplate } from '../../../utils/content-strings' -import { BILLABLE_METERS_KEY, COMPANION_METERS_KEY, COMPANION_SUBSCRIPTION_KEY, reconcileOverageLock } from '../../../utils/overage-lock' +import { BILLABLE_METERS_KEY, COMPANION_CLAIM_KEY, COMPANION_METERS_KEY, COMPANION_SUBSCRIPTION_KEY, OVERAGE_SUSPENDED_KEY, reconcileOverageLock } from '../../../utils/overage-lock' import type { OverageLockAccount } from '../../../utils/overage-lock' import { cancelCompanionSubscription, companionSubscriptionIdOf, openCompanionSubscription } from '../../../utils/companion-subscription' import type { WebhookResult } from '../../../providers/payment/types' @@ -45,7 +45,7 @@ const RECOVERY_EMAIL_KEY = 'recovery_email' * Keys only `setPaymentAccountMetadataKey` writes; upserts built from a read keep them. The companion usage * subscription's keys are among them: the plan subscription's events must not wipe what its companion recorded. */ -const CLAIMED_METADATA_KEYS = [ACTIVATION_EMAIL_KEY, RECOVERY_EMAIL_KEY, COMPANION_SUBSCRIPTION_KEY, COMPANION_METERS_KEY] +const CLAIMED_METADATA_KEYS = [ACTIVATION_EMAIL_KEY, RECOVERY_EMAIL_KEY, COMPANION_SUBSCRIPTION_KEY, COMPANION_METERS_KEY, COMPANION_CLAIM_KEY] /** The past-due episode a read row is in, as its recovery claim value. */ function pastDueEpisode(row: Record | null | undefined): string { @@ -269,8 +269,12 @@ async function applyCompanionEvent(db: Db, result: WebhookResult): Promise if (result.event === 'subscription.canceled') { await db.setPaymentAccountMetadataKey({ workspaceId, key: COMPANION_METERS_KEY, value: '', when: 'different' }) await db.setPaymentAccountMetadataKey({ workspaceId, key: COMPANION_SUBSCRIPTION_KEY, value: '', when: 'different' }) + // The companion is gone while the plan lives on: let the next plan event or reconcile open a new one. + await db.setPaymentAccountMetadataKey({ workspaceId, key: COMPANION_CLAIM_KEY, value: 'failed', when: 'different' }) } else if (result.event === 'subscription.created' || result.event === 'subscription.updated') { + // A replayed event of a companion that has already ended (Polar delivers out of order) must not bring it back. + if (result.subscriptionStatus === 'canceled' || result.subscriptionStatus === 'unpaid' || result.subscriptionStatus === 'incomplete_expired') return await db.setPaymentAccountMetadataKey({ workspaceId, key: COMPANION_SUBSCRIPTION_KEY, value: result.subscriptionId, when: 'different' }) if (result.billableMeters) { await db.setPaymentAccountMetadataKey({ workspaceId, key: COMPANION_METERS_KEY, value: result.billableMeters.join(','), when: 'different' }) @@ -299,22 +303,10 @@ async function applyCompanionEvent(db: Db, result: WebhookResult): Promise }, }) if (overageLock.pluginMetadata) { - await db.upsertPaymentAccount({ - workspaceId, - provider: fresh.provider as string, - customerId: fresh.customer_id as string, - subscriptionId: (fresh.subscription_id as string | null) ?? null, - subscriptionStatus: (fresh.subscription_status as string | null) ?? null, - currentPeriodStart: (fresh.current_period_start as string | null) ?? null, - currentPeriodEnd: (fresh.current_period_end as string | null) ?? null, - trialEndsAt: (fresh.trial_ends_at as string | null) ?? null, - cancelAtPeriodEnd: Boolean(fresh.cancel_at_period_end), - gracePeriodEndsAt: (fresh.grace_period_ends_at as string | null) ?? null, - plan: (fresh.plan as string | null) ?? null, - pluginMetadata: overageLock.pluginMetadata, - preserveMetadataKeys: CLAIMED_METADATA_KEYS, - isActive: true, - }) + // Only the suspended list can have changed: write that key alone, so a plan event that landed since the read + // is not rolled back by a whole-row write of this snapshot. + const suspended = overageLock.pluginMetadata[OVERAGE_SUSPENDED_KEY] + await db.setPaymentAccountMetadataJson({ workspaceId, key: OVERAGE_SUSPENDED_KEY, value: Array.isArray(suspended) ? suspended as string[] : null }) } await overageLock.commit() } @@ -523,16 +515,16 @@ export default defineEventHandler(async (event) => { await db.setPaymentAccountCreditUnit({ workspaceId: result.workspaceId, unit: updatedUnit, periodKey }) } await overageLock.commit() - // A subscription that predates the flag, or whose companion failed to open, gets it here. - if (!companionSubscriptionIdOf(existingAccount?.plugin_metadata)) { - await openCompanionSubscription(provider, db, { - workspaceId: result.workspaceId, - plan: result.plan, - customerId: result.customerId, - subscriptionId: result.subscriptionId, - productId: result.productId, - }) - } + // A subscription that predates the flag, or whose companion failed to open, gets it here. Failing to + // cancel the companion of a product the plan left throws: the webhook is retried. + // A plan moved to another product also lands here: its old companion is replaced. + await openCompanionSubscription(provider, db, { + workspaceId: result.workspaceId, + plan: result.plan, + customerId: result.customerId, + subscriptionId: result.subscriptionId, + productId: result.productId, + }) // A subscription started from a Migrate grant's checkout uses the // grant up: no second included trial after cancel-and-resubscribe. // Idempotent — whichever of created/updated arrives first marks it. @@ -587,7 +579,14 @@ export default defineEventHandler(async (event) => { if (activeSubscriptionId && result.subscriptionId && activeSubscriptionId !== result.subscriptionId) break const canceledPlan = result.plan ?? (priorAccount?.plan as string | null) // Its usage subscription ends with it: left running, it would bill the customer's usage with no plan. - await cancelCompanionSubscription(provider, priorAccount?.plugin_metadata, `plan subscription ${result.subscriptionId ?? 'unknown'} ended`) + // A failure throws, so the webhook answers an error and Polar retries while the account is still active: + // once archived, nothing would look for the companion again. + await cancelCompanionSubscription(provider, priorAccount?.plugin_metadata, `plan subscription ${result.subscriptionId ?? 'unknown'} ended`, true) + // A resubscribe reuses this row: it must not keep the ended companion's id, meters or claim. + const priorMetadata = (priorAccount?.plugin_metadata ?? {}) as Record + for (const key of [COMPANION_SUBSCRIPTION_KEY, COMPANION_METERS_KEY, COMPANION_CLAIM_KEY]) { + if (key in priorMetadata) await db.setPaymentAccountMetadataKey({ workspaceId: result.workspaceId, key, value: '', when: 'different' }) + } await db.archiveActivePaymentAccount(result.workspaceId) await db.updateWorkspace('', result.workspaceId, { plan: 'free', diff --git a/server/api/workspaces/[workspaceId]/index.delete.ts b/server/api/workspaces/[workspaceId]/index.delete.ts index d4f54801..6def36f2 100644 --- a/server/api/workspaces/[workspaceId]/index.delete.ts +++ b/server/api/workspaces/[workspaceId]/index.delete.ts @@ -83,21 +83,33 @@ export default defineEventHandler(async (event) => { // CASCADE will drop the payment_accounts row and we lose the // subscription_id reference. if (cancelSubscription) { + let account: Awaited> = null try { - const account = await db.getActivePaymentAccount(workspaceId) - const subscriptionId = (account?.subscription_id as string | null) ?? null - if (subscriptionId) { - const payment = usePaymentProvider() - if (payment) { - await cancelCompanionSubscription(payment, account?.plugin_metadata, `workspace ${workspaceId} deleted`) - await payment.cancelSubscription(subscriptionId) - } - } + account = await db.getActivePaymentAccount(workspaceId) } catch (err: unknown) { // eslint-disable-next-line no-console console.warn('[workspace-delete] cancelSubscription failed:', err instanceof Error ? err.message : err) } + const subscriptionId = (account?.subscription_id as string | null) ?? null + const payment = subscriptionId ? usePaymentProvider() : null + if (subscriptionId && payment) { + // The companion first, and loudly: CASCADE drops the account row, the only record of its id, so a companion + // left running would keep billing usage to a customer with no workspace. + try { + await cancelCompanionSubscription(payment, account?.plugin_metadata, `workspace ${workspaceId} deleted`, true) + } + catch { + throw createError({ statusCode: 502, message: errorMessage('billing.provider_unavailable') }) + } + try { + await payment.cancelSubscription(subscriptionId) + } + catch (err: unknown) { + // eslint-disable-next-line no-console + console.warn('[workspace-delete] cancelSubscription failed:', err instanceof Error ? err.message : err) + } + } } // Clean R2 storage for all projects diff --git a/server/providers/database.ts b/server/providers/database.ts index 87b15784..ef46057d 100644 --- a/server/providers/database.ts +++ b/server/providers/database.ts @@ -1099,6 +1099,17 @@ export interface DatabaseProvider { when: 'absent' | 'different' | { equals: string } }) => Promise + /** + * Set (or, with `null`, remove) one structured `plugin_metadata` key on the active payment account, leaving + * every other key and column as they are. For a key that holds a list (e.g. the suspended overage meters), + * which `setPaymentAccountMetadataKey` cannot store. Returns whether an active row was found. + */ + setPaymentAccountMetadataJson: (input: { + workspaceId: string + key: string + value: string[] | null + }) => Promise + /** Archive the active payment account for a workspace (no-op if none). */ archiveActivePaymentAccount: (workspaceId: string) => Promise diff --git a/server/providers/payment/plugins/polar.ts b/server/providers/payment/plugins/polar.ts index 9831b136..c5aa6092 100644 --- a/server/providers/payment/plugins/polar.ts +++ b/server/providers/payment/plugins/polar.ts @@ -391,6 +391,8 @@ function createPolarProvider(config: PaymentPluginConfig): PaymentProvider { return { productId: targetProductId, alreadyOnList: false } }, + companionUsageEnabled: () => companionEnabled, + async ensureCompanionSubscription(input: CompanionSubscriptionInput): Promise { const productId = companionProducts[input.plan] if (!companionEnabled || !productId) return null diff --git a/server/providers/payment/types.ts b/server/providers/payment/types.ts index c5a1d1c0..1c242871 100644 --- a/server/providers/payment/types.ts +++ b/server/providers/payment/types.ts @@ -237,6 +237,9 @@ export interface PaymentProvider { * Optional: a provider without it never has one. */ ensureCompanionSubscription?: (input: CompanionSubscriptionInput) => Promise + + /** Whether companions are switched on at all; callers skip every companion step (and every write) when not. */ + companionUsageEnabled?: () => boolean } /** diff --git a/server/providers/postgres-db/payment-accounts.ts b/server/providers/postgres-db/payment-accounts.ts index e7a33ae3..bad56c7c 100644 --- a/server/providers/postgres-db/payment-accounts.ts +++ b/server/providers/postgres-db/payment-accounts.ts @@ -18,6 +18,7 @@ type PaymentAccountMethods = Pick< | 'listActivePaymentAccounts' | 'upsertPaymentAccount' | 'setPaymentAccountMetadataKey' + | 'setPaymentAccountMetadataJson' | 'setPaymentAccountCreditUnit' | 'archiveActivePaymentAccount' | 'enqueueUsageEvent' @@ -188,6 +189,28 @@ export function paymentAccountMethods(): PaymentAccountMethods { } }, + async setPaymentAccountMetadataJson({ workspaceId, key, value }) { + try { + const result = value === null + ? await sql<{ id: string }>` + UPDATE payment_accounts + SET plugin_metadata = coalesce(plugin_metadata, '{}'::jsonb) - ${key}::text + WHERE workspace_id = ${workspaceId} AND is_active = true + RETURNING id + `.execute(getAdmin()) + : await sql<{ id: string }>` + UPDATE payment_accounts + SET plugin_metadata = coalesce(plugin_metadata, '{}'::jsonb) || jsonb_build_object(${key}::text, ${JSON.stringify(value)}::jsonb) + WHERE workspace_id = ${workspaceId} AND is_active = true + RETURNING id + `.execute(getAdmin()) + return result.rows.length > 0 + } + catch (error) { + throwDbError(error) + } + }, + async archiveActivePaymentAccount(workspaceId) { try { await getAdmin() diff --git a/server/providers/supabase-db/payment-accounts.ts b/server/providers/supabase-db/payment-accounts.ts index 7dc2e5e1..ba98914b 100644 --- a/server/providers/supabase-db/payment-accounts.ts +++ b/server/providers/supabase-db/payment-accounts.ts @@ -15,6 +15,7 @@ type PaymentAccountMethods = Pick< | 'listActivePaymentAccounts' | 'upsertPaymentAccount' | 'setPaymentAccountMetadataKey' + | 'setPaymentAccountMetadataJson' | 'setPaymentAccountCreditUnit' | 'archiveActivePaymentAccount' | 'enqueueUsageEvent' @@ -164,6 +165,30 @@ export function paymentAccountMethods(): PaymentAccountMethods { throw createError({ statusCode: 500, message: `Failed to set payment account metadata ${key}: the row kept changing` }) }, + async setPaymentAccountMetadataJson({ workspaceId, key, value }) { + const admin = getAdmin() + for (let attempt = 0; attempt < 5; attempt++) { + const { data: row, error } = await admin + .from('payment_accounts') + .select('id, plugin_metadata, updated_at') + .eq('workspace_id', workspaceId) + .eq('is_active', true) + .maybeSingle() + if (error && error.code !== 'PGRST116') throw createError({ statusCode: 500, message: error.message }) + if (!row) return false + const { [key]: _old, ...rest } = (row.plugin_metadata ?? {}) as Record + const { data: updated, error: updateError } = await admin + .from('payment_accounts') + .update({ plugin_metadata: value === null ? rest : { ...rest, [key]: value } }) + .eq('id', row.id) + .eq('updated_at', row.updated_at) + .select('id') + if (updateError) throw createError({ statusCode: 500, message: updateError.message }) + if (updated?.length) return true + } + throw createError({ statusCode: 500, message: `Failed to set payment account metadata ${key}: the row kept changing` }) + }, + async archiveActivePaymentAccount(workspaceId) { const { error } = await getAdmin() .from('payment_accounts') diff --git a/server/utils/companion-subscription.ts b/server/utils/companion-subscription.ts index 5ca976c6..15f6f5e1 100644 --- a/server/utils/companion-subscription.ts +++ b/server/utils/companion-subscription.ts @@ -23,9 +23,12 @@ */ import type { DatabaseProvider, DatabaseRow } from '../providers/database' import type { PaymentProvider } from '../providers/payment/types' -import { COMPANION_SUBSCRIPTION_KEY, isYearlyPeriod } from './overage-lock' +import { COMPANION_CLAIM_KEY, COMPANION_METERS_KEY, COMPANION_SUBSCRIPTION_KEY, isYearlyPeriod } from './overage-lock' -type Db = Pick +type Db = Pick + +/** A claim still `opening` after this long belongs to a worker that died; another may take it over. */ +const OPENING_STALE_MS = 10 * 60 * 1000 /** The companion's subscription id stored on an account, or null. */ export function companionSubscriptionIdOf(pluginMetadata: unknown): string | null { @@ -34,28 +37,92 @@ export function companionSubscriptionIdOf(pluginMetadata: unknown): string | nul return typeof id === 'string' && id.length > 0 ? id : null } -/** Open (or find) the companion for a plan subscription and record it on the account. Never throws. */ +function claimOf(pluginMetadata: unknown): string | null { + if (!pluginMetadata || typeof pluginMetadata !== 'object') return null + const claim = (pluginMetadata as Record)[COMPANION_CLAIM_KEY] + return typeof claim === 'string' ? claim : null +} + +/** Whether a stored claim leaves nothing for the reconciler to do. */ +export function claimSettled(pluginMetadata: unknown): boolean { + const claim = claimOf(pluginMetadata) + return Boolean(claim && (claim.startsWith('done:') || claim.startsWith('skipped:'))) +} + +export type CompanionOpenOutcome = 'opened' | 'existing' | 'skipped' | 'busy' | 'failed' + +/** + * Take the right to open this workspace's companion. The claim is one conditional write on the account row, so of + * several concurrent callers (the plan's created and updated webhooks, the reconciler) exactly one proceeds. + * Returns the claim it replaced (null when there was none), or false when somebody else holds it. + */ +async function claimOpening(db: Db, workspaceId: string, productId: string | null | undefined): Promise<{ previous: string | null, value: string } | false> { + const value = `opening:${Date.now()}` + if (await db.setPaymentAccountMetadataKey({ workspaceId, key: COMPANION_CLAIM_KEY, value, when: 'absent' })) return { previous: null, value } + const account = await db.getActivePaymentAccount(workspaceId) + if (!account) return false + const current = claimOf(account.plugin_metadata) + if (current === null) return false + const startedAt = current.startsWith('opening:') ? Number(current.slice('opening:'.length)) : Number.NaN + const stale = Number.isFinite(startedAt) && Date.now() - startedAt > OPENING_STALE_MS + const settledFor = current.startsWith('done:') ? current.slice('done:'.length) : current.startsWith('skipped:') ? current.slice('skipped:'.length) : null + const productChanged = settledFor !== null && Boolean(productId) && settledFor !== productId + if (current !== 'failed' && current !== '' && !stale && !productChanged) return false + return (await db.setPaymentAccountMetadataKey({ workspaceId, key: COMPANION_CLAIM_KEY, value, when: { equals: current } })) ? { previous: current, value } : false +} + +/** + * Open (or find) the companion for a plan subscription and record it on the account. Serialized per workspace by + * `claimOpening`; if the plan moved to another product, the old companion is cancelled first (a failure there + * throws, so the webhook is retried). Every other failure is logged as an ALARM and left for the reconciler. + */ export async function openCompanionSubscription( provider: PaymentProvider, db: Db, input: { workspaceId: string, plan: string | null | undefined, customerId: string, subscriptionId: string | null | undefined, productId: string | null | undefined }, -): Promise { - if (!provider.ensureCompanionSubscription) return - if ((input.plan !== 'starter' && input.plan !== 'pro') || !input.subscriptionId) return +): Promise { + if (!provider.ensureCompanionSubscription || provider.companionUsageEnabled?.() === false) return 'skipped' + if ((input.plan !== 'starter' && input.plan !== 'pro') || !input.subscriptionId) return 'skipped' + const { workspaceId } = input + const claim = await claimOpening(db, workspaceId, input.productId) + if (!claim) return 'busy' + const setClaim = (value: string, from: string) => db.setPaymentAccountMetadataKey({ workspaceId, key: COMPANION_CLAIM_KEY, value, when: { equals: from } }) + const held = claim.value + try { + // The plan moved to another product: the companion of the old one carries the wrong prices and credits. + if (claim.previous?.startsWith('done:')) { + const account = await db.getActivePaymentAccount(workspaceId) + await cancelCompanionSubscription(provider, account?.plugin_metadata, `plan product changed from ${claim.previous.slice('done:'.length)}`, true) + await db.setPaymentAccountMetadataKey({ workspaceId, key: COMPANION_METERS_KEY, value: '', when: 'different' }) + await db.setPaymentAccountMetadataKey({ workspaceId, key: COMPANION_SUBSCRIPTION_KEY, value: '', when: 'different' }) + } + } + catch (err) { + // Hand the claim back as it was, so the retry takes the same path. + await setClaim(claim.previous ?? 'failed', held).catch(() => {}) + throw err + } try { const companion = await provider.ensureCompanionSubscription({ - workspaceId: input.workspaceId, + workspaceId, plan: input.plan, customerId: input.customerId, parentSubscriptionId: input.subscriptionId, ...(input.productId ? { parentProductId: input.productId } : {}), }) - if (!companion) return - await db.setPaymentAccountMetadataKey({ workspaceId: input.workspaceId, key: COMPANION_SUBSCRIPTION_KEY, value: companion.subscriptionId, when: 'different' }) + if (!companion) { + await setClaim(`skipped:${input.productId ?? ''}`, held) + return 'skipped' + } + await db.setPaymentAccountMetadataKey({ workspaceId, key: COMPANION_SUBSCRIPTION_KEY, value: companion.subscriptionId, when: 'different' }) + await setClaim(`done:${input.productId ?? ''}`, held) + return companion.created ? 'opened' : 'existing' } catch (err) { // eslint-disable-next-line no-console -- the alarm: watched by the platform's log alert; the reconciler retries - console.error(`[companion] ALARM could not open the usage subscription for workspace ${input.workspaceId} (plan subscription ${input.subscriptionId}):`, err) + console.error(`[companion] ALARM could not open the usage subscription for workspace ${workspaceId} (plan subscription ${input.subscriptionId}):`, err) + await setClaim('failed', held).catch(() => {}) + return 'failed' } } @@ -94,13 +161,17 @@ export function accountsMissingCompanion(rows: DatabaseRow[]): DatabaseRow[] { row.subscription_status === 'active' && Boolean(row.subscription_id) && Boolean(row.customer_id) && isYearlyPeriod({ current_period_start: row.current_period_start as string | null, current_period_end: row.current_period_end as string | null }) - && !companionSubscriptionIdOf(row.plugin_metadata), + && !companionSubscriptionIdOf(row.plugin_metadata) + && !claimSettled(row.plugin_metadata), ) } +const RECONCILE_PAGE = 500 + /** * Open the companion for every active yearly plan that lacks one (a webhook that failed to open it, or a * subscription that predates the flag). The provider decides whether the account's product gets one at all. + * Goes through the same per-workspace claim as the webhook, so the two cannot open it twice. */ export async function reconcileCompanionSubscriptions( provider: PaymentProvider, @@ -108,21 +179,27 @@ export async function reconcileCompanionSubscriptions( providerKey: string, ): Promise { const summary: CompanionReconcileSummary = { checked: 0, opened: 0, failed: 0 } - if (!provider.ensureCompanionSubscription) return summary - for (const row of accountsMissingCompanion(await db.listActivePaymentAccounts(providerKey, 500))) { + // Off: no companion step runs and the accounts are not even listed. + if (!provider.ensureCompanionSubscription || provider.companionUsageEnabled?.() !== true) return summary + const rows = await db.listActivePaymentAccounts(providerKey, RECONCILE_PAGE) + if (rows.length >= RECONCILE_PAGE) { + // eslint-disable-next-line no-console -- accounts past the page are not looked at; this needs paging before then + console.warn(`[companion] reconcile saw ${rows.length} active accounts, the page limit: later ones are not checked`) + } + for (const row of accountsMissingCompanion(rows)) { summary.checked++ const workspaceId = String(row.workspace_id) try { // The account does not store the plan subscription's product: the provider reads it from the subscription. - const companion = await provider.ensureCompanionSubscription({ + const outcome = await openCompanionSubscription(provider, db, { workspaceId, plan: row.plan === 'pro' ? 'pro' : 'starter', customerId: String(row.customer_id), - parentSubscriptionId: String(row.subscription_id), + subscriptionId: String(row.subscription_id), + productId: null, }) - if (!companion) continue - await db.setPaymentAccountMetadataKey({ workspaceId, key: COMPANION_SUBSCRIPTION_KEY, value: companion.subscriptionId, when: 'different' }) - if (companion.created) summary.opened++ + if (outcome === 'opened') summary.opened++ + if (outcome === 'failed') summary.failed++ } catch (err) { summary.failed++ diff --git a/server/utils/overage-lock.ts b/server/utils/overage-lock.ts index d8d8353d..4918f4db 100644 --- a/server/utils/overage-lock.ts +++ b/server/utils/overage-lock.ts @@ -60,6 +60,8 @@ export const OVERAGE_SUSPENDED_KEY = 'overage_suspended' */ export const COMPANION_SUBSCRIPTION_KEY = 'companion_subscription_id' export const COMPANION_METERS_KEY = 'companion_billable_meters' +/** Serializes who opens a workspace's companion: `opening:` | `failed` | `done:` | `skipped:`. */ +export const COMPANION_CLAIM_KEY = 'companion_claim' const METER_NAME_BY_SETTINGS_KEY: Record = Object.fromEntries( USAGE_METER_LIST.map(m => [m.settingsKey, m.name]), diff --git a/tests/contract/payment-accounts.contract.test.ts b/tests/contract/payment-accounts.contract.test.ts index cab45725..d4d8d2f7 100644 --- a/tests/contract/payment-accounts.contract.test.ts +++ b/tests/contract/payment-accounts.contract.test.ts @@ -101,6 +101,25 @@ describe('postgres-db payment-accounts (contract)', () => { expect(await methods.setPaymentAccountMetadataKey({ workspaceId: user.workspaceId, key: 'k', value: 'v', when: 'absent' })).toBe(false) }) + it('metadata json: sets and removes one structured key, leaves the rest; list returns only active accounts of the provider', async () => { + await methods.archiveActivePaymentAccount(user.workspaceId) + await methods.upsertPaymentAccount({ workspaceId: user.workspaceId, provider: 'polar', customerId: `cus_json_${randomUUID()}`, subscriptionStatus: 'active', pluginMetadata: { billable_meters: ['a'], companion_claim: 'done:p' } }) + expect(await methods.setPaymentAccountMetadataJson({ workspaceId: user.workspaceId, key: 'overage_suspended', value: ['ai_messages'] })).toBe(true) + let row = await methods.getActivePaymentAccount(user.workspaceId) + expect(row?.plugin_metadata).toEqual({ billable_meters: ['a'], companion_claim: 'done:p', overage_suspended: ['ai_messages'] }) + expect(await methods.setPaymentAccountMetadataJson({ workspaceId: user.workspaceId, key: 'overage_suspended', value: null })).toBe(true) + row = await methods.getActivePaymentAccount(user.workspaceId) + expect(row?.plugin_metadata).toEqual({ billable_meters: ['a'], companion_claim: 'done:p' }) + + const listed = await methods.listActivePaymentAccounts('polar', 500) + expect(listed.some(r => r.workspace_id === user.workspaceId)).toBe(true) + expect(await methods.listActivePaymentAccounts('no-such-provider', 500)).toEqual([]) + + await methods.archiveActivePaymentAccount(user.workspaceId) + expect(await methods.setPaymentAccountMetadataJson({ workspaceId: user.workspaceId, key: 'k', value: ['x'] })).toBe(false) + expect((await methods.listActivePaymentAccounts('polar', 500)).some(r => r.workspace_id === user.workspaceId)).toBe(false) + }) + it('credit unit: a change converts the period\'s credit counters in the same transaction (QA-12 B3)', async () => { const { getDb, sql } = await import('./helpers') await methods.archiveActivePaymentAccount(user.workspaceId) diff --git a/tests/integration/billing-webhook-companion.integration.test.ts b/tests/integration/billing-webhook-companion.integration.test.ts index 5edf6a63..5f74ccf9 100644 --- a/tests/integration/billing-webhook-companion.integration.test.ts +++ b/tests/integration/billing-webhook-companion.integration.test.ts @@ -16,7 +16,10 @@ describe('billing webhook: companion usage subscription', () => { const getActivePaymentAccount = vi.fn() const getWorkspaceById = vi.fn() const markWorkspaceTrialConsumed = vi.fn().mockResolvedValue(undefined) - const setPaymentAccountMetadataKey = vi.fn(async ({ when }: { when: unknown }) => when === 'different') + // `absent` (the claim) wins unless a claim is already held; every other conditional write lands. + let claimHeld = false + const setPaymentAccountMetadataKey = vi.fn(async ({ when }: { when: unknown }) => when === 'absent' ? !claimHeld : true) + const setPaymentAccountMetadataJson = vi.fn().mockResolvedValue(true) const setPaymentAccountCreditUnit = vi.fn().mockResolvedValue(false) const ensureCompanionSubscription = vi.fn() const cancelSubscription = vi.fn() @@ -42,7 +45,7 @@ describe('billing webhook: companion usage subscription', () => { vi.stubGlobal('useEmailProvider', vi.fn().mockReturnValue(null)) vi.stubGlobal('useDatabaseProvider', vi.fn().mockReturnValue({ upsertPaymentAccount, archiveActivePaymentAccount, updateWorkspace, getActivePaymentAccount, getWorkspaceById, - markWorkspaceTrialConsumed, setPaymentAccountMetadataKey, setPaymentAccountCreditUnit, + markWorkspaceTrialConsumed, setPaymentAccountMetadataKey, setPaymentAccountMetadataJson, setPaymentAccountCreditUnit, })) getWorkspaceById.mockResolvedValue({ id: 'ws-1', overage_settings: { ai_messages: false } }) ensureCompanionSubscription.mockReset().mockResolvedValue({ subscriptionId: 'sub_c1', created: true }) @@ -54,6 +57,8 @@ describe('billing webhook: companion usage subscription', () => { for (const fn of [upsertPaymentAccount, archiveActivePaymentAccount, updateWorkspace, getActivePaymentAccount, getWorkspaceById, markWorkspaceTrialConsumed, setPaymentAccountCreditUnit]) fn.mockReset() upsertPaymentAccount.mockResolvedValue({}) setPaymentAccountMetadataKey.mockClear() + setPaymentAccountMetadataJson.mockClear() + claimHeld = false }) async function load() { @@ -67,7 +72,7 @@ describe('billing webhook: companion usage subscription', () => { isConfigured: () => true, create: () => ({ createCheckoutSession: vi.fn(), createPortalSession: vi.fn(), handleWebhook: handleWebhookMock, cancelSubscription, - createBundleCheckout: vi.fn(), moveBundleSubscriptionToList: vi.fn(), ingestUsageEvent: vi.fn(), ensureCompanionSubscription, + createBundleCheckout: vi.fn(), moveBundleSubscriptionToList: vi.fn(), ingestUsageEvent: vi.fn(), ensureCompanionSubscription, companionUsageEnabled: () => true, }), }) return (await import('../../server/api/billing/webhook/[provider].post.ts')).default @@ -85,9 +90,18 @@ describe('billing webhook: companion usage subscription', () => { expect(ensureCompanionSubscription).toHaveBeenCalledWith({ workspaceId: 'ws-1', plan: 'pro', customerId: 'cus_1', parentSubscriptionId: 'sub_1', parentProductId: 'prod_pro_y', }) + expect(setPaymentAccountMetadataKey).toHaveBeenCalledWith(expect.objectContaining({ key: 'companion_claim', when: 'absent' })) expect(setPaymentAccountMetadataKey).toHaveBeenCalledWith({ workspaceId: 'ws-1', key: 'companion_subscription_id', value: 'sub_c1', when: 'different' }) }) + it('does not open a second companion while another caller holds the claim', async () => { + claimHeld = true + getActivePaymentAccount.mockResolvedValue(yearlyAccount({ companion_claim: `opening:${Date.now()}` })) + handleWebhookMock.mockResolvedValue(planCreated) + await post() + expect(ensureCompanionSubscription).not.toHaveBeenCalled() + }) + it('a companion that cannot be opened never fails the plan subscription\'s webhook', async () => { const log = vi.spyOn(console, 'error').mockImplementation(() => {}) handleWebhookMock.mockResolvedValue(planCreated) @@ -121,10 +135,9 @@ describe('billing webhook: companion usage subscription', () => { handleWebhookMock.mockResolvedValue({ event: 'subscription.updated', companion: true, workspaceId: 'ws-1', subscriptionId: 'sub_c1', customerId: 'cus_1', subscriptionStatus: 'active', billableMeters: ['ai_credits', 'api_credits'] }) await post() expect(updateWorkspace).toHaveBeenCalledWith('', 'ws-1', { overage_settings: { ai_messages: true } }) - const written = upsertPaymentAccount.mock.calls[0]![0] - expect(written).toMatchObject({ subscriptionId: 'sub_1', subscriptionStatus: 'active', plan: 'pro', customerId: 'cus_1', currentPeriodEnd: '2027-10-01T00:00:00Z' }) - expect(written.preserveMetadataKeys).toEqual(expect.arrayContaining(['companion_subscription_id', 'companion_billable_meters'])) - expect(written.pluginMetadata.overage_suspended).toBeUndefined() + // Only the suspended list is written, and alone: no whole-row write of this event's snapshot. + expect(upsertPaymentAccount).not.toHaveBeenCalled() + expect(setPaymentAccountMetadataJson).toHaveBeenCalledWith({ workspaceId: 'ws-1', key: 'overage_suspended', value: null }) }) it('ignores a late event about a companion the account no longer has', async () => { @@ -141,10 +154,18 @@ describe('billing webhook: companion usage subscription', () => { await post() expect(setPaymentAccountMetadataKey).toHaveBeenCalledWith({ workspaceId: 'ws-1', key: 'companion_subscription_id', value: '', when: 'different' }) expect(setPaymentAccountMetadataKey).toHaveBeenCalledWith({ workspaceId: 'ws-1', key: 'companion_billable_meters', value: '', when: 'different' }) + expect(setPaymentAccountMetadataKey).toHaveBeenCalledWith({ workspaceId: 'ws-1', key: 'companion_claim', value: 'failed', when: 'different' }) expect(archiveActivePaymentAccount).not.toHaveBeenCalled() expect(updateWorkspace).not.toHaveBeenCalledWith('', 'ws-1', { plan: 'free', trial_reminder_stage: 0 }) }) + it('a replayed event of a companion that already ended does not bring it back', async () => { + getActivePaymentAccount.mockResolvedValue(yearlyAccount({})) + handleWebhookMock.mockResolvedValue({ event: 'subscription.created', companion: true, workspaceId: 'ws-1', subscriptionId: 'sub_c1', customerId: 'cus_1', subscriptionStatus: 'canceled', billableMeters: ['ai_credits'] }) + await post() + expect(setPaymentAccountMetadataKey).not.toHaveBeenCalled() + }) + it('a companion\'s usage invoice is not the plan\'s payment', async () => { getActivePaymentAccount.mockResolvedValue(yearlyAccount({ companion_subscription_id: 'sub_c1' })) handleWebhookMock.mockResolvedValue({ event: 'invoice.paid', companion: true, workspaceId: 'ws-1', subscriptionId: 'sub_c1', customerId: 'cus_1', invoiceId: 'ord_9' }) @@ -154,12 +175,14 @@ describe('billing webhook: companion usage subscription', () => { }) it('a plan subscription update keeps what its companion recorded, and opens the companion when there is none', async () => { - getActivePaymentAccount.mockResolvedValue(yearlyAccount({ companion_subscription_id: 'sub_c1', companion_billable_meters: 'ai_credits' })) + claimHeld = true + getActivePaymentAccount.mockResolvedValue(yearlyAccount({ companion_subscription_id: 'sub_c1', companion_billable_meters: 'ai_credits', companion_claim: 'done:prod_pro_y' })) handleWebhookMock.mockResolvedValue({ ...planCreated, event: 'subscription.updated' }) await post() expect(upsertPaymentAccount.mock.calls[0]![0].preserveMetadataKeys).toEqual(expect.arrayContaining(['companion_subscription_id', 'companion_billable_meters'])) expect(ensureCompanionSubscription).not.toHaveBeenCalled() + claimHeld = false getActivePaymentAccount.mockResolvedValue(yearlyAccount({})) await post() expect(ensureCompanionSubscription).toHaveBeenCalledTimes(1) @@ -173,14 +196,24 @@ describe('billing webhook: companion usage subscription', () => { expect(archiveActivePaymentAccount).toHaveBeenCalledWith('ws-1') }) - it('a companion that will not cancel is an ALARM, and the plan still ends', async () => { + it('a companion that will not cancel fails the webhook, so Polar retries, and the account is not archived', async () => { const log = vi.spyOn(console, 'error').mockImplementation(() => {}) getActivePaymentAccount.mockResolvedValue(yearlyAccount({ companion_subscription_id: 'sub_c1' })) cancelSubscription.mockRejectedValue(new Error('polar down')) handleWebhookMock.mockResolvedValue({ event: 'subscription.canceled', workspaceId: 'ws-1', subscriptionId: 'sub_1', customerId: 'cus_1', subscriptionStatus: 'canceled' }) - await post() + await expect(post()).rejects.toThrow('polar down') expect(log).toHaveBeenCalledWith(expect.stringContaining('[companion] ALARM could not cancel'), expect.any(Error)) - expect(archiveActivePaymentAccount).toHaveBeenCalledWith('ws-1') + expect(archiveActivePaymentAccount).not.toHaveBeenCalled() log.mockRestore() }) + + it('forgets the ended companion on the row a resubscribe will reuse', async () => { + getActivePaymentAccount.mockResolvedValue(yearlyAccount({ companion_subscription_id: 'sub_c1', companion_billable_meters: 'ai_credits', companion_claim: 'done:prod_pro_y' })) + handleWebhookMock.mockResolvedValue({ event: 'subscription.canceled', workspaceId: 'ws-1', subscriptionId: 'sub_1', customerId: 'cus_1', subscriptionStatus: 'canceled' }) + await post() + for (const key of ['companion_subscription_id', 'companion_billable_meters', 'companion_claim']) { + expect(setPaymentAccountMetadataKey).toHaveBeenCalledWith({ workspaceId: 'ws-1', key, value: '', when: 'different' }) + } + expect(archiveActivePaymentAccount).toHaveBeenCalledWith('ws-1') + }) }) diff --git a/tests/integration/billing-webhook.integration.test.ts b/tests/integration/billing-webhook.integration.test.ts index 6251f375..50f15537 100644 --- a/tests/integration/billing-webhook.integration.test.ts +++ b/tests/integration/billing-webhook.integration.test.ts @@ -550,7 +550,7 @@ describe('billing webhook integration', () => { expect(upsertPaymentAccount).toHaveBeenCalledWith(expect.objectContaining({ pluginMetadata: { billable_meters: LEGACY_PRICES, overage_suspended: ['ai_messages'] }, - preserveMetadataKeys: ['activation_email', 'recovery_email', 'companion_subscription_id', 'companion_billable_meters'], + preserveMetadataKeys: ['activation_email', 'recovery_email', 'companion_subscription_id', 'companion_billable_meters', 'companion_claim'], })) // Trial → active also marks the activation email owed (it goes out on the first paid order). expect(setPaymentAccountMetadataKey).toHaveBeenCalledWith({ workspaceId: 'ws-1', key: 'activation_email', value: 'pending', when: 'absent' }) diff --git a/tests/integration/delete-routes.integration.test.ts b/tests/integration/delete-routes.integration.test.ts index 8a934e94..f7544823 100644 --- a/tests/integration/delete-routes.integration.test.ts +++ b/tests/integration/delete-routes.integration.test.ts @@ -155,6 +155,45 @@ describe('workspace and project delete route integration', () => { }) }) + it('cancels the usage subscription before the plan, and a companion that will not cancel fails the delete with 502', async () => { + const deleteWorkspace = vi.fn().mockResolvedValue(undefined) + const stub = (cancelSubscription: ReturnType) => { + vi.stubGlobal('getRouterParam', vi.fn(() => 'workspace-1')) + vi.stubGlobal('requireAuth', vi.fn().mockReturnValue({ user: { id: 'owner-1' }, accessToken: 'token-1' })) + vi.stubGlobal('readBody', vi.fn().mockResolvedValue({ cancelSubscription: true })) + vi.stubGlobal('useCDNProvider', vi.fn().mockReturnValue(null)) + vi.stubGlobal('usePaymentProvider', vi.fn().mockReturnValue({ cancelSubscription })) + vi.stubGlobal('useDatabaseProvider', vi.fn().mockReturnValue({ + requireWorkspaceRole: vi.fn().mockResolvedValue('owner'), + getWorkspaceById: vi.fn().mockResolvedValue({ id: 'workspace-1', type: 'secondary', owner_id: 'owner-1', github_installation_id: null }), + getActivePaymentAccount: vi.fn().mockResolvedValue({ subscription_id: 'sub_plan', plugin_metadata: { companion_subscription_id: 'sub_companion' } }), + listWorkspaceProjects: vi.fn().mockResolvedValue([]), + deleteWorkspace, + })) + } + const del = async () => { + let status = 0 + await withTestServer({ routes: [{ path: '/api/workspaces/workspace-1', handler: await loadWorkspaceDeleteHandler() }] }, async ({ request }) => { + status = (await request('/api/workspaces/workspace-1', { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ cancelSubscription: true }) })).status + }) + return status + } + + const ok = vi.fn().mockResolvedValue('canceled') + stub(ok) + expect(await del()).toBe(200) + expect(ok.mock.calls.map(c => c[0])).toEqual(['sub_companion', 'sub_plan']) + + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + deleteWorkspace.mockClear() + const failing = vi.fn().mockRejectedValue(new Error('polar down')) + stub(failing) + expect(await del()).toBe(502) + expect(failing).toHaveBeenCalledTimes(1) + expect(deleteWorkspace).not.toHaveBeenCalled() + log.mockRestore() + }) + it('deletes a workspace after cleaning project storage and ignores storage cleanup failures', async () => { const deletePrefix = vi.fn() .mockRejectedValueOnce(new Error('r2 unavailable')) diff --git a/tests/unit/companion-subscription-util.test.ts b/tests/unit/companion-subscription-util.test.ts index c4dd1bb4..2b730daf 100644 --- a/tests/unit/companion-subscription-util.test.ts +++ b/tests/unit/companion-subscription-util.test.ts @@ -16,6 +16,7 @@ const yearlyRow = (over: Record = {}) => ({ const provider = (over: Record = {}) => ({ cancelSubscription: vi.fn().mockResolvedValue('canceled'), ensureCompanionSubscription: vi.fn().mockResolvedValue({ subscriptionId: 'sub_c1', created: true }), + companionUsageEnabled: () => true, ...over, }) as never @@ -28,37 +29,108 @@ describe('companionSubscriptionIdOf', () => { }) }) +/** An account row with the same conditional single-key write the real adapters make. */ +function fakeDb(metadata: Record = {}) { + const row = { plugin_metadata: { ...metadata } as Record } + return { + row, + getActivePaymentAccount: vi.fn(async () => ({ workspace_id: 'ws-1', plugin_metadata: { ...row.plugin_metadata } })), + setPaymentAccountMetadataKey: vi.fn(async ({ key, value, when }: { key: string, value: string, when: 'absent' | 'different' | { equals: string } }) => { + const current = row.plugin_metadata[key] + const allowed = when === 'absent' ? !(key in row.plugin_metadata) : when === 'different' ? current !== value : current === when.equals + if (!allowed) return false + row.plugin_metadata[key] = value + return true + }), + } +} + describe('openCompanionSubscription', () => { const args = { workspaceId: 'ws-1', plan: 'pro', customerId: 'cus_1', subscriptionId: 'sub_1', productId: 'prod_pro_y' } + const ensureOf = (p: unknown) => (p as { ensureCompanionSubscription: ReturnType }).ensureCompanionSubscription - it('records the companion on the account through the single-key write', async () => { - const db = { setPaymentAccountMetadataKey: vi.fn().mockResolvedValue(true) } + it('claims the workspace, records the companion and settles the claim for this product', async () => { + const db = fakeDb() const p = provider() - await openCompanionSubscription(p, db, args) - expect((p as { ensureCompanionSubscription: ReturnType }).ensureCompanionSubscription).toHaveBeenCalledWith({ + expect(await openCompanionSubscription(p, db, args)).toBe('opened') + expect(ensureOf(p)).toHaveBeenCalledWith({ workspaceId: 'ws-1', plan: 'pro', customerId: 'cus_1', parentSubscriptionId: 'sub_1', parentProductId: 'prod_pro_y', }) - expect(db.setPaymentAccountMetadataKey).toHaveBeenCalledWith({ workspaceId: 'ws-1', key: 'companion_subscription_id', value: 'sub_c1', when: 'different' }) + expect(db.row.plugin_metadata.companion_subscription_id).toBe('sub_c1') + expect(db.row.plugin_metadata.companion_claim).toBe('done:prod_pro_y') }) - it('does nothing when the provider has none for this subscription (off, monthly plan)', async () => { - const db = { setPaymentAccountMetadataKey: vi.fn() } - await openCompanionSubscription(provider({ ensureCompanionSubscription: vi.fn().mockResolvedValue(null) }), db, args) - expect(db.setPaymentAccountMetadataKey).not.toHaveBeenCalled() + it('opens exactly one companion when several callers race (created, updated, reconciler)', async () => { + const db = fakeDb() + const ensure = vi.fn(async () => { + await new Promise(r => setTimeout(r, 5)) + return { subscriptionId: 'sub_c1', created: true } + }) + const p = provider({ ensureCompanionSubscription: ensure }) + const outcomes = await Promise.all([1, 2, 3, 4, 5].map(() => openCompanionSubscription(p, db, args))) + expect(ensure).toHaveBeenCalledTimes(1) + expect(outcomes.filter(o => o === 'opened')).toHaveLength(1) + expect(outcomes.filter(o => o === 'busy')).toHaveLength(4) + }) + + it('does not open again once settled for the same product', async () => { + const db = fakeDb({ companion_claim: 'done:prod_pro_y', companion_subscription_id: 'sub_c1' }) + const p = provider() + expect(await openCompanionSubscription(p, db, args)).toBe('busy') + expect(ensureOf(p)).not.toHaveBeenCalled() + }) + + it('takes over a claim whose holder died, but not a fresh one', async () => { + const stale = fakeDb({ companion_claim: `opening:${Date.now() - 11 * 60 * 1000}` }) + expect(await openCompanionSubscription(provider(), stale, args)).toBe('opened') + const fresh = fakeDb({ companion_claim: `opening:${Date.now() - 1000}` }) + expect(await openCompanionSubscription(provider(), fresh, args)).toBe('busy') + }) + + it('replaces the companion when the plan moved to another product: old one cancelled first, then a new one', async () => { + const db = fakeDb({ companion_claim: 'done:prod_starter_y', companion_subscription_id: 'sub_old', companion_billable_meters: 'a,b' }) + const p = provider() + expect(await openCompanionSubscription(p, db, args)).toBe('opened') + expect((p as { cancelSubscription: ReturnType }).cancelSubscription).toHaveBeenCalledWith('sub_old') + expect(db.row.plugin_metadata.companion_subscription_id).toBe('sub_c1') + expect(db.row.plugin_metadata.companion_claim).toBe('done:prod_pro_y') + }) + + it('a product switch whose old companion will not cancel throws and leaves the claim as it was, so the webhook retries', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + const db = fakeDb({ companion_claim: 'done:prod_starter_y', companion_subscription_id: 'sub_old' }) + const p = provider({ cancelSubscription: vi.fn().mockRejectedValue(new Error('polar down')) }) + await expect(openCompanionSubscription(p, db, args)).rejects.toThrow('polar down') + expect(db.row.plugin_metadata.companion_claim).toBe('done:prod_starter_y') + expect(db.row.plugin_metadata.companion_subscription_id).toBe('sub_old') + expect(ensureOf(p)).not.toHaveBeenCalled() + log.mockRestore() }) - it('does nothing for a provider without companions, and for a plan that is not billable', async () => { - const db = { setPaymentAccountMetadataKey: vi.fn() } - await openCompanionSubscription(provider({ ensureCompanionSubscription: undefined }), db, args) - await openCompanionSubscription(provider(), db, { ...args, plan: 'free' }) + it('does nothing, and writes nothing, when companions are off or the provider has none', async () => { + const db = fakeDb() + expect(await openCompanionSubscription(provider({ companionUsageEnabled: () => false }), db, args)).toBe('skipped') + expect(await openCompanionSubscription(provider({ ensureCompanionSubscription: undefined }), db, args)).toBe('skipped') + expect(await openCompanionSubscription(provider(), db, { ...args, plan: 'free' })).toBe('skipped') expect(db.setPaymentAccountMetadataKey).not.toHaveBeenCalled() }) - it('never throws: a failure is an ALARM in the log and the reconciler retries', async () => { + it('remembers that this product gets no companion (monthly plan) instead of asking again', async () => { + const db = fakeDb() + const p = provider({ ensureCompanionSubscription: vi.fn().mockResolvedValue(null) }) + expect(await openCompanionSubscription(p, db, args)).toBe('skipped') + expect(db.row.plugin_metadata.companion_claim).toBe('skipped:prod_pro_y') + expect(await openCompanionSubscription(p, db, args)).toBe('busy') + }) + + it('never throws on a provider failure: an ALARM in the log, the claim is released for the reconciler', async () => { const log = vi.spyOn(console, 'error').mockImplementation(() => {}) - const db = { setPaymentAccountMetadataKey: vi.fn() } - await expect(openCompanionSubscription(provider({ ensureCompanionSubscription: vi.fn().mockRejectedValue(new Error('polar down')) }), db, args)).resolves.toBeUndefined() + const db = fakeDb() + const p = provider({ ensureCompanionSubscription: vi.fn().mockRejectedValueOnce(new Error('polar down')).mockResolvedValue({ subscriptionId: 'sub_c1', created: true }) }) + expect(await openCompanionSubscription(p, db, args)).toBe('failed') expect(log).toHaveBeenCalledWith(expect.stringContaining('[companion] ALARM'), expect.any(Error)) + expect(db.row.plugin_metadata.companion_claim).toBe('failed') + expect(await openCompanionSubscription(p, db, args)).toBe('opened') log.mockRestore() }) }) @@ -93,31 +165,32 @@ describe('reconcileCompanionSubscriptions', () => { yearlyRow({ workspace_id: 'ws-monthly', current_period_end: '2026-11-01T00:00:00Z' }), yearlyRow({ workspace_id: 'ws-trial', subscription_status: 'trialing' }), yearlyRow({ workspace_id: 'ws-nosub', subscription_id: null }), + yearlyRow({ workspace_id: 'ws-settled', plugin_metadata: { companion_claim: 'skipped:prod_pro_m' } }), ] as never expect(accountsMissingCompanion(rows).map(r => r.workspace_id)).toEqual(['ws-1']) }) - it('opens the missing ones and records them; the provider reads the plan subscription\'s product itself', async () => { - const db = { listActivePaymentAccounts: vi.fn().mockResolvedValue([yearlyRow()]), setPaymentAccountMetadataKey: vi.fn().mockResolvedValue(true) } + it('opens the missing ones through the same claim; the provider reads the plan subscription\'s product itself', async () => { + const db = { ...fakeDb(), listActivePaymentAccounts: vi.fn().mockResolvedValue([yearlyRow()]) } const p = provider() expect(await reconcileCompanionSubscriptions(p, db, 'polar')).toEqual({ checked: 1, opened: 1, failed: 0 }) expect((p as { ensureCompanionSubscription: ReturnType }).ensureCompanionSubscription).toHaveBeenCalledWith({ workspaceId: 'ws-1', plan: 'pro', customerId: 'cus_1', parentSubscriptionId: 'sub_1', }) - expect(db.setPaymentAccountMetadataKey).toHaveBeenCalledWith(expect.objectContaining({ key: 'companion_subscription_id', value: 'sub_c1' })) + expect(db.row.plugin_metadata.companion_subscription_id).toBe('sub_c1') }) it('counts a failure and goes on with the rest', async () => { const log = vi.spyOn(console, 'error').mockImplementation(() => {}) - const db = { listActivePaymentAccounts: vi.fn().mockResolvedValue([yearlyRow(), yearlyRow({ workspace_id: 'ws-2' })]), setPaymentAccountMetadataKey: vi.fn().mockResolvedValue(true) } + const db = { ...fakeDb(), listActivePaymentAccounts: vi.fn().mockResolvedValue([yearlyRow(), yearlyRow({ workspace_id: 'ws-2' })]) } const ensure = vi.fn().mockRejectedValueOnce(new Error('polar down')).mockResolvedValueOnce({ subscriptionId: 'sub_c2', created: true }) expect(await reconcileCompanionSubscriptions(provider({ ensureCompanionSubscription: ensure }), db, 'polar')).toEqual({ checked: 2, opened: 1, failed: 1 }) log.mockRestore() }) - it('is a no-op when companions are off (the provider answers null)', async () => { - const db = { listActivePaymentAccounts: vi.fn().mockResolvedValue([yearlyRow()]), setPaymentAccountMetadataKey: vi.fn() } - expect(await reconcileCompanionSubscriptions(provider({ ensureCompanionSubscription: vi.fn().mockResolvedValue(null) }), db, 'polar')).toEqual({ checked: 1, opened: 0, failed: 0 }) - expect(db.setPaymentAccountMetadataKey).not.toHaveBeenCalled() + it('is a no-op that does not even list accounts when companions are off', async () => { + const db = { ...fakeDb(), listActivePaymentAccounts: vi.fn().mockResolvedValue([yearlyRow()]) } + expect(await reconcileCompanionSubscriptions(provider({ companionUsageEnabled: () => false }), db, 'polar')).toEqual({ checked: 0, opened: 0, failed: 0 }) + expect(db.listActivePaymentAccounts).not.toHaveBeenCalled() }) }) From e948b84c8c1331deebd3c4315f4cc928eeb843ea Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:34:15 +0300 Subject: [PATCH 3/4] fix(billing): companion claim survives created upsert; reconciler stamp is learned The plan created upsert now preserves the companion keys, claim included, so a racing open keeps its claim. A claim stamped without a product by the reconciler is learned on the next plan event instead of cancelling a healthy companion. An ending companion event with none recorded is ignored. --- server/api/billing/webhook/[provider].post.ts | 11 +++++++++-- server/utils/companion-subscription.ts | 13 +++++++++---- .../contract/payment-accounts.contract.test.ts | 18 ++++++++++++++++++ ...lling-webhook-companion.integration.test.ts | 17 +++++++++++++++-- tests/unit/companion-subscription-util.test.ts | 12 ++++++++++++ 5 files changed, 63 insertions(+), 8 deletions(-) diff --git a/server/api/billing/webhook/[provider].post.ts b/server/api/billing/webhook/[provider].post.ts index 60ffd5af..3bc17350 100644 --- a/server/api/billing/webhook/[provider].post.ts +++ b/server/api/billing/webhook/[provider].post.ts @@ -19,7 +19,7 @@ import { PLAN_PRICING, normalizePlan } from '../../../../shared/utils/license' import { emailTemplate } from '../../../utils/content-strings' import { BILLABLE_METERS_KEY, COMPANION_CLAIM_KEY, COMPANION_METERS_KEY, COMPANION_SUBSCRIPTION_KEY, OVERAGE_SUSPENDED_KEY, reconcileOverageLock } from '../../../utils/overage-lock' import type { OverageLockAccount } from '../../../utils/overage-lock' -import { cancelCompanionSubscription, companionSubscriptionIdOf, openCompanionSubscription } from '../../../utils/companion-subscription' +import { cancelCompanionSubscription, companionClaimOf, companionSubscriptionIdOf, openCompanionSubscription } from '../../../utils/companion-subscription' import type { WebhookResult } from '../../../providers/payment/types' type Db = ReturnType @@ -45,6 +45,8 @@ const RECOVERY_EMAIL_KEY = 'recovery_email' * Keys only `setPaymentAccountMetadataKey` writes; upserts built from a read keep them. The companion usage * subscription's keys are among them: the plan subscription's events must not wipe what its companion recorded. */ +/** The companion's own keys: preserved by every plan upsert, including the first one, which sets the activation mark itself. */ +const COMPANION_METADATA_KEYS = [COMPANION_SUBSCRIPTION_KEY, COMPANION_METERS_KEY, COMPANION_CLAIM_KEY] const CLAIMED_METADATA_KEYS = [ACTIVATION_EMAIL_KEY, RECOVERY_EMAIL_KEY, COMPANION_SUBSCRIPTION_KEY, COMPANION_METERS_KEY, COMPANION_CLAIM_KEY] /** The past-due episode a read row is in, as its recovery claim value. */ @@ -265,12 +267,15 @@ async function applyCompanionEvent(db: Db, result: WebhookResult): Promise const recorded = companionSubscriptionIdOf(account.plugin_metadata) // A late event about a companion the account has since replaced says nothing about the current one. if (recorded && recorded !== result.subscriptionId) return + // With none recorded, an ending says nothing about the account: it may be mid-open or mid-switch. + if (!recorded && result.event === 'subscription.canceled') return if (result.event === 'subscription.canceled') { await db.setPaymentAccountMetadataKey({ workspaceId, key: COMPANION_METERS_KEY, value: '', when: 'different' }) await db.setPaymentAccountMetadataKey({ workspaceId, key: COMPANION_SUBSCRIPTION_KEY, value: '', when: 'different' }) // The companion is gone while the plan lives on: let the next plan event or reconcile open a new one. - await db.setPaymentAccountMetadataKey({ workspaceId, key: COMPANION_CLAIM_KEY, value: 'failed', when: 'different' }) + const claim = companionClaimOf(account.plugin_metadata) + if (claim?.startsWith('done:')) await db.setPaymentAccountMetadataKey({ workspaceId, key: COMPANION_CLAIM_KEY, value: 'failed', when: { equals: claim } }) } else if (result.event === 'subscription.created' || result.event === 'subscription.updated') { // A replayed event of a companion that has already ended (Polar delivers out of order) must not bring it back. @@ -392,6 +397,8 @@ export default defineEventHandler(async (event) => { // a v2 product meters `_1c` credits, a pre-v2 one $0.03 credits. No // credit meter in the list says nothing: the stored unit stays. ...(createdUnit ? { creditUnit: createdUnit } : {}), + // A companion being opened by the updated webhook holds its claim on this row: this write must not wipe it. + preserveMetadataKeys: COMPANION_METADATA_KEYS, isActive: true, }) await overageLock.commit() diff --git a/server/utils/companion-subscription.ts b/server/utils/companion-subscription.ts index 15f6f5e1..791d8e63 100644 --- a/server/utils/companion-subscription.ts +++ b/server/utils/companion-subscription.ts @@ -37,7 +37,7 @@ export function companionSubscriptionIdOf(pluginMetadata: unknown): string | nul return typeof id === 'string' && id.length > 0 ? id : null } -function claimOf(pluginMetadata: unknown): string | null { +export function companionClaimOf(pluginMetadata: unknown): string | null { if (!pluginMetadata || typeof pluginMetadata !== 'object') return null const claim = (pluginMetadata as Record)[COMPANION_CLAIM_KEY] return typeof claim === 'string' ? claim : null @@ -45,7 +45,7 @@ function claimOf(pluginMetadata: unknown): string | null { /** Whether a stored claim leaves nothing for the reconciler to do. */ export function claimSettled(pluginMetadata: unknown): boolean { - const claim = claimOf(pluginMetadata) + const claim = companionClaimOf(pluginMetadata) return Boolean(claim && (claim.startsWith('done:') || claim.startsWith('skipped:'))) } @@ -61,12 +61,17 @@ async function claimOpening(db: Db, workspaceId: string, productId: string | nul if (await db.setPaymentAccountMetadataKey({ workspaceId, key: COMPANION_CLAIM_KEY, value, when: 'absent' })) return { previous: null, value } const account = await db.getActivePaymentAccount(workspaceId) if (!account) return false - const current = claimOf(account.plugin_metadata) + const current = companionClaimOf(account.plugin_metadata) if (current === null) return false const startedAt = current.startsWith('opening:') ? Number(current.slice('opening:'.length)) : Number.NaN const stale = Number.isFinite(startedAt) && Date.now() - startedAt > OPENING_STALE_MS const settledFor = current.startsWith('done:') ? current.slice('done:'.length) : current.startsWith('skipped:') ? current.slice('skipped:'.length) : null - const productChanged = settledFor !== null && Boolean(productId) && settledFor !== productId + // A claim the reconciler stamped does not know its product (`done:`): that is not a change. Learn it, once. + if (settledFor === '' && productId) { + await db.setPaymentAccountMetadataKey({ workspaceId, key: COMPANION_CLAIM_KEY, value: `${current.slice(0, current.indexOf(':'))}:${productId}`, when: { equals: current } }) + return false + } + const productChanged = settledFor !== null && settledFor !== '' && Boolean(productId) && settledFor !== productId if (current !== 'failed' && current !== '' && !stale && !productChanged) return false return (await db.setPaymentAccountMetadataKey({ workspaceId, key: COMPANION_CLAIM_KEY, value, when: { equals: current } })) ? { previous: current, value } : false } diff --git a/tests/contract/payment-accounts.contract.test.ts b/tests/contract/payment-accounts.contract.test.ts index d4d8d2f7..023bee41 100644 --- a/tests/contract/payment-accounts.contract.test.ts +++ b/tests/contract/payment-accounts.contract.test.ts @@ -101,6 +101,24 @@ describe('postgres-db payment-accounts (contract)', () => { expect(await methods.setPaymentAccountMetadataKey({ workspaceId: user.workspaceId, key: 'k', value: 'v', when: 'absent' })).toBe(false) }) + it('the companion claim survives the plan\'s created-style upsert that races an open in flight', async () => { + await methods.archiveActivePaymentAccount(user.workspaceId) + const base = { workspaceId: user.workspaceId, provider: 'polar', customerId: `cus_race_${randomUUID()}` } + await methods.upsertPaymentAccount({ ...base, subscriptionStatus: 'active', pluginMetadata: { billable_meters: ['a'] } }) + // `updated` holds the claim while its Polar create is in flight… + expect(await methods.setPaymentAccountMetadataKey({ workspaceId: user.workspaceId, key: 'companion_claim', value: 'opening:1', when: 'absent' })).toBe(true) + // …`created` writes the row from its own, earlier read… + const row = await methods.upsertPaymentAccount({ + ...base, + subscriptionStatus: 'active', + pluginMetadata: { overage_suspended: [], activation_email: 'sent' }, + preserveMetadataKeys: ['companion_subscription_id', 'companion_billable_meters', 'companion_claim'], + }) + expect(row.plugin_metadata).toEqual({ overage_suspended: [], activation_email: 'sent', companion_claim: 'opening:1' }) + // …so its own claim attempt loses. + expect(await methods.setPaymentAccountMetadataKey({ workspaceId: user.workspaceId, key: 'companion_claim', value: 'opening:2', when: 'absent' })).toBe(false) + }) + it('metadata json: sets and removes one structured key, leaves the rest; list returns only active accounts of the provider', async () => { await methods.archiveActivePaymentAccount(user.workspaceId) await methods.upsertPaymentAccount({ workspaceId: user.workspaceId, provider: 'polar', customerId: `cus_json_${randomUUID()}`, subscriptionStatus: 'active', pluginMetadata: { billable_meters: ['a'], companion_claim: 'done:p' } }) diff --git a/tests/integration/billing-webhook-companion.integration.test.ts b/tests/integration/billing-webhook-companion.integration.test.ts index 5f74ccf9..4e711df6 100644 --- a/tests/integration/billing-webhook-companion.integration.test.ts +++ b/tests/integration/billing-webhook-companion.integration.test.ts @@ -149,16 +149,29 @@ describe('billing webhook: companion usage subscription', () => { }) it('an ended companion is forgotten without ending the plan', async () => { - getActivePaymentAccount.mockResolvedValue(yearlyAccount({ companion_subscription_id: 'sub_c1', companion_billable_meters: 'ai_credits' })) + getActivePaymentAccount.mockResolvedValue(yearlyAccount({ companion_subscription_id: 'sub_c1', companion_billable_meters: 'ai_credits', companion_claim: 'done:prod_pro_y' })) handleWebhookMock.mockResolvedValue({ event: 'subscription.canceled', companion: true, workspaceId: 'ws-1', subscriptionId: 'sub_c1', customerId: 'cus_1', subscriptionStatus: 'canceled' }) await post() expect(setPaymentAccountMetadataKey).toHaveBeenCalledWith({ workspaceId: 'ws-1', key: 'companion_subscription_id', value: '', when: 'different' }) expect(setPaymentAccountMetadataKey).toHaveBeenCalledWith({ workspaceId: 'ws-1', key: 'companion_billable_meters', value: '', when: 'different' }) - expect(setPaymentAccountMetadataKey).toHaveBeenCalledWith({ workspaceId: 'ws-1', key: 'companion_claim', value: 'failed', when: 'different' }) + expect(setPaymentAccountMetadataKey).toHaveBeenCalledWith({ workspaceId: 'ws-1', key: 'companion_claim', value: 'failed', when: { equals: 'done:prod_pro_y' } }) expect(archiveActivePaymentAccount).not.toHaveBeenCalled() expect(updateWorkspace).not.toHaveBeenCalledWith('', 'ws-1', { plan: 'free', trial_reminder_stage: 0 }) }) + it('the plan\'s created upsert preserves the companion keys, claim included, so a racing open keeps its claim', async () => { + handleWebhookMock.mockResolvedValue(planCreated) + await post() + expect(upsertPaymentAccount.mock.calls[0]![0].preserveMetadataKeys).toEqual(expect.arrayContaining(['companion_subscription_id', 'companion_billable_meters', 'companion_claim'])) + }) + + it('an ending companion event on an account with no recorded companion (mid-open, mid-switch) changes nothing', async () => { + getActivePaymentAccount.mockResolvedValue(yearlyAccount({ companion_claim: `opening:${Date.now()}` })) + handleWebhookMock.mockResolvedValue({ event: 'subscription.canceled', companion: true, workspaceId: 'ws-1', subscriptionId: 'sub_c_old', customerId: 'cus_1', subscriptionStatus: 'canceled' }) + await post() + expect(setPaymentAccountMetadataKey).not.toHaveBeenCalled() + }) + it('a replayed event of a companion that already ended does not bring it back', async () => { getActivePaymentAccount.mockResolvedValue(yearlyAccount({})) handleWebhookMock.mockResolvedValue({ event: 'subscription.created', companion: true, workspaceId: 'ws-1', subscriptionId: 'sub_c1', customerId: 'cus_1', subscriptionStatus: 'canceled', billableMeters: ['ai_credits'] }) diff --git a/tests/unit/companion-subscription-util.test.ts b/tests/unit/companion-subscription-util.test.ts index 2b730daf..551391a0 100644 --- a/tests/unit/companion-subscription-util.test.ts +++ b/tests/unit/companion-subscription-util.test.ts @@ -96,6 +96,18 @@ describe('openCompanionSubscription', () => { expect(db.row.plugin_metadata.companion_claim).toBe('done:prod_pro_y') }) + it('a claim the reconciler stamped without a product is learned, not mistaken for a product change', async () => { + const db = fakeDb({ companion_claim: 'done:', companion_subscription_id: 'sub_c1' }) + const p = provider() + expect(await openCompanionSubscription(p, db, args)).toBe('busy') + expect((p as { cancelSubscription: ReturnType }).cancelSubscription).not.toHaveBeenCalled() + expect(ensureOf(p)).not.toHaveBeenCalled() + expect(db.row.plugin_metadata.companion_claim).toBe('done:prod_pro_y') + expect(db.row.plugin_metadata.companion_subscription_id).toBe('sub_c1') + // Stamped now, so a real change is still a change. + expect(await openCompanionSubscription(p, db, { ...args, productId: 'prod_starter_y' })).toBe('opened') + }) + it('a product switch whose old companion will not cancel throws and leaves the claim as it was, so the webhook retries', async () => { const log = vi.spyOn(console, 'error').mockImplementation(() => {}) const db = fakeDb({ companion_claim: 'done:prod_starter_y', companion_subscription_id: 'sub_old' }) From ceebcbba9c66ea1f0f4f47b166a11d59129554cd Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:35:32 +0300 Subject: [PATCH 4/4] fix(billing): stamp the claim with the product the companion was opened for The provider returns the plan product it read from the plan subscription, so a reconciler-opened companion is stamped with its real product and a switch as the first later plan event is still replaced. --- server/providers/payment/plugins/polar.ts | 4 ++-- server/providers/payment/types.ts | 2 ++ server/utils/companion-subscription.ts | 3 ++- tests/unit/companion-subscription-util.test.ts | 11 +++++++++++ tests/unit/companion-subscription.test.ts | 4 ++-- 5 files changed, 19 insertions(+), 5 deletions(-) diff --git a/server/providers/payment/plugins/polar.ts b/server/providers/payment/plugins/polar.ts index c5aa6092..905c27eb 100644 --- a/server/providers/payment/plugins/polar.ts +++ b/server/providers/payment/plugins/polar.ts @@ -402,7 +402,7 @@ function createPolarProvider(config: PaymentPluginConfig): PaymentProvider { const existing = await polar.subscriptions.list({ customerId: input.customerId, productId, active: true }) for await (const page of existing) { const first = page.result.items[0] - if (first) return { subscriptionId: first.id, created: false } + if (first) return { subscriptionId: first.id, created: false, parentProductId } } const created = await polar.subscriptions.create({ @@ -415,7 +415,7 @@ function createPolarProvider(config: PaymentPluginConfig): PaymentProvider { parent_subscription_id: input.parentSubscriptionId, }, }) - return { subscriptionId: created.id, created: true } + return { subscriptionId: created.id, created: true, parentProductId } }, async cancelSubscription(subscriptionId: string): Promise<'canceled' | 'already_ended'> { diff --git a/server/providers/payment/types.ts b/server/providers/payment/types.ts index 1c242871..1a764655 100644 --- a/server/providers/payment/types.ts +++ b/server/providers/payment/types.ts @@ -169,6 +169,8 @@ export interface CompanionSubscriptionResult { subscriptionId: string /** False when the customer already had an active companion (a repeat). */ created: boolean + /** The plan product the companion was opened for (the provider read it from the plan subscription). */ + parentProductId?: string } export interface UsageEventInput { diff --git a/server/utils/companion-subscription.ts b/server/utils/companion-subscription.ts index 791d8e63..2984d98b 100644 --- a/server/utils/companion-subscription.ts +++ b/server/utils/companion-subscription.ts @@ -120,7 +120,8 @@ export async function openCompanionSubscription( return 'skipped' } await db.setPaymentAccountMetadataKey({ workspaceId, key: COMPANION_SUBSCRIPTION_KEY, value: companion.subscriptionId, when: 'different' }) - await setClaim(`done:${input.productId ?? ''}`, held) + // The product the provider opened it for, not the event's: the two differ when a plan switch is the first event seen. + await setClaim(`done:${companion.parentProductId ?? input.productId ?? ''}`, held) return companion.created ? 'opened' : 'existing' } catch (err) { diff --git a/tests/unit/companion-subscription-util.test.ts b/tests/unit/companion-subscription-util.test.ts index 551391a0..8a464a33 100644 --- a/tests/unit/companion-subscription-util.test.ts +++ b/tests/unit/companion-subscription-util.test.ts @@ -108,6 +108,17 @@ describe('openCompanionSubscription', () => { expect(await openCompanionSubscription(p, db, { ...args, productId: 'prod_starter_y' })).toBe('opened') }) + it('stamps the product the provider opened it for, so a switch as the first plan event is still a switch', async () => { + const db = fakeDb() + const p = provider({ ensureCompanionSubscription: vi.fn().mockResolvedValue({ subscriptionId: 'sub_c1', created: true, parentProductId: 'prod_starter_y' }) }) + // The reconciler does not know the product; the provider does. + expect(await openCompanionSubscription(p, db, { ...args, productId: null })).toBe('opened') + expect(db.row.plugin_metadata.companion_claim).toBe('done:prod_starter_y') + // The first plan event afterwards moves the plan to pro: the starter companion is replaced. + expect(await openCompanionSubscription(p, db, args)).toBe('opened') + expect((p as { cancelSubscription: ReturnType }).cancelSubscription).toHaveBeenCalledWith('sub_c1') + }) + it('a product switch whose old companion will not cancel throws and leaves the claim as it was, so the webhook retries', async () => { const log = vi.spyOn(console, 'error').mockImplementation(() => {}) const db = fakeDb({ companion_claim: 'done:prod_starter_y', companion_subscription_id: 'sub_old' }) diff --git a/tests/unit/companion-subscription.test.ts b/tests/unit/companion-subscription.test.ts index 919597fa..7ec22bbf 100644 --- a/tests/unit/companion-subscription.test.ts +++ b/tests/unit/companion-subscription.test.ts @@ -49,7 +49,7 @@ describe('polar companion subscription', () => { }) it('opens the plan\'s companion product for the customer, tagged and tied to the plan subscription', async () => { - expect(await (await polar()).ensureCompanionSubscription!(input)).toEqual({ subscriptionId: 'sub_c1', created: true }) + expect(await (await polar()).ensureCompanionSubscription!(input)).toMatchObject({ subscriptionId: 'sub_c1', created: true, parentProductId: expect.any(String) }) expect(subscriptionsCreate).toHaveBeenCalledWith({ productId: 'prod_pro_c', customerId: 'cus_1', @@ -95,7 +95,7 @@ describe('polar companion subscription', () => { it('returns the active companion the customer already has instead of opening a second one', async () => { subscriptionsList.mockResolvedValue(pageOf('sub_existing')) - expect(await (await polar()).ensureCompanionSubscription!(input)).toEqual({ subscriptionId: 'sub_existing', created: false }) + expect(await (await polar()).ensureCompanionSubscription!(input)).toMatchObject({ subscriptionId: 'sub_existing', created: false }) expect(subscriptionsList).toHaveBeenCalledWith({ customerId: 'cus_1', productId: 'prod_pro_c', active: true }) expect(subscriptionsCreate).not.toHaveBeenCalled() })