bulk-release-subscription-schedules.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. #!/usr/bin/env node
  2. /**
  3. * This script bulk releases active Stripe subscription schedules that were
  4. * created as part of the billing migration (identified by billing_migration_id
  5. * in schedule metadata).
  6. *
  7. * For each customer in the input CSV, it:
  8. * 1. Lists all subscription schedules for the customer
  9. * 2. Finds the active schedule with billing_migration_id metadata
  10. * 3. Releases that schedule via the Stripe API (with preserve_cancel_date: true)
  11. *
  12. * If the schedule has already been released (or is in a non-active state), it
  13. * is reported as "already-released" in the output.
  14. *
  15. * Usage:
  16. * node scripts/stripe/bulk-release-subscription-schedules.mjs [OPTS] [INPUT-FILE]
  17. *
  18. * Options:
  19. * --output PATH Output file path (default: /tmp/bulk_release_schedules_output_<timestamp>.csv)
  20. * --commit Apply changes (without this, runs in dry-run mode)
  21. * --concurrency N Number of customers to process concurrently (default: 10)
  22. * --stripe-rate-limit N Requests per second for Stripe (default: 50)
  23. * --stripe-api-retries N Number of retries on Stripe 429s (default: 5)
  24. * --stripe-retry-delay-ms N Delay between Stripe retries in ms (default: 1000)
  25. * --help Show help message
  26. *
  27. * CSV Input Format:
  28. * The CSV must have the following columns:
  29. * - stripe_customer_id: Stripe customer id
  30. * - target_stripe_account: Either 'stripe-uk' or 'stripe-us'
  31. *
  32. * CSV Output Format:
  33. * stripe_customer_id,target_stripe_account,schedule_id,status,note
  34. */
  35. import fs from 'node:fs'
  36. import path from 'node:path'
  37. import * as csv from 'csv'
  38. import minimist from 'minimist'
  39. import PQueue from 'p-queue'
  40. import { z } from '../../app/src/infrastructure/Validation.mjs'
  41. import { scriptRunner } from '../lib/ScriptRunner.mjs'
  42. import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
  43. import { ReportError } from './helpers.mjs'
  44. import {
  45. createRateLimitedApiWrappers,
  46. DEFAULT_STRIPE_RATE_LIMIT,
  47. DEFAULT_STRIPE_API_RETRIES,
  48. DEFAULT_STRIPE_RETRY_DELAY_MS,
  49. } from './RateLimiter.mjs'
  50. // rate limiters - initialized in main()
  51. let rateLimiters
  52. function usage() {
  53. console.error(`Usage: node scripts/stripe/bulk-release-subscription-schedules.mjs [OPTS] [INPUT-FILE]
  54. Options:
  55. --output PATH Output file path (default: /tmp/bulk_release_schedules_output_<timestamp>.csv)
  56. --commit Apply changes (without this, runs in dry-run mode)
  57. --concurrency N Number of customers to process concurrently (default: 10)
  58. --stripe-rate-limit N Requests per second for Stripe (default: ${DEFAULT_STRIPE_RATE_LIMIT})
  59. --stripe-api-retries N Number of retries on Stripe 429s (default: ${DEFAULT_STRIPE_API_RETRIES})
  60. --stripe-retry-delay-ms N Delay between Stripe retries in ms (default: ${DEFAULT_STRIPE_RETRY_DELAY_MS})
  61. --help Show this help message
  62. `)
  63. }
  64. async function main(trackProgress) {
  65. const opts = parseArgs()
  66. const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
  67. const outputFile =
  68. opts.output ?? `/tmp/bulk_release_schedules_output_${timestamp}.csv`
  69. rateLimiters = createRateLimitedApiWrappers({
  70. stripeRateLimit: opts.stripeRateLimit,
  71. stripeApiRetries: opts.stripeApiRetries,
  72. stripeRetryDelayMs: opts.stripeRetryDelayMs,
  73. })
  74. await trackProgress('Starting bulk subscription schedule release for Stripe')
  75. await trackProgress(`Run mode: ${opts.commit ? 'COMMIT' : 'DRY RUN'}`)
  76. await trackProgress(`Rate limit: Stripe ${opts.stripeRateLimit}/s`)
  77. await trackProgress(`Concurrency: ${opts.concurrency}`)
  78. const inputStream = opts.inputFile
  79. ? fs.createReadStream(opts.inputFile)
  80. : process.stdin
  81. const csvReader = getCsvReader(inputStream)
  82. const csvWriter = getCsvWriter(outputFile)
  83. await trackProgress(`Output: ${outputFile}`)
  84. let processedCount = 0
  85. let successCount = 0
  86. let errorCount = 0
  87. const queue = new PQueue({ concurrency: opts.concurrency })
  88. const maxQueueSize = opts.concurrency
  89. try {
  90. for await (const input of csvReader) {
  91. if (queue.size >= maxQueueSize) {
  92. await queue.onSizeLessThan(maxQueueSize)
  93. }
  94. queue.add(async () => {
  95. try {
  96. const result = await processScheduleRelease(input, opts.commit)
  97. csvWriter.write({
  98. stripe_customer_id: input.stripe_customer_id,
  99. target_stripe_account: input.target_stripe_account,
  100. schedule_id: result.scheduleId || '',
  101. status: result.status,
  102. note: result.note,
  103. })
  104. if (
  105. result.status === 'released' ||
  106. result.status === 'validated' ||
  107. result.status === 'already-released'
  108. ) {
  109. successCount++
  110. } else {
  111. errorCount++
  112. }
  113. } catch (err) {
  114. errorCount++
  115. csvWriter.write({
  116. stripe_customer_id: input.stripe_customer_id,
  117. target_stripe_account: input.target_stripe_account,
  118. schedule_id: '',
  119. status: err instanceof ReportError ? err.status : 'error',
  120. note: err.message,
  121. })
  122. }
  123. processedCount++
  124. if (processedCount % 25 === 0) {
  125. await trackProgress(
  126. `Progress: ${processedCount} processed, ${successCount} successful, ${errorCount} errors`
  127. )
  128. }
  129. })
  130. }
  131. } finally {
  132. await queue.onIdle()
  133. }
  134. await trackProgress(`✅ Total processed: ${processedCount}`)
  135. if (opts.commit) {
  136. await trackProgress(`✅ Successfully released: ${successCount}`)
  137. } else {
  138. await trackProgress(`✅ Successfully validated: ${successCount}`)
  139. await trackProgress('ℹ️ DRY RUN: No changes were applied')
  140. }
  141. await trackProgress(`❌ Errors: ${errorCount}`)
  142. await trackProgress('🎉 Script completed!')
  143. csvWriter.end()
  144. }
  145. function parseArgs() {
  146. const args = minimist(process.argv.slice(2), {
  147. string: [
  148. 'output',
  149. 'concurrency',
  150. 'stripe-rate-limit',
  151. 'stripe-api-retries',
  152. 'stripe-retry-delay-ms',
  153. ],
  154. boolean: ['commit', 'help'],
  155. default: {
  156. commit: false,
  157. concurrency: 10,
  158. 'stripe-rate-limit': DEFAULT_STRIPE_RATE_LIMIT,
  159. 'stripe-api-retries': DEFAULT_STRIPE_API_RETRIES,
  160. 'stripe-retry-delay-ms': DEFAULT_STRIPE_RETRY_DELAY_MS,
  161. },
  162. })
  163. if (args.help) {
  164. usage()
  165. process.exit(0)
  166. }
  167. const inputFile = args._[0]
  168. const paramsSchema = z.object({
  169. output: z.string().optional(),
  170. commit: z.boolean(),
  171. concurrency: z.number().int().positive(),
  172. stripeRateLimit: z.number().positive(),
  173. stripeApiRetries: z.number().int().nonnegative(),
  174. stripeRetryDelayMs: z.number().int().nonnegative(),
  175. inputFile: z.string().optional(),
  176. })
  177. try {
  178. return paramsSchema.parse({
  179. output: args.output,
  180. commit: args.commit,
  181. concurrency: Number(args.concurrency),
  182. stripeRateLimit: Number(args['stripe-rate-limit']),
  183. stripeApiRetries: Number(args['stripe-api-retries']),
  184. stripeRetryDelayMs: Number(args['stripe-retry-delay-ms']),
  185. inputFile,
  186. })
  187. } catch (err) {
  188. console.error('Invalid arguments:', err.message)
  189. usage()
  190. process.exit(1)
  191. }
  192. }
  193. function getCsvReader(inputStream) {
  194. const parser = csv.parse({ columns: true })
  195. inputStream.pipe(parser)
  196. return parser
  197. }
  198. function getCsvWriter(outputFile) {
  199. fs.mkdirSync(path.dirname(outputFile), { recursive: true })
  200. const outputStream = fs.createWriteStream(outputFile)
  201. const writer = csv.stringify({
  202. columns: [
  203. 'stripe_customer_id',
  204. 'target_stripe_account',
  205. 'schedule_id',
  206. 'status',
  207. 'note',
  208. ],
  209. header: true,
  210. })
  211. writer.on('error', err => {
  212. console.error(err)
  213. process.exit(1)
  214. })
  215. writer.pipe(outputStream)
  216. return writer
  217. }
  218. async function processScheduleRelease(input, commit) {
  219. const {
  220. stripe_customer_id: customerId,
  221. target_stripe_account: targetStripeAccount,
  222. } = input
  223. const region = targetStripeAccount.replace(/^stripe-/, '')
  224. const stripeClient = getRegionClient(region)
  225. let schedules
  226. try {
  227. schedules = await rateLimiters.requestWithRetries(
  228. stripeClient.serviceName,
  229. () =>
  230. stripeClient.stripe.subscriptionSchedules.list({
  231. customer: customerId,
  232. limit: 100,
  233. }),
  234. {
  235. operation: 'subscriptionSchedules.list',
  236. customerId,
  237. region: stripeClient.serviceName,
  238. }
  239. )
  240. } catch (err) {
  241. throw new ReportError(
  242. 'list-schedules-failed',
  243. `Failed to list subscription schedules: ${err.message}`
  244. )
  245. }
  246. const migrationSchedules = schedules.data.filter(
  247. schedule => schedule.metadata?.billing_migration_id != null
  248. )
  249. if (migrationSchedules.length === 0) {
  250. throw new ReportError(
  251. 'no-migration-schedule',
  252. `No subscription schedule with billing_migration_id metadata found for customer ${customerId}`
  253. )
  254. }
  255. const activeSchedules = migrationSchedules.filter(
  256. schedule => schedule.status === 'active'
  257. )
  258. if (activeSchedules.length === 0) {
  259. const statuses = migrationSchedules
  260. .map(s => `${s.id}(${s.status})`)
  261. .join(', ')
  262. return {
  263. status: 'already-released',
  264. note: `No active migration schedules to release (found: ${statuses})`,
  265. scheduleId: '',
  266. }
  267. }
  268. if (activeSchedules.length > 1) {
  269. const scheduleIds = activeSchedules.map(s => s.id).join(', ')
  270. throw new ReportError(
  271. 'multiple-active-schedules',
  272. `Found ${activeSchedules.length} active migration schedules (${scheduleIds}), expected at most 1`
  273. )
  274. }
  275. const targetSchedule = activeSchedules[0]
  276. if (targetSchedule.phases.length !== 1) {
  277. throw new ReportError(
  278. 'multiple-phases',
  279. `Schedule ${targetSchedule.id} has multiple phases, expected exactly 1`
  280. )
  281. }
  282. if (!commit) {
  283. return {
  284. status: 'validated',
  285. note: `Schedule ${targetSchedule.id} can be released`,
  286. scheduleId: targetSchedule.id,
  287. }
  288. }
  289. try {
  290. await rateLimiters.requestWithRetries(
  291. stripeClient.serviceName,
  292. () =>
  293. stripeClient.stripe.subscriptionSchedules.release(targetSchedule.id, {
  294. preserve_cancel_date: true,
  295. }),
  296. {
  297. operation: 'subscriptionSchedules.release',
  298. scheduleId: targetSchedule.id,
  299. region: stripeClient.serviceName,
  300. }
  301. )
  302. return {
  303. status: 'released',
  304. note: `Released schedule ${targetSchedule.id}`,
  305. scheduleId: targetSchedule.id,
  306. }
  307. } catch (err) {
  308. throw new ReportError(
  309. 'release-schedule-failed',
  310. `Failed to release schedule ${targetSchedule.id}: ${err.message}`
  311. )
  312. }
  313. }
  314. try {
  315. await scriptRunner(main)
  316. process.exit(0)
  317. } catch (error) {
  318. console.error(error)
  319. process.exit(1)
  320. }