mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 09:09:36 +02:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user