From 9e41d9afdbcca46fe31497709b33e29004e2c772 Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Mon, 9 Feb 2026 14:26:57 +0100 Subject: [PATCH] Add script to create Stripe prices from CSV (#31144) * Extract currency conversion and rate limiting functions to helpers * Add script to create prices from CSV for Stripe integration * Add tests for create prices from CSV script * Add usage documentation for create_prices_from_csv script * Add `planCode` metadata * Temp: Plans CSV example * Revert "Temp: Plans CSV example" This reverts commit 810b1ed67052f7a1a0deb20b70f14507a282fcf1. * Various improvements to price and product creation script --------- Co-authored-by: Tim Down <158919+timdown@users.noreply.github.com> GitOrigin-RevId: c015c6dd904db3143781581db4210cef282a4070 --- .../scripts/stripe/create_prices_from_csv.mjs | 270 ++++++++++++++++++ services/web/scripts/stripe/helpers.mjs | 50 ++++ .../scripts/stripe/update_prices_from_csv.mjs | 55 +--- 3 files changed, 325 insertions(+), 50 deletions(-) create mode 100644 services/web/scripts/stripe/create_prices_from_csv.mjs diff --git a/services/web/scripts/stripe/create_prices_from_csv.mjs b/services/web/scripts/stripe/create_prices_from_csv.mjs new file mode 100644 index 0000000000..b938649c26 --- /dev/null +++ b/services/web/scripts/stripe/create_prices_from_csv.mjs @@ -0,0 +1,270 @@ +// @ts-check + +/** + * This script creates new Products and Prices in Stripe from a CSV file. + * Use this when adding entirely new plans that don't exist in Stripe yet. + * + * Usage: + * node scripts/stripe/create_prices_from_csv.mjs -f --region --version [options] + * + * Options: + * -f Path to the prices CSV file. + * --region Stripe region (us or uk). + * --version Version string for the lookup_key (e.g., 'v1', 'jan2026'). + * --commit Apply changes to Stripe (default is dry-run). + * + * CSV Format: + * planCode,productName,productDescription,interval,USD,GBP,EUR + * essentials,Essentials Monthly,"Editable project limit 10, collaborators 5",month,21,17,19 + * essentials-annual,Essentials Annual,"Editable project limit 10, collaborators 5",year,199,159,179 + */ + +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 { scriptRunner } from '../lib/ScriptRunner.mjs' +import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs' +import { z } from '@overleaf/validation-tools' +import { convertToMinorUnits, rateLimitSleep } from './helpers.mjs' + +/** + * @typedef {object} PriceRecord + * @property {string} planCode + * @property {string} productName - Optional, can be derived from planCode if not provided + * @property {string} productDescription - Optional + * @property {string} interval - 'month' or 'year' + * @property {Record} currencies - Dynamic currency columns + */ + +/** + * @typedef {import('stripe').Stripe} Stripe + * @typedef {import('stripe').Stripe.Price} Price + * @typedef {import('stripe').Stripe.PriceCreateParams} PriceCreateParams + * @typedef {import('stripe').Stripe.Product} Product + */ + +const paramsSchema = z.object({ + f: z.string(), + region: z.enum(['us', 'uk']), + version: z.string(), + commit: z.boolean().default(false), +}) + +/** + * @param {import('stripe').Stripe} stripe + * @returns {Promise>} + */ +async function getExistingPrices(stripe) { + /** @type {Record} */ + const pricesByLookupKey = {} + let startingAfter + + do { + const response = await stripe.prices.list({ + limit: 100, + starting_after: startingAfter, + }) + for (const price of response.data) { + if (price.lookup_key) { + pricesByLookupKey[price.lookup_key] = price + } + } + startingAfter = response.has_more + ? response.data[response.data.length - 1].id + : undefined + } while (startingAfter) + + return pricesByLookupKey +} + +/** + * @param {import('stripe').Stripe} stripe + * @return {Promise>} + */ +async function getExistingProducts(stripe) { + /** @type {Record} */ + const productsById = {} + let startingAfter + + do { + const response = await stripe.products.list({ + limit: 100, + starting_after: startingAfter, + }) + for (const product of response.data) { + productsById[product.id] = product + } + startingAfter = response.has_more + ? response.data[response.data.length - 1].id + : undefined + } while (startingAfter) + + return productsById +} + +export async function main(trackProgress) { + const args = minimist(process.argv.slice(2), { + boolean: ['commit'], + string: ['region', 'f', 'version'], + }) + + const parseResult = paramsSchema.safeParse(args) + if (!parseResult.success) { + throw new Error(`Invalid parameters: ${parseResult.error.message}`) + } + + const { f: inputFile, region, version, commit } = parseResult.data + const mode = commit ? 'COMMIT MODE' : 'DRY RUN MODE' + + const log = (message = '') => + trackProgress(mode === 'DRY RUN MODE' ? `[DRY RUN] ${message}` : message) + + await log(`Starting creation script in ${mode} for region: ${region}`) + const stripe = getRegionClient(region).stripe + + // Load and Parse CSV + const content = fs.readFileSync(inputFile, 'utf-8') + const records = csv.parse(content, { columns: true, skip_empty_lines: true }) + + if (records.length === 0) { + throw new Error('CSV file is empty or invalid.') + } + + // Identify currency columns (everything except planCode) + const currencyKeys = Object.keys(records[0]).filter(k => k !== 'planCode') + + // Cache existing data to minimize API calls and prevent duplicates + await log('Fetching existing Stripe data...') + const existingPrices = await getExistingPrices(stripe) + const existingProducts = await getExistingProducts(stripe) + + const summary = { + productsCreated: 0, + pricesCreated: 0, + skipped: 0, + invalidRows: 0, + errors: 0, + } + + let rowNumber = 0 // For logging purposes, starting after header + for (const /** @type {PriceRecord} */ record of records) { + ++rowNumber + const { planCode, productDescription, interval } = record + if (!planCode) { + await log(`✗ No plan code in row ${rowNumber}`) + ++summary.invalidRows + continue + } + if (interval !== 'month' && interval !== 'year') { + await log( + `✗ Invalid interval '${interval}' on row ${rowNumber}. Must be either 'month' or 'year'.` + ) + ++summary.invalidRows + continue + } + + await log() + await log(`--- Processing Plan: ${planCode} ---`) + + // 1. Handle product + if (!existingProducts[planCode]) { + const productName = + record.productName || + planCode + .split(/[_-]/) // Handle underscores or hyphens + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') + + if (commit) { + try { + await stripe.products.create({ + id: planCode, + name: productName, + description: productDescription || undefined, // Don't pass an empty string, Stripe thinks we're trying to unset it and doesn't like it + tax_code: 'txcd_10103000', // "Software as a service (SaaS) - personal use", which is what existing products have + metadata: { planCode }, + }) + await rateLimitSleep() + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err) + await log(`✗ Error creating product ${planCode}: ${errorMessage}`) + summary.errors++ + continue // Skip prices if product creation failed + } + } + await log(`✓ Created product: ${planCode} ("${productName}")`) + summary.productsCreated++ + } else { + await log(`- Product '${planCode}' already exists.`) + } + + // 2. Handle Prices for each currency column + for (const currency of currencyKeys) { + const amountValue = parseFloat(record[currency]) + if (isNaN(amountValue) || amountValue <= 0) continue + + const currencyLower = currency.toLowerCase() + // Standardize lookup key format: {plan}_{interval}_{version}_{currency} + const lookupKey = `${planCode}_${interval}_${version}_${currencyLower}` + + if (existingPrices[lookupKey]) { + await log(` - Price '${lookupKey}' already exists. Skipping.`) + summary.skipped++ + continue + } + + /** @type {PriceCreateParams} */ + const priceParams = { + product: planCode, + currency: currencyLower, + unit_amount: convertToMinorUnits(amountValue, currencyLower), + recurring: { interval }, + lookup_key: lookupKey, + } + + if (commit) { + try { + await stripe.prices.create(priceParams) + await rateLimitSleep() + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err) + await log(` ✗ Error creating price ${lookupKey}: ${errorMessage}`) + summary.errors++ + continue + } + } + await log( + ` ✓ Created price: ${lookupKey} (${amountValue} ${currencyLower.toUpperCase()})` + ) + summary.pricesCreated++ + } + } + + // Final Summary + await log() + await log('='.repeat(20)) + await log() + await log('✨ FINAL SUMMARY ✨') + await log(` ✅ Products created: ${summary.productsCreated}`) + await log(` ✅ Prices created: ${summary.pricesCreated}`) + await log(` ⏭️ Items skipped: ${summary.skipped}`) + await log(` ⏭️ Invalid rows skipped: ${summary.invalidRows}`) + await log(` ❌ Errors encountered: ${summary.errors}`) + + if (!commit) { + await log('ℹ️ DRY RUN: No changes were applied to Stripe') + } + await log('🎉 Script completed!') +} + +if (import.meta.main) { + try { + await scriptRunner(main) + process.exit(0) + } catch (error) { + console.error(error) + process.exit(1) + } +} diff --git a/services/web/scripts/stripe/helpers.mjs b/services/web/scripts/stripe/helpers.mjs index ca94be7ce5..4bef98c232 100644 --- a/services/web/scripts/stripe/helpers.mjs +++ b/services/web/scripts/stripe/helpers.mjs @@ -78,3 +78,53 @@ export function getProductIdFromPrice(price) { ? price.product : (price.product?.id ?? '') } + +/** + * Sleep function to respect Stripe rate limits (100 requests per second) + */ +export 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 + */ +export 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} + */ +export function convertFromMinorUnits(amount, currency) { + const isNoCentsCurrency = ['clp', 'jpy', 'krw', 'vnd'].includes( + currency.toLowerCase() + ) + return isNoCentsCurrency ? amount : amount / 100 +} diff --git a/services/web/scripts/stripe/update_prices_from_csv.mjs b/services/web/scripts/stripe/update_prices_from_csv.mjs index 870dca7bb5..17fd8bcd28 100644 --- a/services/web/scripts/stripe/update_prices_from_csv.mjs +++ b/services/web/scripts/stripe/update_prices_from_csv.mjs @@ -30,6 +30,11 @@ import { z } from '../../app/src/infrastructure/Validation.mjs' 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 { + convertFromMinorUnits, + convertToMinorUnits, + rateLimitSleep, +} from './helpers.mjs' /** * @import Stripe from 'stripe' @@ -43,56 +48,6 @@ const paramsSchema = z.object({ 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