From 731bf1d8b6d224e00aef9e94e4d122956599f56f Mon Sep 17 00:00:00 2001 From: Kristina <7614497+khjrtbrg@users.noreply.github.com> Date: Wed, 26 Nov 2025 11:52:56 +0100 Subject: [PATCH] [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 --- .../Features/Subscription/PlansLocator.mjs | 2 +- .../recurly/generate_recurly_prices.mjs | 16 +- .../stripe/archive_prices_by_version_key.mjs | 209 ++++++ .../export_products_from_environment.mjs | 158 +++++ .../stripe/import_products_to_environment.mjs | 279 ++++++++ .../scripts/stripe/update_prices_from_csv.mjs | 595 ++++++++++++++++++ 6 files changed, 1257 insertions(+), 2 deletions(-) create mode 100755 services/web/scripts/stripe/archive_prices_by_version_key.mjs create mode 100755 services/web/scripts/stripe/export_products_from_environment.mjs create mode 100755 services/web/scripts/stripe/import_products_to_environment.mjs create mode 100644 services/web/scripts/stripe/update_prices_from_csv.mjs diff --git a/services/web/app/src/Features/Subscription/PlansLocator.mjs b/services/web/app/src/Features/Subscription/PlansLocator.mjs index 14243f759a..86cbec00bc 100644 --- a/services/web/app/src/Features/Subscription/PlansLocator.mjs +++ b/services/web/app/src/Features/Subscription/PlansLocator.mjs @@ -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, } diff --git a/services/web/scripts/recurly/generate_recurly_prices.mjs b/services/web/scripts/recurly/generate_recurly_prices.mjs index 5624cd6f8c..ddde724440 100644 --- a/services/web/scripts/recurly/generate_recurly_prices.mjs +++ b/services/web/scripts/recurly/generate_recurly_prices.mjs @@ -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) } diff --git a/services/web/scripts/stripe/archive_prices_by_version_key.mjs b/services/web/scripts/stripe/archive_prices_by_version_key.mjs new file mode 100755 index 0000000000..2c7c505a7f --- /dev/null +++ b/services/web/scripts/stripe/archive_prices_by_version_key.mjs @@ -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} + */ +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} + */ +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) +} diff --git a/services/web/scripts/stripe/export_products_from_environment.mjs b/services/web/scripts/stripe/export_products_from_environment.mjs new file mode 100755 index 0000000000..26863bb431 --- /dev/null +++ b/services/web/scripts/stripe/export_products_from_environment.mjs @@ -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} + */ +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) +} diff --git a/services/web/scripts/stripe/import_products_to_environment.mjs b/services/web/scripts/stripe/import_products_to_environment.mjs new file mode 100755 index 0000000000..aaf6e2def7 --- /dev/null +++ b/services/web/scripts/stripe/import_products_to_environment.mjs @@ -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} + */ +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} + */ +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) +} diff --git a/services/web/scripts/stripe/update_prices_from_csv.mjs b/services/web/scripts/stripe/update_prices_from_csv.mjs new file mode 100644 index 0000000000..2ca85c0a4e --- /dev/null +++ b/services/web/scripts/stripe/update_prices_from_csv.mjs @@ -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} + */ +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} 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} + */ +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} 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} + */ +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) +}