[scripts] handle creating custom prices (#32607)

GitOrigin-RevId: 3950698ca21de5d8ee87d0531e1e9f562aeb76f8
This commit is contained in:
Kristina
2026-05-01 11:36:06 +02:00
committed by Copybot
parent 4954c738da
commit 9a670805c4

View File

@@ -0,0 +1,340 @@
// @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_custom_prices_from_csv.mjs -f <file> --region <us|uk> --version <v> [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').
* --productDescription Description to use for newly created products (default: blank).
* --commit Apply changes to Stripe (default is dry-run).
*
* CSV Format:
* planCode,productName,priceDescription,interval,USD,GBP,EUR
* essentials,Essentials Monthly,"Historical custom price",month,21,17,19
* essentials-annual,Essentials Annual,"Historical custom price",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} priceDescription - Optional
* @property {string} interval - 'month' or 'year'
* @property {Record<string, string | number>} 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(),
productDescription: z.string().default(''),
commit: z.boolean().default(false),
})
/**
* @param {import('stripe').Stripe} stripe
* @returns {Promise<Record<string, Price>>}
*/
async function getExistingPrices(stripe) {
/** @type {Record<string, Price>} */
const pricesByLookupKey = {}
let startingAfter
do {
/** @type {any} */
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<Record<string, Product>>}
*/
async function getExistingProducts(stripe) {
/** @type {Record<string, Product>} */
const productsById = {}
let startingAfter
do {
/** @type {any} */
const response = await stripe.products.list({
limit: 100,
starting_after: startingAfter,
})
for (const product of response.data) {
productsById[product.metadata.planCode] = product
}
startingAfter = response.has_more
? response.data[response.data.length - 1].id
: undefined
} while (startingAfter)
return productsById
}
/**
* @param {unknown} err
* @returns {boolean}
*/
function isAlreadyExistsError(err) {
const maybeErr = /** @type {any} */ (err)
const code = maybeErr?.code || maybeErr?.raw?.code
if (code === 'resource_already_exists') return true
const message = err instanceof Error ? err.message : String(err)
return /already exists/i.test(message)
}
/**
* @param {any} trackProgress
*/
export async function main(trackProgress) {
const args = minimist(process.argv.slice(2), {
boolean: ['commit'],
string: ['region', 'f', 'version', 'productDescription'],
})
const parseResult = paramsSchema.safeParse(args)
if (!parseResult.success) {
throw new Error(`Invalid parameters: ${parseResult.error.message}`)
}
const {
f: inputFile,
region,
version,
productDescription: defaultProductDescription,
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')
/** @type {PriceRecord[]} */
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 the known non-currency columns)
const nonCurrencyKeys = new Set([
'planCode',
'productName',
'priceDescription',
'interval',
])
const currencyKeys = Object.keys(records[0]).filter(
k => !nonCurrencyKeys.has(k)
)
// 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, priceDescription, 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
// Keep in-memory caches in sync so repeated plan rows are idempotent
// within a single run.
if (!existingProducts[planCode]) {
let productCreated = false
const productName =
record.productName ||
planCode
.split(/[_-]/) // Handle underscores or hyphens
.map(
/** @param {any} word */
word => word.charAt(0).toUpperCase() + word.slice(1)
)
.join(' ')
if (commit) {
try {
await stripe.products.create({
id: planCode,
name: productName,
description: defaultProductDescription || 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()
productCreated = true
} catch (err) {
if (isAlreadyExistsError(err)) {
await log(
`- Product '${planCode}' already exists (detected during create). Continuing.`
)
} else {
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
}
}
} else {
productCreated = true
}
// Keep in-memory cache in sync so later rows in this run are idempotent.
existingProducts[planCode] = /** @type {any} */ ({
id: planCode,
metadata: { planCode },
})
if (productCreated) {
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(/** @type {any} */ (record)[currency])
if (isNaN(amountValue) || amountValue <= 0) continue
const currencyLower = currency.toLowerCase()
const unitAmount = convertToMinorUnits(amountValue, currencyLower)
const lookupKeyInterval = interval === 'month' ? 'monthly' : 'annual'
// For custom prices, lookup keys always include the minor-unit amount.
const lookupKeyBase = `${planCode}_${lookupKeyInterval}_${version}_${currencyLower}`
const lookupKey = `${lookupKeyBase}_${unitAmount}`
if (existingPrices[lookupKey]) {
await log(` - Price '${lookupKey}' already exists. Skipping.`)
summary.skipped++
continue
}
/** @type {PriceCreateParams} */
const priceParams = {
product: planCode,
currency: currencyLower,
unit_amount: unitAmount,
recurring: { interval },
lookup_key: lookupKey,
nickname: priceDescription || undefined,
}
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
}
}
// Keep in-memory cache in sync so duplicates in the same run are skipped.
existingPrices[lookupKey] = /** @type {any} */ ({
lookup_key: lookupKey,
})
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)
}
}