[web] add scripts for updating Stripe prices (#29858)

* update generate recurly prices script to skip stripe-only prices
* add script for creating new Stripe prices from a CSV
* add script to archive Stripe prices by version key
* add script for exporting Stripe products and prices
* add script to import Stripe products

GitOrigin-RevId: 3c9cf8037d956b9532c3efed5fe8d63f8be53a93
This commit is contained in:
Kristina
2025-11-26 11:52:56 +01:00
committed by Copybot
parent d748d8d606
commit 731bf1d8b6
6 changed files with 1257 additions and 2 deletions

View File

@@ -46,7 +46,6 @@ const recurlyCodeToStripeBaseLookupKey = {
'student-annual': 'student_annual',
student_free_trial_7_days: 'student_monthly',
// TODO: change all group plans' lookup_keys to match the UK account after they have been added
group_collaborator: 'group_standard_enterprise',
group_collaborator_educational: 'group_standard_educational',
group_professional: 'group_professional_enterprise',
@@ -181,4 +180,5 @@ export default {
getPlanTypeAndPeriodFromRecurlyPlanCode,
isGroupPlanCode,
convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded,
LATEST_STRIPE_LOOKUP_KEY_VERSION,
}

View File

@@ -78,6 +78,16 @@ function computeAddOnPrices(prices, size) {
})
}
function shouldSkipPlan(record) {
const planCode = record.plan_code
// Skip non-legacy group plan codes (e.g. group_professional_20_enterprise)
if (planCode.startsWith('group_') && !GROUP_SIZE_REGEX.test(planCode)) {
return true
}
return false
}
// Convert the raw records into the output format
function transformRecordToPlan(record) {
@@ -104,8 +114,12 @@ function transformRecordToPlan(record) {
function generate(inputFile, outputFile) {
const input = fs.readFileSync(inputFile, 'utf8')
const rawRecords = csv.parse(input, { columns: true })
// filter out plans that should be skipped
const filteredRecords = rawRecords.filter(record => !shouldSkipPlan(record))
// transform the raw records into the output format
const plans = _.sortBy(rawRecords, 'plan_code').map(transformRecordToPlan)
const plans = _.sortBy(filteredRecords, 'plan_code').map(
transformRecordToPlan
)
const output = JSON.stringify(plans, null, 2)
fs.writeFileSync(outputFile, output)
}

View File

@@ -0,0 +1,209 @@
#!/usr/bin/env node
/**
* This script archives or unarchives 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' or 'unarchive'
* --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
*
* # 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.js'
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', '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))
}
/**
* 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 results = {
processed: 0,
skipped: 0,
errored: 0,
}
for (const price of prices) {
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
}
if (commit) {
await stripe.prices.update(price.id, { active: targetActiveStatus })
await trackProgress(
`${action === 'archive' ? 'Archived' : 'Unarchived'} price: ${price.id} (${price.lookup_key})`
)
await rateLimitSleep()
} else {
await trackProgress(
`[DRY RUN] Would ${action} price: ${price.id} (${price.lookup_key})`
)
}
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 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)
}

View File

@@ -0,0 +1,158 @@
#!/usr/bin/env node
/**
* This script exports all products and their prices from a Stripe environment to a JSON file
*
* Usage:
* node scripts/stripe/export_products_from_environment.mjs --region us -o fileName [options]
* node scripts/stripe/export_products_from_environment.mjs --region uk -o fileName [options]
*
* Options:
* --region Required. Stripe region to export from (us or uk)
* -o Output file path (JSON format)
*
* Examples:
* # Export all active products from US region
* node scripts/stripe/export_products_from_environment.mjs --region us -o export.json
*
* # Export all active products from UK region
* node scripts/stripe/export_products_from_environment.mjs --region uk -o export.json
*/
import minimist from 'minimist'
import fs from 'node:fs'
import { z } from '../../app/src/infrastructure/Validation.js'
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']),
o: z.string(),
})
/**
* Sleep function to respect Stripe rate limits (100 requests per second)
*/
async function rateLimitSleep() {
return new Promise(resolve => setTimeout(resolve, 50))
}
/**
* Fetch all active prices with expanded product data from Stripe
*
* @param {Stripe} stripe
* @param {function} trackProgress
* @returns {Promise<Stripe.Price[]>}
*/
async function fetchAllPricesWithProducts(stripe, trackProgress) {
const allPrices = []
let hasMore = true
let startingAfter
await trackProgress('Fetching active prices with product data from Stripe...')
while (hasMore) {
const params = {
active: true,
limit: 100,
starting_after: startingAfter,
expand: ['data.product'],
}
const pricesResult = await stripe.prices.list(params)
allPrices.push(...pricesResult.data)
hasMore = pricesResult.has_more
if (hasMore) {
startingAfter = pricesResult.data[pricesResult.data.length - 1].id
}
await trackProgress(`Fetched ${allPrices.length} prices...`)
await rateLimitSleep()
}
return allPrices
}
/**
* Build export data structure from prices with expanded products
*
* @param {Stripe.Price[]} prices
* @returns {object}
*/
function buildExportData(prices) {
const productMap = new Map()
const pricesByProduct = new Map()
// Extract unique products and group prices by product
for (const price of 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)) {
productMap.set(productId, product)
}
if (!pricesByProduct.has(productId)) {
pricesByProduct.set(productId, [])
}
pricesByProduct.get(productId).push(price)
}
const products = Array.from(productMap.values())
return {
exportedAt: new Date().toISOString(),
totalProducts: products.length,
totalPrices: prices.length,
products: products.map(product => ({
product,
prices: pricesByProduct.get(product.id) || [],
})),
}
}
async function main(trackProgress) {
const parseResult = paramsSchema.safeParse(
minimist(process.argv.slice(2), {
string: ['region', 'o'],
})
)
if (!parseResult.success) {
throw new Error(`Invalid parameters: ${parseResult.error.message}`)
}
const { region, o: outputFile } = parseResult.data
await trackProgress(`Starting export from region: ${region}`)
const stripe = getRegionClient(region).stripe
const prices = await fetchAllPricesWithProducts(stripe, trackProgress)
await trackProgress(`Found ${prices.length} active prices`)
await trackProgress('Building export data structure...')
const exportData = buildExportData(prices)
await trackProgress(`Writing to file: ${outputFile}`)
fs.writeFileSync(outputFile, JSON.stringify(exportData, null, 2))
await trackProgress('EXPORT COMPLETE')
await trackProgress(`Exported ${exportData.totalProducts} products`)
await trackProgress(`Exported ${exportData.totalPrices} prices`)
await trackProgress(`Output file: ${outputFile}`)
}
try {
await scriptRunner(main)
process.exit(0)
} catch (error) {
console.error('Script failed:', error.message)
process.exit(1)
}

