diff --git a/services/web/scripts/stripe/.gitignore b/services/web/scripts/stripe/.gitignore new file mode 100644 index 0000000000..53752db253 --- /dev/null +++ b/services/web/scripts/stripe/.gitignore @@ -0,0 +1 @@ +output diff --git a/services/web/scripts/stripe/archive_prices_by_version_key.mjs b/services/web/scripts/stripe/archive_prices_by_version_key.mjs index 272c72f66e..623f2dc661 100755 --- a/services/web/scripts/stripe/archive_prices_by_version_key.mjs +++ b/services/web/scripts/stripe/archive_prices_by_version_key.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /** - * This script archives or unarchives all prices with a matching version key in their lookup key + * 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] @@ -47,6 +47,56 @@ 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} + */ +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 * @@ -102,29 +152,77 @@ async function processPrices(prices, stripe, action, commit, trackProgress) { 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 = + action === 'archive' + ? 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 { - // Skip if already in the desired state - if (price.active === targetActiveStatus) { - await trackProgress( - `Skipping price ${price.id} (${price.lookup_key}) - already ${price.active ? 'active' : 'archived'}` - ) - results.skipped++ - continue + const hasActiveSubscriptions = + action === 'archive' + ? await getHasActiveSubscriptions(stripe, price.id) + : false + if (hasActiveSubscriptions) { + results.hasSubscriptions++ } if (commit) { - await stripe.prices.update(price.id, { active: targetActiveStatus }) - await trackProgress( - `${action === 'archive' ? 'Archived' : 'Unarchived'} price: ${price.id} (${price.lookup_key})` - ) - await rateLimitSleep() + const updateParams = {} + + if (!hasActiveSubscriptions) { + updateParams.active = targetActiveStatus + } + + if (action === 'archive' && !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) + const statusNote = hasActiveSubscriptions + ? '(nickname only - has active subscriptions)' + : '' + await trackProgress( + `${action === 'archive' ? 'Archived' : 'Unarchived'} price: ${price.id} (${price.lookup_key}) ${statusNote}` + ) + await rateLimitSleep() + } } else { + const statusNote = hasActiveSubscriptions + ? '(nickname only - has active subscriptions)' + : '' await trackProgress( - `[DRY RUN] Would ${action} price: ${price.id} (${price.lookup_key})` + `[DRY RUN] Would ${action} price: ${price.id} (${price.lookup_key}) ${statusNote}` ) } @@ -183,6 +281,9 @@ async function main(trackProgress) { 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) { diff --git a/services/web/scripts/stripe/export_products_from_environment.mjs b/services/web/scripts/stripe/export_products_from_environment.mjs index a59777e2de..602a88deaa 100755 --- a/services/web/scripts/stripe/export_products_from_environment.mjs +++ b/services/web/scripts/stripe/export_products_from_environment.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /** - * This script exports all products and their prices from a Stripe environment to a JSON file + * This script exports active products and their active prices from a Stripe environment to a JSON file * * Usage: * node scripts/stripe/export_products_from_environment.mjs --region us -o fileName [options] @@ -21,6 +21,7 @@ import minimist from 'minimist' import fs from 'node:fs' +import path from 'node:path' import { z } from '../../app/src/infrastructure/Validation.mjs' import { scriptRunner } from '../lib/ScriptRunner.mjs' import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs' @@ -93,15 +94,22 @@ function buildExportData(prices) { const product = price.product const productId = typeof product === 'string' ? product : product.id - // Store the product object if it's expanded - if (typeof product !== 'string' && !productMap.has(productId)) { + // Store the product object if it's expanded and active + if ( + typeof product !== 'string' && + product.active && + !productMap.has(productId) + ) { productMap.set(productId, product) } - if (!pricesByProduct.has(productId)) { - pricesByProduct.set(productId, []) + // Only include prices for active products + if (typeof product !== 'string' && product.active) { + if (!pricesByProduct.has(productId)) { + pricesByProduct.set(productId, []) + } + pricesByProduct.get(productId).push(price) } - pricesByProduct.get(productId).push(price) } const products = Array.from(productMap.values()) @@ -141,6 +149,8 @@ async function main(trackProgress) { const exportData = buildExportData(prices) await trackProgress(`Writing to file: ${outputFile}`) + const outputDir = path.dirname(outputFile) + fs.mkdirSync(outputDir, { recursive: true }) fs.writeFileSync(outputFile, JSON.stringify(exportData, null, 2)) await trackProgress('EXPORT COMPLETE')