update_prices_from_csv.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550
  1. #!/usr/bin/env node
  2. /**
  3. * This script creates new price objects in Stripe from a CSV file of prices
  4. *
  5. * Usage:
  6. * node scripts/stripe/update_prices_from_csv.mjs -f fileName --region us --nextVersion versionKey [options]
  7. * node scripts/stripe/update_prices_from_csv.mjs -f fileName --region uk --nextVersion versionKey [options]
  8. *
  9. * Options:
  10. * -f Path to prices CSV file
  11. * --region Required. Stripe region to process (us or uk)
  12. * --nextVersion Next version key (e.g., 'jul2025')
  13. * --commit Actually perform the updates (default: dry-run mode)
  14. *
  15. * Examples:
  16. * # Dry run for US region
  17. * node scripts/stripe/update_prices_from_csv.mjs -f inputFile --region us --nextVersion jul2025
  18. *
  19. * # Commit changes for UK region
  20. * node scripts/stripe/update_prices_from_csv.mjs -f inputFile --region uk --nextVersion jul2025 --commit
  21. */
  22. import minimist from 'minimist'
  23. import fs from 'node:fs'
  24. // https://github.com/import-js/eslint-plugin-import/issues/1810
  25. // eslint-disable-next-line import/no-unresolved
  26. import * as csv from 'csv/sync'
  27. import { z } from '../../app/src/infrastructure/Validation.mjs'
  28. import { scriptRunner } from '../lib/ScriptRunner.mjs'
  29. import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
  30. import PlansLocator from '../../app/src/Features/Subscription/PlansLocator.mjs'
  31. import {
  32. convertFromMinorUnits,
  33. convertToMinorUnits,
  34. rateLimitSleep,
  35. } from './helpers.mjs'
  36. /**
  37. * @import Stripe from 'stripe'
  38. * @import { StripeCurrencyCode } from '../../types/subscription/currency'
  39. */
  40. const paramsSchema = z.object({
  41. f: z.string(),
  42. region: z.enum(['us', 'uk']),
  43. nextVersion: z.string(),
  44. commit: z.boolean().default(false),
  45. })
  46. /**
  47. * @typedef {object} CsvPrice
  48. * @property {number} amountInMinorUnits
  49. * @property {StripeCurrencyCode} currency
  50. */
  51. /**
  52. * Parse CSV file with price data
  53. *
  54. * @param {string} filePath
  55. * @param {string} nextVersion
  56. * @returns {Map<string, CsvPrice>}
  57. */
  58. function loadPricesFromCSV(filePath, nextVersion) {
  59. const content = fs.readFileSync(filePath, 'utf-8')
  60. const records = csv.parse(content, {
  61. columns: true,
  62. })
  63. if (records.length === 0) {
  64. throw new Error('CSV file is empty')
  65. }
  66. const priceMap = new Map()
  67. // Get currency codes from the first record's keys (all columns except plan_code)
  68. const currencies = Object.keys(records[0])
  69. .filter(key => key !== 'plan_code')
  70. .map(c => c.toLowerCase())
  71. // Process each record
  72. for (const record of records) {
  73. const planCode = record.plan_code
  74. // Filter out unwanted plan codes
  75. if (shouldSkipPlanCode(planCode)) {
  76. continue
  77. }
  78. // For each currency column, create lookup keys and store in map
  79. for (const currency of currencies) {
  80. const unitAmount = parseFloat(
  81. record[currency.toUpperCase()] || record[currency]
  82. )
  83. if (!isNaN(unitAmount) && unitAmount > 0) {
  84. const minorUnits = convertToMinorUnits(unitAmount, currency)
  85. const lookupKey = buildLookupKeyForPlan(planCode, currency, nextVersion)
  86. if (lookupKey) {
  87. priceMap.set(lookupKey, {
  88. amountInMinorUnits: minorUnits,
  89. currency,
  90. })
  91. }
  92. }
  93. }
  94. }
  95. return priceMap
  96. }
  97. /**
  98. * Determine if a plan code should be skipped
  99. *
  100. * @param {string} planCode
  101. * @returns {boolean}
  102. */
  103. function shouldSkipPlanCode(planCode) {
  104. if (planCode.includes('trial') || planCode.includes('paid-personal')) {
  105. return true
  106. }
  107. // Skip if matches the specific pattern for non-consolidated group plans
  108. const excludePattern =
  109. /^group_(collaborator|professional)_\d+_(educational|enterprise)$/
  110. if (excludePattern.test(planCode)) {
  111. return true
  112. }
  113. return false
  114. }
  115. /**
  116. * Build the Stripe lookup key for a plan code, handling discounts and special cases
  117. *
  118. * @param {string} planCode
  119. * @param {string} currency
  120. * @param {string} version
  121. * @returns {string | null}
  122. */
  123. function buildLookupKeyForPlan(planCode, currency, version) {
  124. // rm "enterprise" from plan code, if present
  125. const planCodeWithoutEnterprise = planCode.replace('_enterprise', '')
  126. // Check if this plan code has a discount suffix (e.g., _discount_20)
  127. const discountMatch = planCodeWithoutEnterprise.match(/^(.+)_discount_(\d+)$/)
  128. const hasDiscount = discountMatch !== null
  129. const planCodeWithoutDiscount = hasDiscount
  130. ? discountMatch[1]
  131. : planCodeWithoutEnterprise
  132. const discountAmount = hasDiscount ? discountMatch[2] : null
  133. // Special case: Nonprofit group plans
  134. // These are constructed manually without using PlansLocator (these are not available for sale online)
  135. if (planCode.includes('nonprofit')) {
  136. let lookupKey = `${planCodeWithoutDiscount}_${version}_${currency}`
  137. if (discountAmount) {
  138. lookupKey += `_discount_${discountAmount}`
  139. }
  140. return lookupKey
  141. }
  142. // Standard case: Use PlansLocator to build the lookup key
  143. const lookupKey = PlansLocator.buildStripeLookupKey(
  144. planCodeWithoutDiscount,
  145. currency
  146. )
  147. if (!lookupKey) {
  148. return null
  149. }
  150. // Replace the current version with the new version
  151. const lookupKeyWithNewVersion = lookupKey.replace(
  152. PlansLocator.LATEST_STRIPE_LOOKUP_KEY_VERSION,
  153. version
  154. )
  155. // If the plan code had a discount, append it to the lookup key
  156. if (discountAmount) {
  157. return `${lookupKeyWithNewVersion}_discount_${discountAmount}`
  158. }
  159. return lookupKeyWithNewVersion
  160. }
  161. /**
  162. * Copy an existing price and update with pricing data from the CSV, if available
  163. *
  164. * @param {Stripe.Price} existingPrice
  165. * @param {Map<string, CsvPrice>} csvPricesByLookupKey
  166. * @param {string} nextVersion
  167. * @returns {Promise<{ success: boolean, price: Stripe.PriceCreateParams | null, error: string | null }>}
  168. */
  169. function copyPriceAndUpdate(existingPrice, csvPricesByLookupKey, nextVersion) {
  170. try {
  171. const nextLookupKey = existingPrice.lookup_key.replace(
  172. PlansLocator.LATEST_STRIPE_LOOKUP_KEY_VERSION,
  173. nextVersion
  174. )
  175. const csvData = csvPricesByLookupKey.get(nextLookupKey)
  176. const unitAmount = csvData
  177. ? csvData.amountInMinorUnits
  178. : existingPrice.unit_amount
  179. const nextPrice = getPriceParamsFromPriceObject(existingPrice)
  180. nextPrice.unit_amount = unitAmount
  181. nextPrice.lookup_key = nextLookupKey
  182. // TODO: remove this after the June 2025 prices are archived
  183. nextPrice.nickname = nextPrice.nickname.match(/June 2025/)
  184. ? ''
  185. : nextPrice.nickname
  186. return { success: true, price: nextPrice, error: null }
  187. } catch (error) {
  188. return { success: false, price: null, error: error.message }
  189. }
  190. }
  191. /**
  192. * Returns params for cloning a price in Stripe
  193. *
  194. * @param {Stripe.Price} priceData
  195. * @returns {Stripe.PriceCreateParams}
  196. */
  197. function getPriceParamsFromPriceObject(priceData) {
  198. return {
  199. product: priceData.product,
  200. currency: priceData.currency,
  201. unit_amount: Number.parseInt(priceData.unit_amount),
  202. billing_scheme: priceData.billing_scheme,
  203. recurring: {
  204. interval: priceData.recurring.interval,
  205. interval_count: Number.parseInt(priceData.recurring.interval_count),
  206. },
  207. lookup_key: priceData.lookup_key,
  208. active: priceData.active,
  209. metadata: priceData.metadata,
  210. nickname: priceData.nickname,
  211. tax_behavior: priceData.tax_behavior,
  212. }
  213. }
  214. /**
  215. * Fetch all current version prices from Stripe
  216. *
  217. * @param {Stripe} stripe
  218. * @returns {Promise<Stripe.PriceCreateParams[]>}
  219. */
  220. async function fetchCurrentVersionPrices(stripe) {
  221. const currentPrices = []
  222. let hasMore = true
  223. let startingAfter
  224. while (hasMore) {
  225. const pricesResult = await stripe.prices.list({
  226. active: true,
  227. limit: 100,
  228. starting_after: startingAfter,
  229. })
  230. currentPrices.push(...pricesResult.data)
  231. hasMore = pricesResult.has_more
  232. if (hasMore) {
  233. startingAfter = pricesResult.data[pricesResult.data.length - 1].id
  234. }
  235. }
  236. const currentVersionPrices = currentPrices.filter(
  237. price =>
  238. price.lookup_key &&
  239. price.lookup_key.includes(PlansLocator.LATEST_STRIPE_LOOKUP_KEY_VERSION)
  240. )
  241. return currentVersionPrices
  242. }
  243. /**
  244. * Compare CSV lookup keys with Stripe lookup keys and show differences
  245. *
  246. * @param {Map<string, CsvPrice>} csvPricesByLookupKey
  247. * @param {Stripe.Price[]} currentVersionPrices
  248. * @param {string} nextVersion
  249. * @param {function} trackProgress
  250. */
  251. async function compareCsvAndStripeLookupKeys(
  252. csvPricesByLookupKey,
  253. currentVersionPrices,
  254. nextVersion,
  255. trackProgress
  256. ) {
  257. // Get all CSV lookup keys
  258. const csvLookupKeys = new Set(csvPricesByLookupKey.keys())
  259. // Get all Stripe lookup keys (converted to next version)
  260. const stripeLookupKeys = new Set(
  261. currentVersionPrices.map(price =>
  262. price.lookup_key.replace(
  263. PlansLocator.LATEST_STRIPE_LOOKUP_KEY_VERSION,
  264. nextVersion
  265. )
  266. )
  267. )
  268. // Find keys in CSV but not in Stripe
  269. const inCsvNotInStripe = [...csvLookupKeys].filter(
  270. key => !stripeLookupKeys.has(key)
  271. )
  272. if (inCsvNotInStripe.length > 0) {
  273. await trackProgress(
  274. `\n⚠️ ${inCsvNotInStripe.length} lookup key(s) in CSV but NOT in Stripe and will NOT be created:`
  275. )
  276. for (const key of inCsvNotInStripe.sort()) {
  277. await trackProgress(
  278. ` - ${key.replace(nextVersion, PlansLocator.LATEST_STRIPE_LOOKUP_KEY_VERSION)}`
  279. )
  280. }
  281. }
  282. }
  283. /**
  284. * Display a summary of unit amount changes
  285. *
  286. * @param {Stripe.Price[]} currentVersionPrices
  287. * @param {Stripe.PriceCreateParams[]} nextPriceObjects
  288. * @param {string} nextVersion
  289. * @param {function} trackProgress
  290. */
  291. async function showAmountChanges(
  292. currentVersionPrices,
  293. nextPriceObjects,
  294. nextVersion,
  295. trackProgress
  296. ) {
  297. const currentMap = new Map(currentVersionPrices.map(p => [p.lookup_key, p]))
  298. const changeList = []
  299. let changeCount = 0
  300. for (const nextPrice of nextPriceObjects) {
  301. const currentLookupKey = nextPrice.lookup_key.replace(
  302. nextVersion,
  303. PlansLocator.LATEST_STRIPE_LOOKUP_KEY_VERSION
  304. )
  305. const current = currentMap.get(currentLookupKey)
  306. if (current) {
  307. if (current.unit_amount !== nextPrice.unit_amount) {
  308. const oldAmount = convertFromMinorUnits(
  309. current.unit_amount,
  310. current.currency
  311. )
  312. const newAmount = convertFromMinorUnits(
  313. nextPrice.unit_amount,
  314. nextPrice.currency
  315. )
  316. changeList.push(
  317. `${nextPrice.lookup_key}: ${oldAmount} -> ${newAmount} ${nextPrice.currency}`
  318. )
  319. changeCount++
  320. } else {
  321. changeList.push(`${nextPrice.lookup_key}: UNCHANGED`)
  322. }
  323. } else {
  324. changeList.push(`New: ${nextPrice.lookup_key}`)
  325. changeCount++
  326. }
  327. }
  328. if (changeCount === 0) {
  329. await trackProgress('\nNo unit amount changes detected')
  330. } else {
  331. await trackProgress(`\nUnit amount changes (${changeCount} total changes):`)
  332. for (const change of changeList) {
  333. await trackProgress(` ${change}`)
  334. }
  335. }
  336. }
  337. /**
  338. * Create prices in Stripe
  339. *
  340. * @param {Stripe.PriceCreateParams[]} pricesToCreate
  341. * @param {Stripe} stripe
  342. * @param {function} trackProgress
  343. * @returns {Promise<Stripe.Price[]>}
  344. */
  345. async function createPricesInStripe(pricesToCreate, stripe, trackProgress) {
  346. const createdPrices = []
  347. let errorCount = 0
  348. for (const priceObj of pricesToCreate) {
  349. const amountDisplay = convertFromMinorUnits(
  350. priceObj.unit_amount,
  351. priceObj.currency
  352. )
  353. try {
  354. const created = await stripe.prices.create(priceObj)
  355. await trackProgress(
  356. `✓ Created: ${priceObj.lookup_key} (${amountDisplay} ${priceObj.currency}) -> ${created.id}`
  357. )
  358. createdPrices.push(created)
  359. await rateLimitSleep()
  360. } catch (error) {
  361. await trackProgress(
  362. `✗ Error creating ${priceObj.lookup_key}: ${error.message}`
  363. )
  364. errorCount++
  365. }
  366. }
  367. return { createdPrices, errorCount }
  368. }
  369. async function main(trackProgress) {
  370. const parseResult = paramsSchema.safeParse(
  371. minimist(process.argv.slice(2), {
  372. boolean: ['commit'],
  373. string: ['region', 'f', 'nextVersion'],
  374. })
  375. )
  376. if (!parseResult.success) {
  377. throw new Error(`Invalid parameters: ${parseResult.error.message}`)
  378. }
  379. const { f: inputFile, region, nextVersion, commit } = parseResult.data
  380. const mode = commit ? 'COMMIT MODE' : 'DRY RUN MODE'
  381. await trackProgress(`Starting script in ${mode} for region: ${region}`)
  382. await trackProgress(
  383. `Current version: ${PlansLocator.LATEST_STRIPE_LOOKUP_KEY_VERSION}`
  384. )
  385. await trackProgress(`Next version: ${nextVersion}`)
  386. await trackProgress(`\nLoading prices from: ${inputFile}`)
  387. const csvPricesByLookupKey = loadPricesFromCSV(inputFile, nextVersion)
  388. await trackProgress(
  389. `Loaded ${csvPricesByLookupKey.size} price entries from CSV`
  390. )
  391. const stripe = getRegionClient(region).stripe
  392. await trackProgress('\nFetching existing prices from Stripe...')
  393. const currentVersionPrices = await fetchCurrentVersionPrices(stripe)
  394. await trackProgress(
  395. `Found ${currentVersionPrices.length} prices with version ${PlansLocator.LATEST_STRIPE_LOOKUP_KEY_VERSION}`
  396. )
  397. await trackProgress('\nProcessing prices...')
  398. const nextPriceObjects = []
  399. let buildPricesErrorCount = 0
  400. for (const existingPrice of currentVersionPrices) {
  401. const result = copyPriceAndUpdate(
  402. existingPrice,
  403. csvPricesByLookupKey,
  404. nextVersion
  405. )
  406. if (result.success) {
  407. nextPriceObjects.push(result.price)
  408. } else {
  409. buildPricesErrorCount++
  410. if (result.error) {
  411. await trackProgress(
  412. `Error cloning ${existingPrice.lookup_key}: ${result.error}`
  413. )
  414. }
  415. }
  416. }
  417. await trackProgress(`Built ${nextPriceObjects.length} price objects`)
  418. await compareCsvAndStripeLookupKeys(
  419. csvPricesByLookupKey,
  420. currentVersionPrices,
  421. nextVersion,
  422. trackProgress
  423. )
  424. let createdPrices = []
  425. let commitPricesErrorCount = 0
  426. if (commit) {
  427. await trackProgress('Creating prices in Stripe...')
  428. const createResult = await createPricesInStripe(
  429. nextPriceObjects,
  430. stripe,
  431. trackProgress
  432. )
  433. createdPrices = createResult.createdPrices
  434. commitPricesErrorCount += createResult.errorCount
  435. } else {
  436. await showAmountChanges(
  437. currentVersionPrices,
  438. nextPriceObjects,
  439. nextVersion,
  440. trackProgress
  441. )
  442. }
  443. await trackProgress('\nFINAL SUMMARY')
  444. await trackProgress(
  445. `Prices ${commit ? 'created' : 'would be created'}: ${nextPriceObjects.length}`
  446. )
  447. if (buildPricesErrorCount > 0) {
  448. await trackProgress(
  449. `⚠️ Errors encountered while building price objects: ${buildPricesErrorCount}`
  450. )
  451. }
  452. if (commit) {
  453. if (commitPricesErrorCount > 0) {
  454. await trackProgress(
  455. `⚠️ Errors encountered while creating prices in Stripe: ${commitPricesErrorCount}`
  456. )
  457. }
  458. const lookupKeysString =
  459. createdPrices.map(price => price.lookup_key).join(', ') || 'n/a'
  460. await trackProgress(`Created Price Lookup Keys: ${lookupKeysString}`)
  461. } else {
  462. await trackProgress(
  463. '💡 This was a DRY RUN. To actually create the prices, run with --commit'
  464. )
  465. }
  466. if (commit) {
  467. await trackProgress('NEXT STEPS:')
  468. await trackProgress(
  469. `1. Update LATEST_STRIPE_LOOKUP_KEY_VERSION in PlansLocator.mjs to: '${nextVersion}'`
  470. )
  471. await trackProgress('2. Deploy the updated code to production')
  472. await trackProgress(
  473. '3. Archive the old prices in Stripe (set active: false)'
  474. )
  475. }
  476. await trackProgress(`Script completed successfully in ${mode}`)
  477. }
  478. try {
  479. await scriptRunner(main)
  480. process.exit(0)
  481. } catch (error) {
  482. console.error('Script failed:', error.message)
  483. process.exit(1)
  484. }