From 2742319229265310003faa2e548eb1a2fc577dae Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:45:19 +0300 Subject: [PATCH] feat(billing): polar-sync creates the Migrate-with-Studio bundle and yearly products A third sync step creates, per plan, the bundle product (year 1 at 20% off) and the yearly list-price product the Polar plugin already reads through the NUXT_POLAR_{STARTER,PRO}_{BUNDLE,YEARLY}_PRODUCT_ID env names. Prices come from shared/utils/migrate-bundle.ts. Products are matched by a new contentrain_variant tag (no tag means monthly, so the existing products keep matching). Metered prices and meter credits are not attached yet: a yearly period grants credits per billing cycle. --- docs/PAYMENT_PROVIDERS.md | 12 ++++++ scripts/polar-sync.ts | 78 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/docs/PAYMENT_PROVIDERS.md b/docs/PAYMENT_PROVIDERS.md index 1fa79a48..de867d06 100644 --- a/docs/PAYMENT_PROVIDERS.md +++ b/docs/PAYMENT_PROVIDERS.md @@ -86,6 +86,18 @@ First run prints a copy-paste block of product IDs for your env: NUXT_POLAR_PRO_PRODUCT_ID=… ``` +The same run also creates the four yearly products "Migrate with Studio" sells through (fixed price only, tagged +`contentrain_variant`; monthly products are untouched) and prints their ids: + +``` + NUXT_POLAR_STARTER_BUNDLE_PRODUCT_ID=… # year 1 at 20% off ($72), the checkout overrides the amount per order + NUXT_POLAR_STARTER_YEARLY_PRODUCT_ID=… # the list price ($90) the subscription moves to at the next period + NUXT_POLAR_PRO_BUNDLE_PRODUCT_ID=… # $392 + NUXT_POLAR_PRO_YEARLY_PRODUCT_ID=… # $490 +``` + +Prices come from `shared/utils/migrate-bundle.ts`. Metered prices and meter credits are not attached to these products yet. + **3. Webhook endpoint.** In the Polar dashboard: - URL: `https:///api/billing/webhook/polar` diff --git a/scripts/polar-sync.ts b/scripts/polar-sync.ts index c8d7266a..36fa72d4 100644 --- a/scripts/polar-sync.ts +++ b/scripts/polar-sync.ts @@ -58,6 +58,7 @@ import plansData from '../.contentrain/content/system/plans/en.json' import planFeaturesData from '../.contentrain/content/system/plan-features/data.json' import { USAGE_METER_LIST } from '../shared/utils/usage-meters' import { OVERAGE_PRICING, PLAN_PRICING } from '../shared/utils/license' +import { bundleYear1Cents, STUDIO_YEARLY_LIST_CENTS } from '../shared/utils/migrate-bundle' // ─── Config ────────────────────────────────────────────────────────────── @@ -293,7 +294,70 @@ function findExistingProduct( products: ProductSummary[], slug: BillablePlan, ): ProductSummary | undefined { - return products.find(p => p.metadata?.contentrain_slug === slug && p.metadata?.contentrain_catalog === CATALOG_VERSION) + // A product with no variant tag is the monthly one (how the first two were created), so they keep matching. + return products.find(p => p.metadata?.contentrain_slug === slug && p.metadata?.contentrain_catalog === CATALOG_VERSION + && (p.metadata?.contentrain_variant ?? 'monthly') === 'monthly') +} + +/** + * The yearly products "Migrate with Studio" sells through (`createBundleCheckout` / `moveBundleSubscriptionToList` in + * the Polar plugin): per plan, a `bundle` product the checkout opens on (year 1 at 20% off, the checkout overrides the + * amount per order) and a `yearly` product the subscription moves to at the next period (the list price). + * Prices come from `shared/utils/migrate-bundle.ts`, the numbers the quote and the checkout use. + * Create-if-missing only. Metered prices and included meter credits are NOT attached here: on a yearly period the + * 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] + +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 + && p.metadata?.contentrain_variant === variant) +} + +async function syncVariantProduct( + slug: BillablePlan, + variant: VariantSlug, + existingProducts: ProductSummary[], +): Promise { + const key = `${slug}#${variant}` + const cents = variant === 'yearly' ? STUDIO_YEARLY_LIST_CENTS[slug] : bundleYear1Cents(slug) + const name = variant === 'yearly' ? `Studio ${PLAN_PRICING[slug].name} Yearly` : `Migrate with Studio ${PLAN_PRICING[slug].name}` + const existing = findVariantProduct(existingProducts, slug, variant) + if (existing) { + summary.products[key] = existing.id + const fixed = existing.prices.filter(p => !isPriceArchived(p)).find(p => getPriceType(p) === 'fixed') + const current = fixed ? getFixedPriceAmount(fixed) : undefined + if (current !== cents) { + summary.warnings.push(`Fixed price drift on "${name}": Polar has ${current ?? 'none'} cents, the bundle constants want ${cents} cents. Archive the price in the Polar dashboard and create a new one.`) + } + else { + console.log(` ✓ product "${name}" in sync (${existing.id})`) + } + return + } + if (!APPLY) { + console.log(` + product "${name}" would be created — $${(cents / 100).toFixed(2)}/year`) + return + } + try { + const created = await polar.products.create({ + recurringInterval: 'year', + name, + description: variant === 'yearly' + ? `${PLAN_PRICING[slug].name} billed yearly at the list price.` + : `Migrate order with the first year of Studio ${PLAN_PRICING[slug].name}; renews on Studio ${PLAN_PRICING[slug].name} Yearly.`, + metadata: { contentrain_slug: slug, contentrain_catalog: CATALOG_VERSION, contentrain_variant: variant }, + prices: [{ amountType: 'fixed', priceAmount: cents }], + }) + summary.products[key] = created.id + console.log(` + product "${name}" created (${created.id}) — $${(cents / 100).toFixed(2)}/year`) + } + 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}`) + } } interface ProductPriceRow { @@ -737,16 +801,22 @@ 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/2 — syncing meters') + console.log('\n[polar-sync] step 1/3 — syncing meters') const existingMeters = await listAllMeters() const meterIdByName = await syncMeters(existingMeters) - console.log('\n[polar-sync] step 2/2 — syncing products, prices + included units') + console.log('\n[polar-sync] step 2/3 — 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)') + 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('\n[polar-sync] summary') if (summary.warnings.length > 0) { console.log(' warnings:') @@ -756,7 +826,7 @@ async function main() { const meterLines = Object.entries(summary.meters).map(([name, id]) => ` - ${name}: ${id}`).join('\n') if (meterLines) console.log(` created meters:\n${meterLines}`) - const productLines = Object.entries(summary.products).map(([slug, id]) => ` NUXT_POLAR_${slug.toUpperCase()}_PRODUCT_ID=${id}`).join('\n') + const productLines = Object.entries(summary.products).map(([slug, id]) => ` NUXT_POLAR_${slug.replace('#', '_').toUpperCase()}_PRODUCT_ID=${id}`).join('\n') if (productLines) { console.log(`\n paste these into .env.local (or your deployment env):\n${productLines}`) }