View File

@@ -0,0 +1,279 @@
#!/usr/bin/env node
/**
* This script imports products and prices into a Stripe environment from a JSON file
*
* Usage:
* node scripts/stripe/import_products_to_environment.mjs -f fileName --region us [options]
* node scripts/stripe/import_products_to_environment.mjs -f fileName --region uk [options]
*
* Options:
* -f Path to import JSON file (from export_products_from_environment.mjs)
* --region Required. Stripe region to import to (us or uk)
* --commit Actually perform the imports (default: dry-run mode)
*
* Examples:
* # Dry run import to US region
* node scripts/stripe/import_products_to_environment.mjs -f export.json --region us
*
* # Commit import to UK region
* node scripts/stripe/import_products_to_environment.mjs -f export.json --region uk --commit
*/
import minimist from 'minimist'
import fs from 'node:fs'
import { z } from '../../app/src/infrastructure/Validation.js'
import { scriptRunner } from '../lib/ScriptRunner.mjs'
import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
/**
* @import Stripe from 'stripe'
*/
const paramsSchema = z.object({
f: z.string(),
region: z.enum(['us', 'uk']),
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))
}
/**
* @typedef {object} ImportProduct
* @property {Stripe.Product} product
* @property {Stripe.Price[]} prices
*/
/**
* @typedef {object} ImportData
* @property {string} exportedAt
* @property {number} totalProducts
* @property {number} totalPrices
* @property {ImportProduct[]} products
*/
/**
* Load import data from JSON file
*
* @param {string} filePath
* @returns {ImportData}
*/
function loadImportData(filePath) {
const content = fs.readFileSync(filePath, 'utf-8')
const data = JSON.parse(content)
if (!data.products || !Array.isArray(data.products)) {
throw new Error('Invalid import file format: missing products array')
}
// Validate structure of each product entry
for (const entry of data.products) {
if (!entry.product) {
throw new Error(
'Invalid import file format: product entry missing "product" field'
)
}
if (!entry.prices || !Array.isArray(entry.prices)) {
throw new Error(
`Invalid import file format: product ${entry.product.id || 'unknown'} missing "prices" array`
)
}
}
return data
}
/**
* Create a product in Stripe
*
* @param {Stripe} stripe
* @param {Stripe.Product} productData
* @returns {Promise<Stripe.Product>}
*/
async function createProduct(stripe, productData) {
const params = {
name: productData.name,
active: productData.active,
metadata: productData.metadata,
tax_code: productData.tax_code,
images: productData.images,
}
if (productData.description) {
params.description = productData.description
}
return await stripe.products.create(params)
}
/**
* Create a price in Stripe
*
* @param {Stripe} stripe
* @param {Stripe.Price} priceData
* @param {string} productId
* @returns {Promise<Stripe.Price>}
*/
async function createPrice(stripe, priceData, productId) {
const params = {
product: productId,
currency: priceData.currency,
unit_amount: Number.parseInt(priceData.unit_amount),
billing_scheme: priceData.billing_scheme,
recurring: {
interval: priceData.recurring.interval,
interval_count: Number.parseInt(priceData.recurring.interval_count),
},
lookup_key: priceData.lookup_key,
active: priceData.active,
metadata: priceData.metadata,
nickname: priceData.nickname,
tax_behavior: priceData.tax_behavior,
}
return await stripe.prices.create(params)
}
/**
* Import products and prices to Stripe
*
* @param {ImportData} importData
* @param {Stripe} stripe
* @param {boolean} commit
* @param {function} trackProgress
* @returns {Promise<{productsCreated: number, productsSkipped: number, productsErrored: number, pricesCreated: number, pricesErrored: number}>}
*/
async function importToStripe(importData, stripe, commit, trackProgress) {
const results = {
productsCreated: 0,
productsErrored: 0,
pricesCreated: 0,
pricesErrored: 0,
}
for (const entry of importData.products) {
const { product, prices } = entry
try {
// Create product (Stripe will generate a new ID for this environment)
let createdProduct
if (commit) {
createdProduct = await createProduct(stripe, product)
await trackProgress(
`Created product: ${createdProduct.id} (${product.name})`
)
await rateLimitSleep()
} else {
await trackProgress(`[DRY RUN] Would create product: ${product.name}`)
}
results.productsCreated++
// Create prices for this product
for (const price of prices) {
try {
if (commit) {
const createdPrice = await createPrice(
stripe,
price,
createdProduct.id
)
await trackProgress(
` Created price: ${createdPrice.id} (${price.currency}, ${price.unit_amount})`
)
await rateLimitSleep()
} else {
await trackProgress(
` [DRY RUN] Would create price: ${price.nickname} (${price.currency}, ${price.unit_amount})`
)
}
results.pricesCreated++
} catch (error) {
await trackProgress(
` ERROR creating price ${price.id}: ${error.message}`
)
results.pricesErrored++
}
}
} catch (error) {
await trackProgress(
`ERROR creating product ${product.id}: ${error.message}`
)
results.productsErrored++
}
}
return results
}
async function main(trackProgress) {
const parseResult = paramsSchema.safeParse(
minimist(process.argv.slice(2), {
boolean: ['commit'],
string: ['region', 'f'],
})
)
if (!parseResult.success) {
throw new Error(`Invalid parameters: ${parseResult.error.message}`)
}
const { f: inputFile, region, commit } = parseResult.data
const mode = commit ? 'COMMIT MODE' : 'DRY RUN MODE'
await trackProgress(`Starting import in ${mode} to region: ${region}`)
await trackProgress(`Loading import data from: ${inputFile}`)
const importData = loadImportData(inputFile)
await trackProgress(
`Loaded ${importData.totalProducts} products and ${importData.totalPrices} prices`
)
const stripe = getRegionClient(region).stripe
await trackProgress('Processing import...')
const results = await importToStripe(
importData,
stripe,
commit,
trackProgress
)
await trackProgress('IMPORT SUMMARY')
await trackProgress(
`Products ${commit ? 'created' : 'would be created'}: ${results.productsCreated}`
)
await trackProgress(`Products errored: ${results.productsErrored}`)
await trackProgress(
`Prices ${commit ? 'created' : 'would be created'}: ${results.pricesCreated}`
)
await trackProgress(`Prices errored: ${results.pricesErrored}`)
if (results.productsErrored > 0 || results.pricesErrored > 0) {
await trackProgress(
'WARNING: Some items failed to import. Check the logs above.'
)
}
if (!commit) {
await trackProgress(
'This was a dry run. Use --commit to actually create the items.'
)
}
await trackProgress(`Import completed in ${mode}`)
}
try {
await scriptRunner(main)
process.exit(0)
} catch (error) {
console.error('Script failed:', error.message)
process.exit(1)
}

