Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 68 additions & 9 deletions scripts/polar-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -494,6 +494,7 @@ async function syncMeterCredits(
productId: string,
meterIdByName: Map<string, string>,
existingBenefits: Array<Record<string, unknown>>,
variant: 'monthly' | 'companion' = 'monthly',
): Promise<void> {
const planned: CreditBenefitPlan[] = []

Expand Down Expand Up @@ -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<string, unknown> | undefined)?.contentrain_key === key,
)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<string, string>,
existingProducts: ProductSummary[],
): Promise<void> {
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) {
Expand Down
114 changes: 111 additions & 3 deletions server/api/billing/webhook/[provider].post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof useDatabaseProvider>

Expand All @@ -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<string, unknown> | null | undefined): string {
Expand Down Expand Up @@ -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<void> {
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') ?? ''

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<string, unknown>
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',
Expand Down
Loading
Loading