mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-30 12:24:25 +02:00
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
This commit is contained in:
270
services/web/scripts/stripe/create_prices_from_csv.mjs
Normal file
270
services/web/scripts/stripe/create_prices_from_csv.mjs
Normal file
@@ -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 <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').
|
||||
* --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<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(),
|
||||
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 {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user