Files
Verso/services/web/scripts/stripe/archive_prices_by_version_key.mjs
T
KristinaandCopybot 0cabc81d46 Merge pull request #31218 from overleaf/kh-update-assistant-prices
* update Stripe AI assist prices
* add soft archive option to the archiving prices script

GitOrigin-RevId: 3f0b66cf227e31e03fb3337b3cb4c1b6a82bd1db
2026-02-04 09:06:34 +00:00

325 lines
9.5 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* This script marks as archived all prices with a matching version key in their lookup key
*
* Usage:
* node scripts/stripe/archive_prices_by_version_key.mjs --region us --version versionKey [options]
* node scripts/stripe/archive_prices_by_version_key.mjs --region uk --version versionKey [options]
*
* Options:
* --region Required. Stripe region to process (us or uk)
* --version Required. Version key to match in lookup keys (e.g., 'jul2025')
* --action Required. Action to perform: 'archive', 'soft-archive', or 'unarchive'
* - archive: Set prices as inactive (if not associated with any active subscriptions) and add [ARCHIVED] to nickname
* - soft-archive: Only add [ARCHIVED] to nickname, keep prices active
* - unarchive: Reactivate prices and remove [ARCHIVED] from nickname
* --commit Actually perform the updates (default: dry-run mode)
*
* Examples:
* # Dry run archive prices with version 'jul2025' in US region
* node scripts/stripe/archive_prices_by_version_key.mjs --region us --version jul2025 --action archive
*
* # Commit archive prices with version 'jul2025' in UK region
* node scripts/stripe/archive_prices_by_version_key.mjs --region uk --version jul2025 --action archive --commit
*
* # Soft archive: only mark in nickname, keep prices active
* node scripts/stripe/archive_prices_by_version_key.mjs --region us --version jul2025 --action soft-archive --commit
*
* # Unarchive prices with version 'jul2025'
* node scripts/stripe/archive_prices_by_version_key.mjs --region us --version jul2025 --action unarchive --commit
*/
import minimist from 'minimist'
import { z } from '../../app/src/infrastructure/Validation.mjs'
import { scriptRunner } from '../lib/ScriptRunner.mjs'
import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
/**
* @import Stripe from 'stripe'
*/
const paramsSchema = z.object({
region: z.enum(['us', 'uk']),
version: z.string(),
action: z.enum(['archive', 'soft-archive', 'unarchive']),
commit: z.boolean().default(false),
})
/**
* Sleep function to respect Stripe rate limits (100 requests per second)
*/
async function rateLimitSleep() {
return new Promise(resolve => setTimeout(resolve, 50))
}
/**
* Check if a price has active subscriptions (if an active subscription has
* archived prices, those customers will run into issues modifying their
* subscriptions)
*
* @param {Stripe} stripe
* @param {string} priceId
* @returns {Promise<boolean>}
*/
async function getHasActiveSubscriptions(stripe, priceId) {
const potentiallyActiveStatuses = [
'active',
'trialing',
'past_due',
'unpaid',
'paused',
'incomplete',
]
let hasMore = true
let startingAfter
while (hasMore) {
const params = {
price: priceId,
limit: 100,
}
if (startingAfter) {
params.starting_after = startingAfter
}
const subscriptions = await stripe.subscriptions.list(params)
await rateLimitSleep()
const hasActiveInBatch = subscriptions.data.some(subscription =>
potentiallyActiveStatuses.includes(subscription.status)
)
if (hasActiveInBatch) {
return true
}
hasMore = subscriptions.has_more
if (hasMore && subscriptions.data.length > 0) {
startingAfter = subscriptions.data[subscriptions.data.length - 1].id
}
}
return false
}
/**
* Fetch all prices matching the version key from Stripe
*
* @param {Stripe} stripe
* @param {string} version
* @param {function} trackProgress
* @returns {Promise<Stripe.Price[]>}
*/
async function fetchPricesByVersion(stripe, version, trackProgress) {
const matchingPrices = []
let hasMore = true
let startingAfter
await trackProgress('Fetching prices from Stripe...')
while (hasMore) {
const pricesResult = await stripe.prices.list({
limit: 100,
starting_after: startingAfter,
})
// Filter prices that have the version in their lookup key
const filtered = pricesResult.data.filter(
price => price.lookup_key && price.lookup_key.includes(version)
)
matchingPrices.push(...filtered)
hasMore = pricesResult.has_more
if (hasMore) {
startingAfter = pricesResult.data[pricesResult.data.length - 1].id
}
await rateLimitSleep()
}
await trackProgress(`Found ${matchingPrices.length} matching prices...`)
return matchingPrices
}
/**
* Archive or unarchive prices in Stripe
*
* @param {Stripe.Price[]} prices
* @param {Stripe} stripe
* @param {string} action
* @param {boolean} commit
* @param {function} trackProgress
* @returns {Promise<object>}
*/
async function processPrices(prices, stripe, action, commit, trackProgress) {
const targetActiveStatus = action === 'unarchive'
const isSoftArchive = action === 'soft-archive'
const isArchiving = action === 'archive' || action === 'soft-archive'
const results = {
processed: 0,
skipped: 0,
hasSubscriptions: 0,
errored: 0,
}
// pre-filter prices already in the desired state to avoid unnecessary API calls
const pricesToProcess = []
for (const price of prices) {
const hasArchivedNickname = price.nickname?.includes('[ARCHIVED]')
const alreadyInDesiredState = isArchiving
? hasArchivedNickname
: price.active && !hasArchivedNickname
if (alreadyInDesiredState) {
await trackProgress(
`Skipping price ${price.id} (${price.lookup_key}) - already ${price.active ? 'active' : 'archived'}`
)
results.skipped++
} else {
pricesToProcess.push(price)
}
}
if (pricesToProcess.length === 0) {
return results
}
await trackProgress(`Processing ${pricesToProcess.length} prices...`)
for (const price of pricesToProcess) {
try {
const hasActiveSubscriptions =
action === 'archive'
? await getHasActiveSubscriptions(stripe, price.id)
: false
if (hasActiveSubscriptions) {
results.hasSubscriptions++
}
if (commit) {
const updateParams = {}
// only update active status if not soft-archiving and price doesn't have active subscriptions
if (!isSoftArchive && !hasActiveSubscriptions) {
updateParams.active = targetActiveStatus
}
if (isArchiving && !price.nickname?.includes('[ARCHIVED]')) {
updateParams.nickname = price.nickname
? `[ARCHIVED] ${price.nickname}`
: '[ARCHIVED]'
}
if (action === 'unarchive' && price.nickname?.includes('[ARCHIVED]')) {
updateParams.nickname = price.nickname.replace(/^\[ARCHIVED\]\s*/, '')
}
if (Object.keys(updateParams).length > 0) {
await stripe.prices.update(price.id, updateParams)
let statusNote = ''
if (hasActiveSubscriptions) {
statusNote = '(soft archived - has active subscriptions)'
} else if (isSoftArchive) {
statusNote = '(soft archived)'
}
await trackProgress(
`${isArchiving ? 'Archived' : 'Unarchived'} price: ${price.id} (${price.lookup_key}) ${statusNote}`
)
await rateLimitSleep()
}
} else {
let statusNote = ''
if (hasActiveSubscriptions) {
statusNote = '(soft archived - has active subscriptions)'
} else if (isSoftArchive) {
statusNote = '(soft archived)'
}
await trackProgress(
`[DRY RUN] Would ${action} price: ${price.id} (${price.lookup_key}) ${statusNote}`
)
}
results.processed++
} catch (error) {
await trackProgress(
`ERROR processing price ${price.id}: ${error.message}`
)
results.errored++
}
}
return results
}
async function main(trackProgress) {
const parseResult = paramsSchema.safeParse(
minimist(process.argv.slice(2), {
boolean: ['commit'],
string: ['region', 'version', 'action'],
})
)
if (!parseResult.success) {
throw new Error(`Invalid parameters: ${parseResult.error.message}`)
}
const { region, version, action, commit } = parseResult.data
const mode = commit ? 'COMMIT MODE' : 'DRY RUN MODE'
await trackProgress(`Starting ${action} in ${mode} for region: ${region}`)
await trackProgress(`Target version: ${version}`)
const stripe = getRegionClient(region).stripe
const prices = await fetchPricesByVersion(stripe, version, trackProgress)
if (prices.length === 0) {
await trackProgress('No prices found. Exiting.')
return
}
await trackProgress(`Processing ${action} operation...`)
const results = await processPrices(
prices,
stripe,
action,
commit,
trackProgress
)
await trackProgress('OPERATION SUMMARY')
await trackProgress(
`Prices ${commit ? 'processed' : 'would be processed'}: ${results.processed}`
)
await trackProgress(
`Prices skipped (already in desired state): ${results.skipped}`
)
await trackProgress(
`Prices skipped (has active subscriptions): ${results.hasSubscriptions}`
)
await trackProgress(`Prices errored: ${results.errored}`)
if (results.errored > 0) {
await trackProgress(
'WARNING: Some prices failed to process. Check the logs above.'
)
}
if (!commit) {
await trackProgress(
'This was a dry run. Use --commit to actually perform the operation.'
)
}
await trackProgress(`Script completed in ${mode}`)
}
try {
await scriptRunner(main)
process.exit(0)
} catch (error) {
console.error('Script failed:', error.message)
process.exit(1)
}