archive_prices_by_version_key.mjs 9.5 KB

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