diff --git a/services/web/scripts/stripe/convert_yearly_prices_to_12months.mjs b/services/web/scripts/stripe/convert_yearly_prices_to_12months.mjs new file mode 100644 index 0000000000..52b5eb68c5 --- /dev/null +++ b/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 --productId [--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} + */ +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} + */ +async function getAllProductPrices(stripe, productId) { + /** @type {Record} */ + 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} 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} 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) + } +} diff --git a/services/web/scripts/stripe/create_custom_prices_from_csv.mjs b/services/web/scripts/stripe/create_custom_prices_from_csv.mjs index 2b7eb9d120..2c8cc3ed13 100644 --- a/services/web/scripts/stripe/create_custom_prices_from_csv.mjs +++ b/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>} @@ -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, } diff --git a/services/web/scripts/stripe/create_prices_from_csv.mjs b/services/web/scripts/stripe/create_prices_from_csv.mjs index 9f37c92b29..4bf2127141 100644 --- a/services/web/scripts/stripe/create_prices_from_csv.mjs +++ b/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>} @@ -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, }