View File

@@ -0,0 +1,595 @@
#!/usr/bin/env node
/**
* This script creates new price objects in Stripe from a CSV file of prices
*
* Usage:
* node scripts/stripe/update_prices_from_csv.mjs -f fileName --region us --nextVersion versionKey [options]
* node scripts/stripe/update_prices_from_csv.mjs -f fileName --region uk --nextVersion versionKey [options]
*
* Options:
* -f Path to prices CSV file
* --region Required. Stripe region to process (us or uk)
* --nextVersion Next version key (e.g., 'jul2025')
* --commit Actually perform the updates (default: dry-run mode)
*
* Examples:
* # Dry run for US region
* node scripts/stripe/update_prices_from_csv.mjs -f inputFile --region us --nextVersion jul2025
*
* # Commit changes for UK region
* node scripts/stripe/update_prices_from_csv.mjs -f inputFile --region uk --nextVersion jul2025 --commit
*/
import minimist from 'minimist'
import fs from 'node:fs'
// https://github.com/import-js/eslint-plugin-import/issues/1810
// eslint-disable-next-line import/no-unresolved
import * as csv from 'csv/sync'
import { z } from '../../app/src/infrastructure/Validation.js'
import { scriptRunner } from '../lib/ScriptRunner.mjs'
import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
import PlansLocator from '../../app/src/Features/Subscription/PlansLocator.mjs'
/**
* @import Stripe from 'stripe'
* @import { StripeCurrencyCode } from '../../types/subscription/currency'
*/
const paramsSchema = z.object({
f: z.string(),
region: z.enum(['us', 'uk']),
nextVersion: z.string(),
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))
}
/**
* Convert amount to minor units (cents for most currencies)
* Some currencies like JPY, KRW, CLP, VND don't have cents
*
* Copied from services/web/frontend/js/shared/utils/currency.ts
*
* @param {number} amount - Amount in major units (dollars, euros, etc.)
* @param {string} currency - Currency code (lowercase)
* @returns {number} Amount in minor units
*/
function convertToMinorUnits(amount, currency) {
const isNoCentsCurrency = ['clp', 'jpy', 'krw', 'vnd'].includes(
currency.toLowerCase()
)
// Determine the multiplier based on currency
let multiplier = 100 // default for most currencies (2 decimal places)
if (isNoCentsCurrency) {
multiplier = 1 // no decimal places
}
// Convert and round to an integer
return Math.round(amount * multiplier)
}
/**
* Convert amount from minor units (cents for most currencies)
* Some currencies like JPY, KRW, CLP, VND don't have cents
*
* Copied from services/web/modules/subscriptions/app/src/StripeClient.mjs
*
* @param {number} amount - price in the smallest currency unit (e.g. dollar cents, CLP units, ...)
* @param {StripeCurrencyCode} currency - currency code
* @return {number}
*/
function convertFromMinorUnits(amount, currency) {
const isNoCentsCurrency = ['clp', 'jpy', 'krw', 'vnd'].includes(
currency.toLowerCase()
)
return isNoCentsCurrency ? amount : amount / 100
}
/**
* @typedef {object} CsvPrice
* @property {number} amountInMinorUnits
* @property {StripeCurrencyCode} currency
*/
/**
* Parse CSV file with price data
*
* @param {string} filePath
* @param {string} nextVersion
* @returns {Map<string, CsvPrice>}
*/
function loadPricesFromCSV(filePath, nextVersion) {
const content = fs.readFileSync(filePath, 'utf-8')
const records = csv.parse(content, {
columns: true,
})
if (records.length === 0) {
throw new Error('CSV file is empty')
}
const priceMap = new Map()
// Get currency codes from the first record's keys (all columns except plan_code)
const currencies = Object.keys(records[0])
.filter(key => key !== 'plan_code')
.map(c => c.toLowerCase())
// Process each record
for (const record of records) {
const planCode = record.plan_code
// Filter out unwanted plan codes
if (shouldSkipPlanCode(planCode)) {
continue
}
// For each currency column, create lookup keys and store in map
for (const currency of currencies) {
const unitAmount = parseFloat(
record[currency.toUpperCase()] || record[currency]
)
if (!isNaN(unitAmount) && unitAmount > 0) {
const minorUnits = convertToMinorUnits(unitAmount, currency)
const lookupKey = buildLookupKeyForPlan(planCode, currency, nextVersion)
if (lookupKey) {
priceMap.set(lookupKey, {
amountInMinorUnits: minorUnits,
currency,
})
}
}
}
}
return priceMap
}
/**
* Determine if a plan code should be skipped
*
* @param {string} planCode
* @returns {boolean}
*/
function shouldSkipPlanCode(planCode) {
if (planCode.includes('trial') || planCode.includes('paid-personal')) {
return true
}
// Skip if matches the specific pattern for non-consolidated group plans
const excludePattern =
/^group_(collaborator|professional)_\d+_(educational|enterprise)$/
if (excludePattern.test(planCode)) {
return true
}
return false
}
/**
* Build the Stripe lookup key for a plan code, handling discounts and special cases
*
* @param {string} planCode
* @param {string} currency
* @param {string} version
* @returns {string | null}
*/
function buildLookupKeyForPlan(planCode, currency, version) {
// rm "enterprise" from plan code, if present
const planCodeWithoutEnterprise = planCode.replace('_enterprise', '')
// Check if this plan code has a discount suffix (e.g., _discount_20)
const discountMatch = planCodeWithoutEnterprise.match(/^(.+)_discount_(\d+)$/)
const hasDiscount = discountMatch !== null
const planCodeWithoutDiscount = hasDiscount
? discountMatch[1]
: planCodeWithoutEnterprise
const discountAmount = hasDiscount ? discountMatch[2] : null
// Special case: Nonprofit group plans
// These are constructed manually without using PlansLocator (these are not available for sale online)
if (planCode.includes('nonprofit')) {
let lookupKey = `${planCodeWithoutDiscount}_${version}_${currency}`
if (discountAmount) {
lookupKey += `_discount_${discountAmount}`
}
return lookupKey
}
// Standard case: Use PlansLocator to build the lookup key
const lookupKey = PlansLocator.buildStripeLookupKey(
planCodeWithoutDiscount,
currency
)
if (!lookupKey) {
return null
}
// Replace the current version with the new version
const lookupKeyWithNewVersion = lookupKey.replace(
PlansLocator.LATEST_STRIPE_LOOKUP_KEY_VERSION,
version
)
// If the plan code had a discount, append it to the lookup key
if (discountAmount) {
return `${lookupKeyWithNewVersion}_discount_${discountAmount}`
}
return lookupKeyWithNewVersion
}
/**
* Copy an existing price and update with pricing data from the CSV, if available
*
* @param {Stripe.Price} existingPrice
* @param {Map<string, CsvPrice>} csvPricesByLookupKey
* @param {string} nextVersion
* @returns {Promise<{ success: boolean, price: Stripe.PriceCreateParams | null, error: string | null }>}
*/
function copyPriceAndUpdate(existingPrice, csvPricesByLookupKey, nextVersion) {
try {
const nextLookupKey = existingPrice.lookup_key.replace(
PlansLocator.LATEST_STRIPE_LOOKUP_KEY_VERSION,
nextVersion
)
const csvData = csvPricesByLookupKey.get(nextLookupKey)
const unitAmount = csvData
? csvData.amountInMinorUnits
: existingPrice.unit_amount
const nextPrice = getPriceParamsFromPriceObject(existingPrice)
nextPrice.unit_amount = unitAmount
nextPrice.lookup_key = nextLookupKey
// TODO: remove this after the June 2025 prices are archived
nextPrice.nickname = nextPrice.nickname.match(/June 2025/)
? ''
: nextPrice.nickname
return { success: true, price: nextPrice, error: null }
} catch (error) {
return { success: false, price: null, error: error.message }
}
}
/**
* Returns params for cloning a price in Stripe
*
* @param {Stripe.Price} priceData
* @returns {Stripe.PriceCreateParams}
*/
function getPriceParamsFromPriceObject(priceData) {
return {
product: priceData.product,
currency: priceData.currency,
unit_amount: Number.parseInt(priceData.unit_amount),
billing_scheme: priceData.billing_scheme,
recurring: {
interval: priceData.recurring.interval,
interval_count: Number.parseInt(priceData.recurring.interval_count),
},
lookup_key: priceData.lookup_key,
active: priceData.active,
metadata: priceData.metadata,
nickname: priceData.nickname,
tax_behavior: priceData.tax_behavior,
}
}
/**
* Fetch all current version prices from Stripe
*
* @param {Stripe} stripe
* @returns {Promise<Stripe.PriceCreateParams[]>}
*/
async function fetchCurrentVersionPrices(stripe) {
const currentPrices = []
let hasMore = true
let startingAfter
while (hasMore) {
const pricesResult = await stripe.prices.list({
active: true,
limit: 100,
starting_after: startingAfter,
})
currentPrices.push(...pricesResult.data)
hasMore = pricesResult.has_more
if (hasMore) {
startingAfter = pricesResult.data[pricesResult.data.length - 1].id
}
}
const currentVersionPrices = currentPrices.filter(
price =>
price.lookup_key &&
price.lookup_key.includes(PlansLocator.LATEST_STRIPE_LOOKUP_KEY_VERSION)
)
return currentVersionPrices
}
/**
* Compare CSV lookup keys with Stripe lookup keys and show differences
*
* @param {Map<string, CsvPrice>} csvPricesByLookupKey
* @param {Stripe.Price[]} currentVersionPrices
* @param {string} nextVersion
* @param {function} trackProgress
*/
async function compareCsvAndStripeLookupKeys(
csvPricesByLookupKey,
currentVersionPrices,
nextVersion,
trackProgress
) {
// Get all CSV lookup keys
const csvLookupKeys = new Set(csvPricesByLookupKey.keys())
// Get all Stripe lookup keys (converted to next version)
const stripeLookupKeys = new Set(
currentVersionPrices.map(price =>
price.lookup_key.replace(
PlansLocator.LATEST_STRIPE_LOOKUP_KEY_VERSION,
nextVersion
)
)
)
// Find keys in CSV but not in Stripe
const inCsvNotInStripe = [...csvLookupKeys].filter(
key => !stripeLookupKeys.has(key)
)
if (inCsvNotInStripe.length > 0) {
await trackProgress(
`\n⚠️ ${inCsvNotInStripe.length} lookup key(s) in CSV but NOT in Stripe and will NOT be created:`
)
for (const key of inCsvNotInStripe.sort()) {
await trackProgress(
` - ${key.replace(nextVersion, PlansLocator.LATEST_STRIPE_LOOKUP_KEY_VERSION)}`
)
}
}
}
/**
* Display a summary of unit amount changes
*
* @param {Stripe.Price[]} currentVersionPrices
* @param {Stripe.PriceCreateParams[]} nextPriceObjects
* @param {string} nextVersion
* @param {function} trackProgress
*/
async function showAmountChanges(
currentVersionPrices,
nextPriceObjects,
nextVersion,
trackProgress
) {
const currentMap = new Map(currentVersionPrices.map(p => [p.lookup_key, p]))
const changeList = []
let changeCount = 0
for (const nextPrice of nextPriceObjects) {
const currentLookupKey = nextPrice.lookup_key.replace(
nextVersion,
PlansLocator.LATEST_STRIPE_LOOKUP_KEY_VERSION
)
const current = currentMap.get(currentLookupKey)
if (current) {
if (current.unit_amount !== nextPrice.unit_amount) {
const oldAmount = convertFromMinorUnits(
current.unit_amount,
current.currency
)
const newAmount = convertFromMinorUnits(
nextPrice.unit_amount,
nextPrice.currency
)
changeList.push(
`${nextPrice.lookup_key}: ${oldAmount} -> ${newAmount} ${nextPrice.currency}`
)
changeCount++
} else {
changeList.push(`${nextPrice.lookup_key}: UNCHANGED`)
}
} else {
changeList.push(`New: ${nextPrice.lookup_key}`)
changeCount++
}
}
if (changeCount === 0) {
await trackProgress('\nNo unit amount changes detected')
} else {
await trackProgress(`\nUnit amount changes (${changeCount} total changes):`)
for (const change of changeList) {
await trackProgress(` ${change}`)
}
}
}
/**
* Create prices in Stripe
*
* @param {Stripe.PriceCreateParams[]} pricesToCreate
* @param {Stripe} stripe
* @param {function} trackProgress
* @returns {Promise<Stripe.Price[]>}
*/
async function createPricesInStripe(pricesToCreate, stripe, trackProgress) {
const createdPrices = []
let errorCount = 0
for (const priceObj of pricesToCreate) {
const amountDisplay = convertFromMinorUnits(
priceObj.unit_amount,
priceObj.currency
)
try {
const created = await stripe.prices.create(priceObj)
await trackProgress(
`✓ Created: ${priceObj.lookup_key} (${amountDisplay} ${priceObj.currency}) -> ${created.id}`
)
createdPrices.push(created)
await rateLimitSleep()
} catch (error) {
await trackProgress(
`✗ Error creating ${priceObj.lookup_key}: ${error.message}`
)
errorCount++
}
}
return { createdPrices, errorCount }
}
async function main(trackProgress) {
const parseResult = paramsSchema.safeParse(
minimist(process.argv.slice(2), {
boolean: ['commit'],
string: ['region', 'f', 'nextVersion'],
})
)
if (!parseResult.success) {
throw new Error(`Invalid parameters: ${parseResult.error.message}`)
}
const { f: inputFile, region, nextVersion, commit } = parseResult.data
const mode = commit ? 'COMMIT MODE' : 'DRY RUN MODE'
await trackProgress(`Starting script in ${mode} for region: ${region}`)
await trackProgress(
`Current version: ${PlansLocator.LATEST_STRIPE_LOOKUP_KEY_VERSION}`
)
await trackProgress(`Next version: ${nextVersion}`)
await trackProgress(`\nLoading prices from: ${inputFile}`)
const csvPricesByLookupKey = loadPricesFromCSV(inputFile, nextVersion)
await trackProgress(
`Loaded ${csvPricesByLookupKey.size} price entries from CSV`
)
const stripe = getRegionClient(region).stripe
await trackProgress('\nFetching existing prices from Stripe...')
const currentVersionPrices = await fetchCurrentVersionPrices(stripe)
await trackProgress(
`Found ${currentVersionPrices.length} prices with version ${PlansLocator.LATEST_STRIPE_LOOKUP_KEY_VERSION}`
)
await trackProgress('\nProcessing prices...')
const nextPriceObjects = []
let buildPricesErrorCount = 0
for (const existingPrice of currentVersionPrices) {
const result = copyPriceAndUpdate(
existingPrice,
csvPricesByLookupKey,
nextVersion
)
if (result.success) {
nextPriceObjects.push(result.price)
} else {
buildPricesErrorCount++
if (result.error) {
await trackProgress(
`Error cloning ${existingPrice.lookup_key}: ${result.error}`
)
}
}
}
await trackProgress(`Built ${nextPriceObjects.length} price objects`)
await compareCsvAndStripeLookupKeys(
csvPricesByLookupKey,
currentVersionPrices,
nextVersion,
trackProgress
)
let createdPrices = []
let commitPricesErrorCount = 0
if (commit) {
await trackProgress('Creating prices in Stripe...')
const createResult = await createPricesInStripe(
nextPriceObjects,
stripe,
trackProgress
)
createdPrices = createResult.createdPrices
commitPricesErrorCount += createResult.errorCount
} else {
await showAmountChanges(
currentVersionPrices,
nextPriceObjects,
nextVersion,
trackProgress
)
}
await trackProgress('\nFINAL SUMMARY')
await trackProgress(
`Prices ${commit ? 'created' : 'would be created'}: ${nextPriceObjects.length}`
)
if (buildPricesErrorCount > 0) {
await trackProgress(
`⚠️ Errors encountered while building price objects: ${buildPricesErrorCount}`
)
}
if (commit) {
if (commitPricesErrorCount > 0) {
await trackProgress(
`⚠️ Errors encountered while creating prices in Stripe: ${commitPricesErrorCount}`
)
}
const lookupKeysString =
createdPrices.map(price => price.lookup_key).join(', ') || 'n/a'
await trackProgress(`Created Price Lookup Keys: ${lookupKeysString}`)
} else {
await trackProgress(
'💡 This was a DRY RUN. To actually create the prices, run with --commit'
)
}
if (commit) {
await trackProgress('NEXT STEPS:')
await trackProgress(
`1. Update LATEST_STRIPE_LOOKUP_KEY_VERSION in PlansLocator.mjs to: '${nextVersion}'`
)
await trackProgress('2. Deploy the updated code to production')
await trackProgress(
'3. Archive the old prices in Stripe (set active: false)'
)
}
await trackProgress(`Script completed successfully in ${mode}`)
}
try {
await scriptRunner(main)
process.exit(0)
} catch (error) {
console.error('Script failed:', error.message)
process.exit(1)
}