update_prices_from_csv.mjs 17 KB

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