Sfoglia il codice sorgente

Consistently import Stripe prices with interval: 12 monthly rather than yearly. (#33536)

* ensure imported annual prices are 12-monthly
* script to convert annual prices to 12-monthly

GitOrigin-RevId: b7af88156bde510ecdf080fc97384463fa77db13
Simon Gardner 5 mesi fa
parent
commit
8869dd5f32

+ 378 - 0
services/web/scripts/stripe/convert_yearly_prices_to_12months.mjs

@@ -0,0 +1,378 @@
+#!/usr/bin/env node
+
+// @ts-check
+
+/**
+ * Convert yearly Stripe prices for a product to 12-month prices.
+ *
+ * For each recurring yearly price on the target product:
+ * 1) Create a replacement recurring price with interval=month and interval_count=12
+ * 2) Archive (set active=false) the original yearly price
+ *
+ * Note: Stripe Prices cannot be hard-deleted. Archiving is the supported replacement.
+ *
+ * Usage:
+ * node scripts/stripe/convert_yearly_prices_to_12months.mjs --region <us|uk> --productId <prod_...> [--commit]
+ *
+ * Options:
+ * --region       Required. Stripe region (us or uk)
+ * --productId    Required. Stripe product id
+ * --commit       Apply changes. Default is dry-run.
+ */
+
+import minimist from 'minimist'
+import { z } from '@overleaf/validation-tools'
+import { scriptRunner } from '../lib/ScriptRunner.mjs'
+import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
+import { rateLimitSleep } from './helpers.mjs'
+
+/**
+ * @typedef {import('stripe').Stripe} Stripe
+ * @typedef {import('stripe').Stripe.Price} Price
+ * @typedef {import('stripe').Stripe.PriceCreateParams} PriceCreateParams
+ */
+
+const paramsSchema = z.object({
+  region: z.enum(['us', 'uk']),
+  productId: z.string(),
+  commit: z.boolean().default(false),
+})
+
+const ARCHIVED_PREFIX = '[ARCHIVED]'
+
+/**
+ * @param {Stripe} stripe
+ * @param {string} priceId
+ * @returns {Promise<boolean>}
+ */
+async function getHasActiveSubscriptions(stripe, priceId) {
+  const activeStatuses = [
+    'active',
+    'trialing',
+    'past_due',
+    'unpaid',
+    'paused',
+    'incomplete',
+  ]
+
+  let hasMore = true
+  let startingAfter
+
+  while (hasMore) {
+    /** @type {{price: string, limit: number, starting_after?: string}} */
+    const params = {
+      price: priceId,
+      limit: 100,
+    }
+
+    if (startingAfter) {
+      params.starting_after = startingAfter
+    }
+
+    const subscriptions = await stripe.subscriptions.list(params)
+    await rateLimitSleep()
+
+    if (
+      subscriptions.data.some(subscription =>
+        activeStatuses.includes(subscription.status)
+      )
+    ) {
+      return true
+    }
+
+    hasMore = subscriptions.has_more
+    if (hasMore && subscriptions.data.length > 0) {
+      startingAfter = subscriptions.data[subscriptions.data.length - 1].id
+    }
+  }
+
+  return false
+}
+
+/**
+ * @param {Stripe} stripe
+ * @param {string} productId
+ * @returns {Promise<Price[]>}
+ */
+async function getAllProductPrices(stripe, productId) {
+  /** @type {Record<string, Price>} */
+  const pricesById = {}
+
+  for (const active of [true, false]) {
+    let startingAfter
+
+    do {
+      /** @type {any} */
+      const response = await stripe.prices.list({
+        product: productId,
+        active,
+        limit: 100,
+        starting_after: startingAfter,
+      })
+
+      for (const price of response.data) {
+        pricesById[price.id] = price
+      }
+
+      startingAfter = response.has_more
+        ? response.data[response.data.length - 1].id
+        : undefined
+    } while (startingAfter)
+  }
+
+  return Object.values(pricesById)
+}
+
+/**
+ * Check if there is an active 12-month price that is equivalent to the yearly price (same currency, unit_amount, nickname, and lookup_key).
+ * This is mostly a redundant safety check since for this one-off script there won't be any duplicates.
+ * @param {Price} yearlyPrice
+ * @param {Price[]} productPrices
+ * @returns {boolean}
+ */
+function hasEquivalent12MonthPrice(yearlyPrice, productPrices) {
+  return productPrices.some(candidate => {
+    return (
+      candidate.active &&
+      candidate.id !== yearlyPrice.id &&
+      candidate.recurring?.interval === 'month' &&
+      candidate.recurring?.interval_count === 12 &&
+      candidate.currency === yearlyPrice.currency &&
+      candidate.unit_amount === yearlyPrice.unit_amount &&
+      candidate.nickname === yearlyPrice.nickname &&
+      candidate.lookup_key === yearlyPrice.lookup_key
+    )
+  })
+}
+
+/**
+ * @param {Price} yearlyPrice
+ * @returns {PriceCreateParams}
+ */
+function build12MonthPriceParams(yearlyPrice) {
+  if (typeof yearlyPrice.product !== 'string') {
+    throw new Error(
+      `Price ${yearlyPrice.id} has an expanded product. Please rerun without expanded product objects.`
+    )
+  }
+
+  if (typeof yearlyPrice.unit_amount !== 'number') {
+    throw new Error(
+      `Price ${yearlyPrice.id} does not have unit_amount. Only per-unit prices are supported by this script.`
+    )
+  }
+
+  /** @type {PriceCreateParams} */
+  const params = {
+    product: yearlyPrice.product,
+    currency: yearlyPrice.currency,
+    unit_amount: yearlyPrice.unit_amount,
+    billing_scheme: yearlyPrice.billing_scheme,
+    recurring: {
+      interval: 'month',
+      interval_count: 12,
+    },
+    active: yearlyPrice.active,
+    metadata: yearlyPrice.metadata,
+    nickname: yearlyPrice.nickname || undefined,
+    tax_behavior: yearlyPrice.tax_behavior || undefined,
+  }
+
+  if (yearlyPrice.lookup_key) {
+    params.lookup_key = yearlyPrice.lookup_key
+    params.transfer_lookup_key = true
+  }
+
+  return params
+}
+
+/**
+ * @param {Price[]} activeYearlyPrices
+ * @param {Price[]} allProductPrices
+ * @param {Stripe} stripe
+ * @param {boolean} commit
+ * @param {(msg: string) => Promise<void>} trackProgress
+ */
+async function convertPrices(
+  activeYearlyPrices,
+  allProductPrices,
+  stripe,
+  commit,
+  trackProgress
+) {
+  const summary = {
+    yearlyFound: activeYearlyPrices.length,
+    created: 0,
+    archived: 0,
+    skippedHasActiveSubscriptions: 0,
+    skippedAlreadyConverted: 0,
+    errors: 0,
+  }
+
+  for (const yearlyPrice of activeYearlyPrices) {
+    try {
+      await trackProgress(
+        `Processing yearly price ${yearlyPrice.id} (${yearlyPrice.currency.toUpperCase()} ${yearlyPrice.unit_amount})`
+      )
+
+      const hasActiveSubscriptions = await getHasActiveSubscriptions(
+        stripe,
+        yearlyPrice.id
+      )
+      if (hasActiveSubscriptions) {
+        await trackProgress(
+          `  WARNING: Price ${yearlyPrice.id} has active subscriptions. Skipping conversion for this price.`
+        )
+        summary.skippedHasActiveSubscriptions++
+        continue
+      }
+
+      const alreadyHasEquivalent = hasEquivalent12MonthPrice(
+        yearlyPrice,
+        allProductPrices
+      )
+
+      if (!alreadyHasEquivalent) {
+        const params = build12MonthPriceParams(yearlyPrice)
+
+        if (commit) {
+          const newPrice = await stripe.prices.create(params)
+          await rateLimitSleep()
+          allProductPrices.push(newPrice)
+          await trackProgress(
+            `  Created 12-month price ${newPrice.id}${newPrice.lookup_key ? ` (lookup_key: ${newPrice.lookup_key})` : ''}`
+          )
+        } else {
+          await trackProgress(
+            `  [DRY RUN] Would create 12-month replacement price${yearlyPrice.lookup_key ? ` with lookup_key transfer (${yearlyPrice.lookup_key})` : ''}`
+          )
+        }
+
+        summary.created++
+      } else {
+        await trackProgress(
+          '  Found an equivalent 12-month price already. Skipping create step.'
+        )
+        summary.skippedAlreadyConverted++
+      }
+
+      if (commit) {
+        const archivedNickname = yearlyPrice.nickname?.includes('[ARCHIVED]')
+          ? yearlyPrice.nickname
+          : yearlyPrice.nickname
+            ? `${ARCHIVED_PREFIX} ${yearlyPrice.nickname}`
+            : ARCHIVED_PREFIX
+
+        await stripe.prices.update(yearlyPrice.id, {
+          active: false,
+          nickname: archivedNickname,
+        })
+        await rateLimitSleep()
+        await trackProgress(`  Archived yearly price ${yearlyPrice.id}`)
+      } else {
+        await trackProgress(
+          `  [DRY RUN] Would archive yearly price ${yearlyPrice.id} and prepend [ARCHIVED] to nickname`
+        )
+      }
+      summary.archived++
+    } catch (error) {
+      const message = error instanceof Error ? error.message : String(error)
+      await trackProgress(
+        `  ERROR processing price ${yearlyPrice.id}: ${message}`
+      )
+      summary.errors++
+    }
+  }
+
+  return summary
+}
+
+/**
+ * @param {(msg: string) => Promise<void>} trackProgress
+ */
+export async function main(trackProgress) {
+  const rawArgs = minimist(process.argv.slice(2), {
+    boolean: ['commit'],
+    string: ['region', 'productId', 'product-id', 'p'],
+    alias: { p: 'productId' },
+  })
+
+  const parseResult = paramsSchema.safeParse({
+    region: rawArgs.region,
+    productId: rawArgs.productId || rawArgs['product-id'],
+    commit: rawArgs.commit,
+  })
+
+  if (!parseResult.success) {
+    throw new Error(`Invalid parameters: ${parseResult.error.message}`)
+  }
+
+  const { region, productId, commit } = parseResult.data
+  const mode = commit ? 'COMMIT MODE' : 'DRY RUN MODE'
+
+  await trackProgress(`Starting conversion in ${mode} for region: ${region}`)
+  await trackProgress(`Target product: ${productId}`)
+  await trackProgress(
+    'Note: Stripe prices cannot be deleted. This script archives yearly prices after replacement.'
+  )
+
+  const stripe = getRegionClient(region).stripe
+
+  const allProductPrices = await getAllProductPrices(stripe, productId)
+  const activeYearlyPrices = allProductPrices.filter(
+    price => price.active && price.recurring?.interval === 'year'
+  )
+
+  if (activeYearlyPrices.length === 0) {
+    await trackProgress(
+      'No active yearly recurring prices found for this product. Exiting.'
+    )
+    return
+  }
+
+  await trackProgress(
+    `Found ${activeYearlyPrices.length} active yearly recurring price(s) to process.`
+  )
+
+  const summary = await convertPrices(
+    activeYearlyPrices,
+    allProductPrices,
+    stripe,
+    commit,
+    trackProgress
+  )
+
+  await trackProgress('CONVERSION SUMMARY')
+  await trackProgress(`Yearly prices found: ${summary.yearlyFound}`)
+  await trackProgress(
+    `12-month prices ${commit ? 'created' : 'to create'}: ${summary.created}`
+  )
+  await trackProgress(
+    `Yearly prices ${commit ? 'archived' : 'to archive'}: ${summary.archived}`
+  )
+  await trackProgress(
+    `Skipped (has active subscriptions): ${summary.skippedHasActiveSubscriptions}`
+  )
+  await trackProgress(
+    `Create skipped (already converted): ${summary.skippedAlreadyConverted}`
+  )
+  await trackProgress(`Errors: ${summary.errors}`)
+
+  if (!commit) {
+    await trackProgress(
+      'This was a dry run. Use --commit to perform create+archive operations.'
+    )
+  }
+
+  await trackProgress(`Script completed in ${mode}`)
+}
+
+if (import.meta.main) {
+  try {
+    await scriptRunner(main)
+    process.exit(0)
+  } catch (error) {
+    console.error(error)
+    process.exit(1)
+  }
+}

+ 16 - 1
services/web/scripts/stripe/create_custom_prices_from_csv.mjs

@@ -53,6 +53,21 @@ const paramsSchema = z.object({
   commit: z.boolean().default(false),
 })
 
+/**
+ * Normalize annual cadence to month+12 so all annual prices share the same
+ * recurring shape for downstream import/migration tooling.
+ *
+ * @param {'month' | 'year'} interval
+ * @returns {{ interval: 'month', interval_count?: 1 | 12 }}
+ */
+function getRecurringFromInterval(interval) {
+  if (interval === 'year') {
+    return { interval: 'month', interval_count: 12 }
+  }
+
+  return { interval: 'month', interval_count: 1 }
+}
+
 /**
  * @param {import('stripe').Stripe} stripe
  * @returns {Promise<Record<string, Price>>}
@@ -262,7 +277,7 @@ export async function main(trackProgress) {
         product: productIdForPrice,
         currency: currencyLower,
         unit_amount: unitAmount,
-        recurring: { interval },
+        recurring: getRecurringFromInterval(interval),
         lookup_key: lookupKey,
         nickname: priceDescription || undefined,
       }

+ 16 - 1
services/web/scripts/stripe/create_prices_from_csv.mjs

@@ -52,6 +52,21 @@ const paramsSchema = z.object({
   commit: z.boolean().default(false),
 })
 
+/**
+ * Normalize annual cadence to month+12 so all annual prices share the same
+ * recurring shape for downstream import/migration tooling.
+ *
+ * @param {'month' | 'year'} interval
+ * @returns {{ interval: 'month', interval_count: 1 | 12 }}
+ */
+function getRecurringFromInterval(interval) {
+  if (interval === 'year') {
+    return { interval: 'month', interval_count: 12 }
+  }
+
+  return { interval: 'month', interval_count: 1 }
+}
+
 /**
  * @param {import('stripe').Stripe} stripe
  * @returns {Promise<Record<string, Price>>}
@@ -229,7 +244,7 @@ export async function main(trackProgress) {
         product: planCode,
         currency: currencyLower,
         unit_amount: convertToMinorUnits(amountValue, currencyLower),
-        recurring: { interval },
+        recurring: getRecurringFromInterval(interval),
         lookup_key: lookupKey,
       }