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..3bc17350 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_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, companionClaimOf, companionSubscriptionIdOf, openCompanionSubscription } from '../../../utils/companion-subscription' +import type { WebhookResult } from '../../../providers/payment/types' type Db = ReturnType @@ -39,8 +41,13 @@ 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. + */ +/** 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. */ function pastDueEpisode(row: Record | null | undefined): string { @@ -243,6 +250,72 @@ 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 + // 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. + 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. + 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' }) + } + 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) { + // 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() +} + export default defineEventHandler(async (event) => { const providerKey = getRouterParam(event, 'provider') ?? '' @@ -275,6 +348,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. @@ -318,9 +397,19 @@ 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() + // 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 +522,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. 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. @@ -486,6 +585,15 @@ 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. + // 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 3c00b967..6def36f2 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') @@ -81,19 +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 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/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..ef46057d 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). * @@ -1096,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 38be0b0e..905c27eb 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,33 @@ 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 + 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, parentProductId } + } + + 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, parentProductId } + }, + 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..1a764655 100644 --- a/server/providers/payment/types.ts +++ b/server/providers/payment/types.ts @@ -136,6 +136,41 @@ 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 + /** The plan product the companion was opened for (the provider read it from the plan subscription). */ + parentProductId?: string } export interface UsageEventInput { @@ -195,6 +230,18 @@ 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 + + /** 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 0173b832..bad56c7c 100644 --- a/server/providers/postgres-db/payment-accounts.ts +++ b/server/providers/postgres-db/payment-accounts.ts @@ -15,8 +15,10 @@ import { getAdmin, throwDbError } from './helpers' type PaymentAccountMethods = Pick< DatabaseProvider, | 'getActivePaymentAccount' + | 'listActivePaymentAccounts' | 'upsertPaymentAccount' | 'setPaymentAccountMetadataKey' + | 'setPaymentAccountMetadataJson' | 'setPaymentAccountCreditUnit' | 'archiveActivePaymentAccount' | 'enqueueUsageEvent' @@ -57,6 +59,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 @@ -170,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 3526e7ac..ba98914b 100644 --- a/server/providers/supabase-db/payment-accounts.ts +++ b/server/providers/supabase-db/payment-accounts.ts @@ -12,8 +12,10 @@ import { getAdmin } from './helpers' type PaymentAccountMethods = Pick< DatabaseProvider, | 'getActivePaymentAccount' + | 'listActivePaymentAccounts' | 'upsertPaymentAccount' | 'setPaymentAccountMetadataKey' + | 'setPaymentAccountMetadataJson' | 'setPaymentAccountCreditUnit' | 'archiveActivePaymentAccount' | 'enqueueUsageEvent' @@ -38,6 +40,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 @@ -150,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 new file mode 100644 index 00000000..2984d98b --- /dev/null +++ b/server/utils/companion-subscription.ts @@ -0,0 +1,217 @@ +/** + * 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_CLAIM_KEY, COMPANION_METERS_KEY, COMPANION_SUBSCRIPTION_KEY, isYearlyPeriod } from './overage-lock' + +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 { + if (!pluginMetadata || typeof pluginMetadata !== 'object') return null + const id = (pluginMetadata as Record)[COMPANION_SUBSCRIPTION_KEY] + return typeof id === 'string' && id.length > 0 ? id : 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 +} + +/** Whether a stored claim leaves nothing for the reconciler to do. */ +export function claimSettled(pluginMetadata: unknown): boolean { + const claim = companionClaimOf(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 = 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 + // 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 +} + +/** + * 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 || 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, + plan: input.plan, + customerId: input.customerId, + parentSubscriptionId: input.subscriptionId, + ...(input.productId ? { parentProductId: input.productId } : {}), + }) + if (!companion) { + await setClaim(`skipped:${input.productId ?? ''}`, held) + return 'skipped' + } + await db.setPaymentAccountMetadataKey({ workspaceId, key: COMPANION_SUBSCRIPTION_KEY, value: companion.subscriptionId, when: 'different' }) + // 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) { + // 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 ${workspaceId} (plan subscription ${input.subscriptionId}):`, err) + await setClaim('failed', held).catch(() => {}) + return 'failed' + } +} + +/** + * 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) + && !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, + db: Db & Pick, + providerKey: string, +): Promise { + const summary: CompanionReconcileSummary = { checked: 0, opened: 0, failed: 0 } + // 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 outcome = await openCompanionSubscription(provider, db, { + workspaceId, + plan: row.plan === 'pro' ? 'pro' : 'starter', + customerId: String(row.customer_id), + subscriptionId: String(row.subscription_id), + productId: null, + }) + if (outcome === 'opened') summary.opened++ + if (outcome === 'failed') summary.failed++ + } + 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..4918f4db 100644 --- a/server/utils/overage-lock.ts +++ b/server/utils/overage-lock.ts @@ -52,6 +52,16 @@ 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' +/** 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]), @@ -80,11 +90,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 +126,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/contract/payment-accounts.contract.test.ts b/tests/contract/payment-accounts.contract.test.ts index cab45725..023bee41 100644 --- a/tests/contract/payment-accounts.contract.test.ts +++ b/tests/contract/payment-accounts.contract.test.ts @@ -101,6 +101,43 @@ 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' } }) + 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 new file mode 100644 index 00000000..4e711df6 --- /dev/null +++ b/tests/integration/billing-webhook-companion.integration.test.ts @@ -0,0 +1,232 @@ +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) + // `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() + 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, setPaymentAccountMetadataJson, 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() + setPaymentAccountMetadataJson.mockClear() + claimHeld = false + }) + + 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, companionUsageEnabled: () => true, + }), + }) + 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(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) + 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 } }) + // 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 () => { + 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', 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: { 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'] }) + 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' }) + 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 () => { + 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) + }) + + 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 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 expect(post()).rejects.toThrow('polar down') + expect(log).toHaveBeenCalledWith(expect.stringContaining('[companion] ALARM could not cancel'), expect.any(Error)) + 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 f8d139d2..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'], + 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 new file mode 100644 index 00000000..8a464a33 --- /dev/null +++ b/tests/unit/companion-subscription-util.test.ts @@ -0,0 +1,219 @@ +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 }), + companionUsageEnabled: () => 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() + }) +}) + +/** 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('claims the workspace, records the companion and settles the claim for this product', async () => { + const db = fakeDb() + const p = provider() + 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.row.plugin_metadata.companion_subscription_id).toBe('sub_c1') + expect(db.row.plugin_metadata.companion_claim).toBe('done:prod_pro_y') + }) + + 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 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('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' }) + 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, 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('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 = 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() + }) +}) + +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 }), + 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 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.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 = { ...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 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() + }) +}) diff --git a/tests/unit/companion-subscription.test.ts b/tests/unit/companion-subscription.test.ts new file mode 100644 index 00000000..7ec22bbf --- /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)).toMatchObject({ subscriptionId: 'sub_c1', created: true, parentProductId: expect.any(String) }) + 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)).toMatchObject({ 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 }) + }) +})