| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324 |
- #!/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)
- }
|