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
This commit is contained in:
Simon Gardner
2026-05-08 15:03:10 +01:00
committed by Copybot
parent 45005d2783
commit 8869dd5f32
3 changed files with 410 additions and 2 deletions

View File

@@ -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)
}
}

View File

@@ -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,
}

View File

@@ -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,
}