create_custom_prices_from_csv.mjs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  1. // @ts-check
  2. /**
  3. * This script creates new Products and Prices in Stripe from a CSV file.
  4. * Use this when adding entirely new plans that don't exist in Stripe yet.
  5. *
  6. * Usage:
  7. * node scripts/stripe/create_custom_prices_from_csv.mjs -f <file> --region <us|uk> --version <v> [options]
  8. *
  9. * Options:
  10. * -f Path to the prices CSV file.
  11. * --region Stripe region (us or uk).
  12. * --version Version string for the lookup_key (e.g., 'v1', 'jan2026').
  13. * --productDescription Description to use for newly created products (default: blank).
  14. * --commit Apply changes to Stripe (default is dry-run).
  15. *
  16. * CSV Format:
  17. * planCode,productName,priceDescription,interval,USD,GBP,EUR
  18. * essentials,Essentials Monthly,"Historical custom price",month,21,17,19
  19. * essentials-annual,Essentials Annual,"Historical custom price",year,199,159,179
  20. */
  21. import minimist from 'minimist'
  22. import fs from 'node:fs'
  23. // https://github.com/import-js/eslint-plugin-import/issues/1810
  24. // eslint-disable-next-line import/no-unresolved
  25. import * as csv from 'csv/sync'
  26. import { scriptRunner } from '../lib/ScriptRunner.mjs'
  27. import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
  28. import { z } from '@overleaf/validation-tools'
  29. import { convertToMinorUnits, rateLimitSleep } from './helpers.mjs'
  30. /**
  31. * @typedef {object} PriceRecord
  32. * @property {string} planCode
  33. * @property {string} productName - Optional, can be derived from planCode if not provided
  34. * @property {string} priceDescription - Optional
  35. * @property {string} interval - 'month' or 'year'
  36. * @property {Record<string, string | number>} currencies - Dynamic currency columns
  37. */
  38. /**
  39. * @typedef {import('stripe').Stripe} Stripe
  40. * @typedef {import('stripe').Stripe.Price} Price
  41. * @typedef {import('stripe').Stripe.PriceCreateParams} PriceCreateParams
  42. * @typedef {import('stripe').Stripe.Product} Product
  43. */
  44. const paramsSchema = z.object({
  45. f: z.string(),
  46. region: z.enum(['us', 'uk']),
  47. version: z.string(),
  48. productDescription: z.string().default(''),
  49. commit: z.boolean().default(false),
  50. })
  51. /**
  52. * @param {import('stripe').Stripe} stripe
  53. * @returns {Promise<Record<string, Price>>}
  54. */
  55. async function getExistingPrices(stripe) {
  56. /** @type {Record<string, Price>} */
  57. const pricesByLookupKey = {}
  58. let startingAfter
  59. do {
  60. /** @type {any} */
  61. const response = await stripe.prices.list({
  62. limit: 100,
  63. starting_after: startingAfter,
  64. })
  65. for (const price of response.data) {
  66. if (price.lookup_key) {
  67. pricesByLookupKey[price.lookup_key] = price
  68. }
  69. }
  70. startingAfter = response.has_more
  71. ? response.data[response.data.length - 1].id
  72. : undefined
  73. } while (startingAfter)
  74. return pricesByLookupKey
  75. }
  76. /**
  77. * @param {import('stripe').Stripe} stripe
  78. * @return {Promise<Record<string, Product>>}
  79. */
  80. async function getExistingProducts(stripe) {
  81. /** @type {Record<string, Product>} */
  82. const productsById = {}
  83. let startingAfter
  84. do {
  85. /** @type {any} */
  86. const response = await stripe.products.list({
  87. limit: 100,
  88. starting_after: startingAfter,
  89. })
  90. for (const product of response.data) {
  91. productsById[product.metadata.planCode] = product
  92. }
  93. startingAfter = response.has_more
  94. ? response.data[response.data.length - 1].id
  95. : undefined
  96. } while (startingAfter)
  97. return productsById
  98. }
  99. /**
  100. * @param {unknown} err
  101. * @returns {boolean}
  102. */
  103. function isAlreadyExistsError(err) {
  104. const maybeErr = /** @type {any} */ (err)
  105. const code = maybeErr?.code || maybeErr?.raw?.code
  106. if (code === 'resource_already_exists') return true
  107. const message = err instanceof Error ? err.message : String(err)
  108. return /already exists/i.test(message)
  109. }
  110. /**
  111. * @param {any} trackProgress
  112. */
  113. export async function main(trackProgress) {
  114. const args = minimist(process.argv.slice(2), {
  115. boolean: ['commit'],
  116. string: ['region', 'f', 'version', 'productDescription'],
  117. })
  118. const parseResult = paramsSchema.safeParse(args)
  119. if (!parseResult.success) {
  120. throw new Error(`Invalid parameters: ${parseResult.error.message}`)
  121. }
  122. const {
  123. f: inputFile,
  124. region,
  125. version,
  126. productDescription: defaultProductDescription,
  127. commit,
  128. } = parseResult.data
  129. const mode = commit ? 'COMMIT MODE' : 'DRY RUN MODE'
  130. const log = (message = '') =>
  131. trackProgress(mode === 'DRY RUN MODE' ? `[DRY RUN] ${message}` : message)
  132. await log(`Starting creation script in ${mode} for region: ${region}`)
  133. const stripe = getRegionClient(region).stripe
  134. // Load and Parse CSV
  135. const content = fs.readFileSync(inputFile, 'utf-8')
  136. /** @type {PriceRecord[]} */
  137. const records = csv.parse(content, { columns: true, skip_empty_lines: true })
  138. if (records.length === 0) {
  139. throw new Error('CSV file is empty or invalid.')
  140. }
  141. // Identify currency columns (everything except the known non-currency columns)
  142. const nonCurrencyKeys = new Set([
  143. 'planCode',
  144. 'productName',
  145. 'priceDescription',
  146. 'interval',
  147. ])
  148. const currencyKeys = Object.keys(records[0]).filter(
  149. k => !nonCurrencyKeys.has(k)
  150. )
  151. // Cache existing data to minimize API calls and prevent duplicates
  152. await log('Fetching existing Stripe data...')
  153. const existingPrices = await getExistingPrices(stripe)
  154. const existingProducts = await getExistingProducts(stripe)
  155. const summary = {
  156. productsCreated: 0,
  157. pricesCreated: 0,
  158. skipped: 0,
  159. invalidRows: 0,
  160. errors: 0,
  161. }
  162. let rowNumber = 0 // For logging purposes, starting after header
  163. for (const /** @type {PriceRecord} */ record of records) {
  164. ++rowNumber
  165. const { planCode, priceDescription, interval } = record
  166. if (!planCode) {
  167. await log(`✗ No plan code in row ${rowNumber}`)
  168. ++summary.invalidRows
  169. continue
  170. }
  171. if (interval !== 'month' && interval !== 'year') {
  172. await log(
  173. `✗ Invalid interval '${interval}' on row ${rowNumber}. Must be either 'month' or 'year'.`
  174. )
  175. ++summary.invalidRows
  176. continue
  177. }
  178. await log()
  179. await log(`--- Processing Plan: ${planCode} ---`)
  180. // 1. Handle product
  181. // Keep in-memory caches in sync so repeated plan rows are idempotent
  182. // within a single run.
  183. if (!existingProducts[planCode]) {
  184. let productCreated = false
  185. const productName =
  186. record.productName ||
  187. planCode
  188. .split(/[_-]/) // Handle underscores or hyphens
  189. .map(
  190. /** @param {any} word */
  191. word => word.charAt(0).toUpperCase() + word.slice(1)
  192. )
  193. .join(' ')
  194. if (commit) {
  195. try {
  196. await stripe.products.create({
  197. id: planCode,
  198. name: productName,
  199. description: defaultProductDescription || undefined, // Don't pass an empty string, Stripe thinks we're trying to unset it and doesn't like it
  200. tax_code: 'txcd_10103000', // "Software as a service (SaaS) - personal use", which is what existing products have
  201. metadata: { planCode },
  202. })
  203. await rateLimitSleep()
  204. productCreated = true
  205. } catch (err) {
  206. if (isAlreadyExistsError(err)) {
  207. await log(
  208. `- Product '${planCode}' already exists (detected during create). Continuing.`
  209. )
  210. } else {
  211. const errorMessage =
  212. err instanceof Error ? err.message : String(err)
  213. await log(`✗ Error creating product ${planCode}: ${errorMessage}`)
  214. summary.errors++
  215. continue // Skip prices if product creation failed
  216. }
  217. }
  218. } else {
  219. productCreated = true
  220. }
  221. // Keep in-memory cache in sync so later rows in this run are idempotent.
  222. existingProducts[planCode] = /** @type {any} */ ({
  223. id: planCode,
  224. metadata: { planCode },
  225. })
  226. if (productCreated) {
  227. await log(`✓ Created product: ${planCode} ("${productName}")`)
  228. summary.productsCreated++
  229. }
  230. } else {
  231. await log(`- Product '${planCode}' already exists.`)
  232. }
  233. // 2. Handle Prices for each currency column
  234. for (const currency of currencyKeys) {
  235. const amountValue = parseFloat(/** @type {any} */ (record)[currency])
  236. if (isNaN(amountValue) || amountValue <= 0) continue
  237. const currencyLower = currency.toLowerCase()
  238. const unitAmount = convertToMinorUnits(amountValue, currencyLower)
  239. const lookupKeyInterval = interval === 'month' ? 'monthly' : 'annual'
  240. // For custom prices, lookup keys always include the minor-unit amount.
  241. const lookupKeyBase = `${planCode}_${lookupKeyInterval}_${version}_${currencyLower}`
  242. const lookupKey = `${lookupKeyBase}_${unitAmount}`
  243. if (existingPrices[lookupKey]) {
  244. await log(` - Price '${lookupKey}' already exists. Skipping.`)
  245. summary.skipped++
  246. continue
  247. }
  248. /** @type {PriceCreateParams} */
  249. const priceParams = {
  250. product: planCode,
  251. currency: currencyLower,
  252. unit_amount: unitAmount,
  253. recurring: { interval },
  254. lookup_key: lookupKey,
  255. nickname: priceDescription || undefined,
  256. }
  257. if (commit) {
  258. try {
  259. await stripe.prices.create(priceParams)
  260. await rateLimitSleep()
  261. } catch (err) {
  262. const errorMessage = err instanceof Error ? err.message : String(err)
  263. await log(` ✗ Error creating price ${lookupKey}: ${errorMessage}`)
  264. summary.errors++
  265. continue
  266. }
  267. }
  268. // Keep in-memory cache in sync so duplicates in the same run are skipped.
  269. existingPrices[lookupKey] = /** @type {any} */ ({
  270. lookup_key: lookupKey,
  271. })
  272. await log(
  273. ` ✓ Created price: ${lookupKey} (${amountValue} ${currencyLower.toUpperCase()})`
  274. )
  275. summary.pricesCreated++
  276. }
  277. }
  278. // Final Summary
  279. await log()
  280. await log('='.repeat(20))
  281. await log()
  282. await log('✨ FINAL SUMMARY ✨')
  283. await log(` ✅ Products created: ${summary.productsCreated}`)
  284. await log(` ✅ Prices created: ${summary.pricesCreated}`)
  285. await log(` ⏭️ Items skipped: ${summary.skipped}`)
  286. await log(` ⏭️ Invalid rows skipped: ${summary.invalidRows}`)
  287. await log(` ❌ Errors encountered: ${summary.errors}`)
  288. if (!commit) {
  289. await log('ℹ️ DRY RUN: No changes were applied to Stripe')
  290. }
  291. await log('🎉 Script completed!')
  292. }
  293. if (import.meta.main) {
  294. try {
  295. await scriptRunner(main)
  296. process.exit(0)
  297. } catch (error) {
  298. console.error(error)
  299. process.exit(1)
  300. }
  301. }