create_prices_from_csv.mjs 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  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_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. * --commit Apply changes to Stripe (default is dry-run).
  14. *
  15. * CSV Format:
  16. * planCode,productName,productDescription,interval,USD,GBP,EUR
  17. * essentials,Essentials Monthly,"Editable project limit 10, collaborators 5",month,21,17,19
  18. * essentials-annual,Essentials Annual,"Editable project limit 10, collaborators 5",year,199,159,179
  19. */
  20. import minimist from 'minimist'
  21. import fs from 'node:fs'
  22. // https://github.com/import-js/eslint-plugin-import/issues/1810
  23. // eslint-disable-next-line import/no-unresolved
  24. import * as csv from 'csv/sync'
  25. import { scriptRunner } from '../lib/ScriptRunner.mjs'
  26. import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
  27. import { z } from '@overleaf/validation-tools'
  28. import { convertToMinorUnits, rateLimitSleep } from './helpers.mjs'
  29. /**
  30. * @typedef {object} PriceRecord
  31. * @property {string} planCode
  32. * @property {string} productName - Optional, can be derived from planCode if not provided
  33. * @property {string} productDescription - Optional
  34. * @property {string} interval - 'month' or 'year'
  35. * @property {Record<string, string | number>} currencies - Dynamic currency columns
  36. */
  37. /**
  38. * @typedef {import('stripe').Stripe} Stripe
  39. * @typedef {import('stripe').Stripe.Price} Price
  40. * @typedef {import('stripe').Stripe.PriceCreateParams} PriceCreateParams
  41. * @typedef {import('stripe').Stripe.Product} Product
  42. */
  43. const paramsSchema = z.object({
  44. f: z.string(),
  45. region: z.enum(['us', 'uk']),
  46. version: z.string(),
  47. commit: z.boolean().default(false),
  48. })
  49. /**
  50. * Normalize annual cadence to month+12 so all annual prices share the same
  51. * recurring shape for downstream import/migration tooling.
  52. *
  53. * @param {'month' | 'year'} interval
  54. * @returns {{ interval: 'month', interval_count: 1 | 12 }}
  55. */
  56. function getRecurringFromInterval(interval) {
  57. if (interval === 'year') {
  58. return { interval: 'month', interval_count: 12 }
  59. }
  60. return { interval: 'month', interval_count: 1 }
  61. }
  62. /**
  63. * @param {import('stripe').Stripe} stripe
  64. * @returns {Promise<Record<string, Price>>}
  65. */
  66. async function getExistingPrices(stripe) {
  67. /** @type {Record<string, Price>} */
  68. const pricesByLookupKey = {}
  69. let startingAfter
  70. do {
  71. /** @type {any} */
  72. const response = await stripe.prices.list({
  73. limit: 100,
  74. starting_after: startingAfter,
  75. })
  76. for (const price of response.data) {
  77. if (price.lookup_key) {
  78. pricesByLookupKey[price.lookup_key] = price
  79. }
  80. }
  81. startingAfter = response.has_more
  82. ? response.data[response.data.length - 1].id
  83. : undefined
  84. } while (startingAfter)
  85. return pricesByLookupKey
  86. }
  87. /**
  88. * @param {import('stripe').Stripe} stripe
  89. * @return {Promise<Record<string, Product>>}
  90. */
  91. async function getExistingProducts(stripe) {
  92. /** @type {Record<string, Product>} */
  93. const productsById = {}
  94. let startingAfter
  95. do {
  96. /** @type {any} */
  97. const response = await stripe.products.list({
  98. limit: 100,
  99. starting_after: startingAfter,
  100. })
  101. for (const product of response.data) {
  102. productsById[product.id] = product
  103. }
  104. startingAfter = response.has_more
  105. ? response.data[response.data.length - 1].id
  106. : undefined
  107. } while (startingAfter)
  108. return productsById
  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'],
  117. })
  118. const parseResult = paramsSchema.safeParse(args)
  119. if (!parseResult.success) {
  120. throw new Error(`Invalid parameters: ${parseResult.error.message}`)
  121. }
  122. const { f: inputFile, region, version, commit } = parseResult.data
  123. const mode = commit ? 'COMMIT MODE' : 'DRY RUN MODE'
  124. const log = (message = '') =>
  125. trackProgress(mode === 'DRY RUN MODE' ? `[DRY RUN] ${message}` : message)
  126. await log(`Starting creation script in ${mode} for region: ${region}`)
  127. const stripe = getRegionClient(region).stripe
  128. // Load and Parse CSV
  129. const content = fs.readFileSync(inputFile, 'utf-8')
  130. /** @type {PriceRecord[]} */
  131. const records = csv.parse(content, { columns: true, skip_empty_lines: true })
  132. if (records.length === 0) {
  133. throw new Error('CSV file is empty or invalid.')
  134. }
  135. // Identify currency columns (everything except planCode)
  136. const currencyKeys = Object.keys(records[0]).filter(k => k !== 'planCode')
  137. // Cache existing data to minimize API calls and prevent duplicates
  138. await log('Fetching existing Stripe data...')
  139. const existingPrices = await getExistingPrices(stripe)
  140. const existingProducts = await getExistingProducts(stripe)
  141. const summary = {
  142. productsCreated: 0,
  143. pricesCreated: 0,
  144. skipped: 0,
  145. invalidRows: 0,
  146. errors: 0,
  147. }
  148. let rowNumber = 0 // For logging purposes, starting after header
  149. for (const /** @type {PriceRecord} */ record of records) {
  150. ++rowNumber
  151. const { planCode, productDescription, interval } = record
  152. if (!planCode) {
  153. await log(`✗ No plan code in row ${rowNumber}`)
  154. ++summary.invalidRows
  155. continue
  156. }
  157. if (interval !== 'month' && interval !== 'year') {
  158. await log(
  159. `✗ Invalid interval '${interval}' on row ${rowNumber}. Must be either 'month' or 'year'.`
  160. )
  161. ++summary.invalidRows
  162. continue
  163. }
  164. await log()
  165. await log(`--- Processing Plan: ${planCode} ---`)
  166. // 1. Handle product
  167. if (!existingProducts[planCode]) {
  168. const productName =
  169. record.productName ||
  170. planCode
  171. .split(/[_-]/) // Handle underscores or hyphens
  172. .map(
  173. /** @param {any} word */
  174. word => word.charAt(0).toUpperCase() + word.slice(1)
  175. )
  176. .join(' ')
  177. if (commit) {
  178. try {
  179. await stripe.products.create({
  180. id: planCode,
  181. name: productName,
  182. description: productDescription || undefined, // Don't pass an empty string, Stripe thinks we're trying to unset it and doesn't like it
  183. tax_code: 'txcd_10103000', // "Software as a service (SaaS) - personal use", which is what existing products have
  184. metadata: { planCode },
  185. })
  186. await rateLimitSleep()
  187. } catch (err) {
  188. const errorMessage = err instanceof Error ? err.message : String(err)
  189. await log(`✗ Error creating product ${planCode}: ${errorMessage}`)
  190. summary.errors++
  191. continue // Skip prices if product creation failed
  192. }
  193. }
  194. await log(`✓ Created product: ${planCode} ("${productName}")`)
  195. summary.productsCreated++
  196. } else {
  197. await log(`- Product '${planCode}' already exists.`)
  198. }
  199. // 2. Handle Prices for each currency column
  200. for (const currency of currencyKeys) {
  201. const amountValue = parseFloat(/** @type {any} */ (record)[currency])
  202. if (isNaN(amountValue) || amountValue <= 0) continue
  203. const currencyLower = currency.toLowerCase()
  204. // Standardize lookup key format: {plan}_{interval}_{version}_{currency}
  205. const lookupKey = `${planCode}_${interval}_${version}_${currencyLower}`
  206. if (existingPrices[lookupKey]) {
  207. await log(` - Price '${lookupKey}' already exists. Skipping.`)
  208. summary.skipped++
  209. continue
  210. }
  211. /** @type {PriceCreateParams} */
  212. const priceParams = {
  213. product: planCode,
  214. currency: currencyLower,
  215. unit_amount: convertToMinorUnits(amountValue, currencyLower),
  216. recurring: getRecurringFromInterval(interval),
  217. lookup_key: lookupKey,
  218. }
  219. if (commit) {
  220. try {
  221. await stripe.prices.create(priceParams)
  222. await rateLimitSleep()
  223. } catch (err) {
  224. const errorMessage = err instanceof Error ? err.message : String(err)
  225. await log(` ✗ Error creating price ${lookupKey}: ${errorMessage}`)
  226. summary.errors++
  227. continue
  228. }
  229. }
  230. await log(
  231. ` ✓ Created price: ${lookupKey} (${amountValue} ${currencyLower.toUpperCase()})`
  232. )
  233. summary.pricesCreated++
  234. }
  235. }
  236. // Final Summary
  237. await log()
  238. await log('='.repeat(20))
  239. await log()
  240. await log('✨ FINAL SUMMARY ✨')
  241. await log(` ✅ Products created: ${summary.productsCreated}`)
  242. await log(` ✅ Prices created: ${summary.pricesCreated}`)
  243. await log(` ⏭️ Items skipped: ${summary.skipped}`)
  244. await log(` ⏭️ Invalid rows skipped: ${summary.invalidRows}`)
  245. await log(` ❌ Errors encountered: ${summary.errors}`)
  246. if (!commit) {
  247. await log('ℹ️ DRY RUN: No changes were applied to Stripe')
  248. }
  249. await log('🎉 Script completed!')
  250. }
  251. if (import.meta.main) {
  252. try {
  253. await scriptRunner(main)
  254. process.exit(0)
  255. } catch (error) {
  256. console.error(error)
  257. process.exit(1)
  258. }
  259. }