create_custom_prices_from_csv.mjs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. // @ts-check
  2. /**
  3. * This script creates custom Prices in Stripe from a CSV file.
  4. * It does not create products; each row must include an existing Stripe productId.
  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. * --commit Apply changes to Stripe (default is dry-run).
  14. *
  15. * CSV Format:
  16. * planCode,productId,productName,priceDescription,interval,USD,GBP,EUR
  17. * essentials,prod_123,Essentials Monthly,"Historical custom price",month,21,17,19
  18. * essentials-annual,prod_456,Essentials Annual,"Historical custom price",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} productId - Optional expected Stripe Product ID to validate against
  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. commit: z.boolean().default(false),
  49. })
  50. /**
  51. * Normalize annual cadence to month+12 so all annual prices share the same
  52. * recurring shape for downstream import/migration tooling.
  53. *
  54. * @param {'month' | 'year'} interval
  55. * @returns {{ interval: 'month', interval_count?: 1 | 12 }}
  56. */
  57. function getRecurringFromInterval(interval) {
  58. if (interval === 'year') {
  59. return { interval: 'month', interval_count: 12 }
  60. }
  61. return { interval: 'month', interval_count: 1 }
  62. }
  63. /**
  64. * @param {import('stripe').Stripe} stripe
  65. * @returns {Promise<Record<string, Price>>}
  66. */
  67. async function getExistingPrices(stripe) {
  68. /** @type {Record<string, Price>} */
  69. const pricesByLookupKey = {}
  70. let startingAfter
  71. do {
  72. /** @type {any} */
  73. const response = await stripe.prices.list({
  74. limit: 100,
  75. starting_after: startingAfter,
  76. })
  77. for (const price of response.data) {
  78. if (price.lookup_key) {
  79. pricesByLookupKey[price.lookup_key] = price
  80. }
  81. }
  82. startingAfter = response.has_more
  83. ? response.data[response.data.length - 1].id
  84. : undefined
  85. } while (startingAfter)
  86. return pricesByLookupKey
  87. }
  88. /**
  89. * @param {import('stripe').Stripe} stripe
  90. * @return {Promise<Record<string, Product>>}
  91. */
  92. async function getExistingProducts(stripe) {
  93. /** @type {Record<string, Product>} */
  94. const productsById = {}
  95. let startingAfter
  96. do {
  97. /** @type {any} */
  98. const response = await stripe.products.list({
  99. limit: 100,
  100. starting_after: startingAfter,
  101. })
  102. for (const product of response.data) {
  103. productsById[product.metadata.planCode] = product
  104. }
  105. startingAfter = response.has_more
  106. ? response.data[response.data.length - 1].id
  107. : undefined
  108. } while (startingAfter)
  109. return productsById
  110. }
  111. /**
  112. * @param {any} trackProgress
  113. */
  114. export async function main(trackProgress) {
  115. const args = minimist(process.argv.slice(2), {
  116. boolean: ['commit'],
  117. string: ['region', 'f', 'version'],
  118. })
  119. const parseResult = paramsSchema.safeParse(args)
  120. if (!parseResult.success) {
  121. throw new Error(`Invalid parameters: ${parseResult.error.message}`)
  122. }
  123. const { f: inputFile, region, version, commit } = parseResult.data
  124. const mode = commit ? 'COMMIT MODE' : 'DRY RUN MODE'
  125. const log = (message = '') =>
  126. trackProgress(mode === 'DRY RUN MODE' ? `[DRY RUN] ${message}` : message)
  127. await log(`Starting creation script in ${mode} for region: ${region}`)
  128. const stripe = getRegionClient(region).stripe
  129. // Load and Parse CSV
  130. const content = fs.readFileSync(inputFile, 'utf-8')
  131. /** @type {PriceRecord[]} */
  132. const records = csv.parse(content, { columns: true, skip_empty_lines: true })
  133. if (records.length === 0) {
  134. throw new Error('CSV file is empty or invalid.')
  135. }
  136. // Identify currency columns (everything except the known non-currency columns)
  137. const nonCurrencyKeys = new Set([
  138. 'planCode',
  139. 'productId',
  140. 'productName',
  141. 'priceDescription',
  142. 'interval',
  143. ])
  144. const currencyKeys = Object.keys(records[0]).filter(
  145. k => !nonCurrencyKeys.has(k)
  146. )
  147. // Cache existing data to minimize API calls and prevent duplicates
  148. await log('Fetching existing Stripe data...')
  149. const existingPrices = await getExistingPrices(stripe)
  150. const existingProducts = await getExistingProducts(stripe)
  151. /** @type {Record<string, Product>} */
  152. const existingProductsById = {}
  153. for (const product of Object.values(existingProducts)) {
  154. existingProductsById[product.id] = product
  155. }
  156. const summary = {
  157. productsCreated: 0,
  158. pricesCreated: 0,
  159. skipped: 0,
  160. invalidRows: 0,
  161. errors: 0,
  162. }
  163. let rowNumber = 0 // For logging purposes, starting after header
  164. for (const /** @type {PriceRecord} */ record of records) {
  165. ++rowNumber
  166. const { planCode, productId, priceDescription, interval } = record
  167. const expectedProductId = String(productId || '').trim()
  168. if (!planCode) {
  169. await log(`✗ No plan code in row ${rowNumber}`)
  170. ++summary.invalidRows
  171. continue
  172. }
  173. if (interval !== 'month' && interval !== 'year') {
  174. await log(
  175. `✗ Invalid interval '${interval}' on row ${rowNumber}. Must be either 'month' or 'year'.`
  176. )
  177. ++summary.invalidRows
  178. continue
  179. }
  180. // If productId is provided, treat it as an assertion that the product already
  181. // exists and matches the planCode mapping. Skip the row before product-create.
  182. if (expectedProductId) {
  183. const existingProduct = existingProducts[planCode]
  184. if (!existingProduct) {
  185. await log(
  186. `✗ CSV productId '${expectedProductId}' provided for plan '${planCode}', but no existing product was found for that planCode. Skipping row.`
  187. )
  188. summary.errors++
  189. continue
  190. }
  191. if (existingProduct.id !== expectedProductId) {
  192. await log(
  193. `✗ CSV productId '${expectedProductId}' does not match existing product id '${existingProduct.id}' for plan '${planCode}'. Skipping row.`
  194. )
  195. summary.errors++
  196. continue
  197. }
  198. }
  199. await log()
  200. await log(`--- Processing Plan: ${planCode} ---`)
  201. // 1. Validate required existing productId from CSV.
  202. const productIdForPrice = String(productId || '').trim()
  203. if (!productIdForPrice) {
  204. await log(`✗ No productId in row ${rowNumber}. Skipping row.`)
  205. summary.invalidRows++
  206. continue
  207. }
  208. if (!existingProductsById[productIdForPrice]) {
  209. await log(
  210. `✗ Product '${productIdForPrice}' from CSV row ${rowNumber} was not found in Stripe. Skipping row.`
  211. )
  212. summary.errors++
  213. continue
  214. }
  215. if (
  216. existingProducts[planCode]?.id &&
  217. existingProducts[planCode].id !== productIdForPrice
  218. ) {
  219. await log(
  220. ` ✗ productId mismatch for plan '${planCode}': CSV has '${productIdForPrice}', planCode resolves to '${existingProducts[planCode].id}'. Skipping row.`
  221. )
  222. summary.errors++
  223. continue
  224. }
  225. // 2. Handle Prices for each currency column
  226. for (const currency of currencyKeys) {
  227. const amountValue = parseFloat(/** @type {any} */ (record)[currency])
  228. if (isNaN(amountValue) || amountValue <= 0) continue
  229. const currencyLower = currency.toLowerCase()
  230. const unitAmount = convertToMinorUnits(amountValue, currencyLower)
  231. const lookupKeyInterval = interval === 'month' ? 'monthly' : 'annual'
  232. // For custom prices, lookup keys always include the minor-unit amount.
  233. const lookupKeyBase = `${planCode}_${lookupKeyInterval}_${version}_${currencyLower}`
  234. const lookupKey = `${lookupKeyBase}_${unitAmount}`
  235. if (existingPrices[lookupKey]) {
  236. await log(` - Price '${lookupKey}' already exists. Skipping.`)
  237. summary.skipped++
  238. continue
  239. }
  240. /** @type {PriceCreateParams} */
  241. const priceParams = {
  242. product: productIdForPrice,
  243. currency: currencyLower,
  244. unit_amount: unitAmount,
  245. recurring: getRecurringFromInterval(interval),
  246. lookup_key: lookupKey,
  247. nickname: priceDescription || undefined,
  248. }
  249. if (commit) {
  250. try {
  251. await stripe.prices.create(priceParams)
  252. await rateLimitSleep()
  253. } catch (err) {
  254. const errorMessage = err instanceof Error ? err.message : String(err)
  255. await log(` ✗ Error creating price ${lookupKey}: ${errorMessage}`)
  256. summary.errors++
  257. continue
  258. }
  259. }
  260. // Keep in-memory cache in sync so duplicates in the same run are skipped.
  261. existingPrices[lookupKey] = /** @type {any} */ ({
  262. lookup_key: lookupKey,
  263. })
  264. await log(
  265. ` ✓ Created price: ${lookupKey} (${amountValue} ${currencyLower.toUpperCase()})`
  266. )
  267. summary.pricesCreated++
  268. }
  269. }
  270. // Final Summary
  271. await log()
  272. await log('='.repeat(20))
  273. await log()
  274. await log('✨ FINAL SUMMARY ✨')
  275. await log(` ✅ Products created: ${summary.productsCreated}`)
  276. await log(` ✅ Prices created: ${summary.pricesCreated}`)
  277. await log(` ⏭️ Items skipped: ${summary.skipped}`)
  278. await log(` ⏭️ Invalid rows skipped: ${summary.invalidRows}`)
  279. await log(` ❌ Errors encountered: ${summary.errors}`)
  280. if (!commit) {
  281. await log('ℹ️ DRY RUN: No changes were applied to Stripe')
  282. }
  283. await log('🎉 Script completed!')
  284. }
  285. if (import.meta.main) {
  286. try {
  287. await scriptRunner(main)
  288. process.exit(0)
  289. } catch (error) {
  290. console.error(error)
  291. process.exit(1)
  292. }
  293. }