bulk-cancel-subscription-schedules.mjs 10 KB

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