archive_prices_by_version_key.mjs 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. #!/usr/bin/env node
  2. /**
  3. * This script marks as archived all prices with a matching version key in their lookup key
  4. *
  5. * Usage:
  6. * node scripts/stripe/archive_prices_by_version_key.mjs --region us --version versionKey [options]
  7. * node scripts/stripe/archive_prices_by_version_key.mjs --region uk --version versionKey [options]
  8. *
  9. * Options:
  10. * --region Required. Stripe region to process (us or uk)
  11. * --version Required. Version key to match in lookup keys (e.g., 'jul2025')
  12. * --action Required. Action to perform: 'archive' or 'unarchive'
  13. * --commit Actually perform the updates (default: dry-run mode)
  14. *
  15. * Examples:
  16. * # Dry run archive prices with version 'jul2025' in US region
  17. * node scripts/stripe/archive_prices_by_version_key.mjs --region us --version jul2025 --action archive
  18. *
  19. * # Commit archive prices with version 'jul2025' in UK region
  20. * node scripts/stripe/archive_prices_by_version_key.mjs --region uk --version jul2025 --action archive --commit
  21. *
  22. * # Unarchive prices with version 'jul2025'
  23. * node scripts/stripe/archive_prices_by_version_key.mjs --region us --version jul2025 --action unarchive --commit
  24. */
  25. import minimist from 'minimist'
  26. import { z } from '../../app/src/infrastructure/Validation.mjs'
  27. import { scriptRunner } from '../lib/ScriptRunner.mjs'
  28. import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
  29. /**
  30. * @import Stripe from 'stripe'
  31. */
  32. const paramsSchema = z.object({
  33. region: z.enum(['us', 'uk']),
  34. version: z.string(),
  35. action: z.enum(['archive', 'unarchive']),
  36. commit: z.boolean().default(false),
  37. })
  38. /**
  39. * Sleep function to respect Stripe rate limits (100 requests per second)
  40. */
  41. async function rateLimitSleep() {
  42. return new Promise(resolve => setTimeout(resolve, 50))
  43. }
  44. /**
  45. * Check if a price has active subscriptions (if an active subscription has
  46. * archived prices, those customers will run into issues modifying their
  47. * subscriptions)
  48. *
  49. * @param {Stripe} stripe
  50. * @param {string} priceId
  51. * @returns {Promise<boolean>}
  52. */
  53. async function getHasActiveSubscriptions(stripe, priceId) {
  54. const potentiallyActiveStatuses = [
  55. 'active',
  56. 'trialing',
  57. 'past_due',
  58. 'unpaid',
  59. 'paused',
  60. 'incomplete',
  61. ]
  62. let hasMore = true
  63. let startingAfter
  64. while (hasMore) {
  65. const params = {
  66. price: priceId,
  67. limit: 100,
  68. }
  69. if (startingAfter) {
  70. params.starting_after = startingAfter
  71. }
  72. const subscriptions = await stripe.subscriptions.list(params)
  73. await rateLimitSleep()
  74. const hasActiveInBatch = subscriptions.data.some(subscription =>
  75. potentiallyActiveStatuses.includes(subscription.status)
  76. )
  77. if (hasActiveInBatch) {
  78. return true
  79. }
  80. hasMore = subscriptions.has_more
  81. if (hasMore && subscriptions.data.length > 0) {
  82. startingAfter = subscriptions.data[subscriptions.data.length - 1].id
  83. }
  84. }
  85. return false
  86. }
  87. /**
  88. * Fetch all prices matching the version key from Stripe
  89. *
  90. * @param {Stripe} stripe
  91. * @param {string} version
  92. * @param {function} trackProgress
  93. * @returns {Promise<Stripe.Price[]>}
  94. */
  95. async function fetchPricesByVersion(stripe, version, trackProgress) {
  96. const matchingPrices = []
  97. let hasMore = true
  98. let startingAfter
  99. await trackProgress('Fetching prices from Stripe...')
  100. while (hasMore) {
  101. const pricesResult = await stripe.prices.list({
  102. limit: 100,
  103. starting_after: startingAfter,
  104. })
  105. // Filter prices that have the version in their lookup key
  106. const filtered = pricesResult.data.filter(
  107. price => price.lookup_key && price.lookup_key.includes(version)
  108. )
  109. matchingPrices.push(...filtered)
  110. hasMore = pricesResult.has_more
  111. if (hasMore) {
  112. startingAfter = pricesResult.data[pricesResult.data.length - 1].id
  113. }
  114. await rateLimitSleep()
  115. }
  116. await trackProgress(`Found ${matchingPrices.length} matching prices...`)
  117. return matchingPrices
  118. }
  119. /**
  120. * Archive or unarchive prices in Stripe
  121. *
  122. * @param {Stripe.Price[]} prices
  123. * @param {Stripe} stripe
  124. * @param {string} action
  125. * @param {boolean} commit
  126. * @param {function} trackProgress
  127. * @returns {Promise<object>}
  128. */
  129. async function processPrices(prices, stripe, action, commit, trackProgress) {
  130. const targetActiveStatus = action === 'unarchive'
  131. const results = {
  132. processed: 0,
  133. skipped: 0,
  134. hasSubscriptions: 0,
  135. errored: 0,
  136. }
  137. // pre-filter prices already in the desired state to avoid unnecessary API calls
  138. const pricesToProcess = []
  139. for (const price of prices) {
  140. const hasArchivedNickname = price.nickname?.includes('[ARCHIVED]')
  141. const alreadyInDesiredState =
  142. action === 'archive'
  143. ? hasArchivedNickname
  144. : price.active && !hasArchivedNickname
  145. if (alreadyInDesiredState) {
  146. await trackProgress(
  147. `Skipping price ${price.id} (${price.lookup_key}) - already ${price.active ? 'active' : 'archived'}`
  148. )
  149. results.skipped++
  150. } else {
  151. pricesToProcess.push(price)
  152. }
  153. }
  154. if (pricesToProcess.length === 0) {
  155. return results
  156. }
  157. await trackProgress(`Processing ${pricesToProcess.length} prices...`)
  158. for (const price of pricesToProcess) {
  159. try {
  160. const hasActiveSubscriptions =
  161. action === 'archive'
  162. ? await getHasActiveSubscriptions(stripe, price.id)
  163. : false
  164. if (hasActiveSubscriptions) {
  165. results.hasSubscriptions++
  166. }
  167. if (commit) {
  168. const updateParams = {}
  169. if (!hasActiveSubscriptions) {
  170. updateParams.active = targetActiveStatus
  171. }
  172. if (action === 'archive' && !price.nickname?.includes('[ARCHIVED]')) {
  173. updateParams.nickname = price.nickname
  174. ? `[ARCHIVED] ${price.nickname}`
  175. : '[ARCHIVED]'
  176. }
  177. if (action === 'unarchive' && price.nickname?.includes('[ARCHIVED]')) {
  178. updateParams.nickname = price.nickname.replace(/^\[ARCHIVED\]\s*/, '')
  179. }
  180. if (Object.keys(updateParams).length > 0) {
  181. await stripe.prices.update(price.id, updateParams)
  182. const statusNote = hasActiveSubscriptions
  183. ? '(nickname only - has active subscriptions)'
  184. : ''
  185. await trackProgress(
  186. `${action === 'archive' ? 'Archived' : 'Unarchived'} price: ${price.id} (${price.lookup_key}) ${statusNote}`
  187. )
  188. await rateLimitSleep()
  189. }
  190. } else {
  191. const statusNote = hasActiveSubscriptions
  192. ? '(nickname only - has active subscriptions)'
  193. : ''
  194. await trackProgress(
  195. `[DRY RUN] Would ${action} price: ${price.id} (${price.lookup_key}) ${statusNote}`
  196. )
  197. }
  198. results.processed++
  199. } catch (error) {
  200. await trackProgress(
  201. `ERROR processing price ${price.id}: ${error.message}`
  202. )
  203. results.errored++
  204. }
  205. }
  206. return results
  207. }
  208. async function main(trackProgress) {
  209. const parseResult = paramsSchema.safeParse(
  210. minimist(process.argv.slice(2), {
  211. boolean: ['commit'],
  212. string: ['region', 'version', 'action'],
  213. })
  214. )
  215. if (!parseResult.success) {
  216. throw new Error(`Invalid parameters: ${parseResult.error.message}`)
  217. }
  218. const { region, version, action, commit } = parseResult.data
  219. const mode = commit ? 'COMMIT MODE' : 'DRY RUN MODE'
  220. await trackProgress(`Starting ${action} in ${mode} for region: ${region}`)
  221. await trackProgress(`Target version: ${version}`)
  222. const stripe = getRegionClient(region).stripe
  223. const prices = await fetchPricesByVersion(stripe, version, trackProgress)
  224. if (prices.length === 0) {
  225. await trackProgress('No prices found. Exiting.')
  226. return
  227. }
  228. await trackProgress(`Processing ${action} operation...`)
  229. const results = await processPrices(
  230. prices,
  231. stripe,
  232. action,
  233. commit,
  234. trackProgress
  235. )
  236. await trackProgress('OPERATION SUMMARY')
  237. await trackProgress(
  238. `Prices ${commit ? 'processed' : 'would be processed'}: ${results.processed}`
  239. )
  240. await trackProgress(
  241. `Prices skipped (already in desired state): ${results.skipped}`
  242. )
  243. await trackProgress(
  244. `Prices skipped (has active subscriptions): ${results.hasSubscriptions}`
  245. )
  246. await trackProgress(`Prices errored: ${results.errored}`)
  247. if (results.errored > 0) {
  248. await trackProgress(
  249. 'WARNING: Some prices failed to process. Check the logs above.'
  250. )
  251. }
  252. if (!commit) {
  253. await trackProgress(
  254. 'This was a dry run. Use --commit to actually perform the operation.'
  255. )
  256. }
  257. await trackProgress(`Script completed in ${mode}`)
  258. }
  259. try {
  260. await scriptRunner(main)
  261. process.exit(0)
  262. } catch (error) {
  263. console.error('Script failed:', error.message)
  264. process.exit(1)
  265. }