bulk-cancel-subscriptions.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. #!/usr/bin/env node
  2. /**
  3. * This script bulk cancels active Stripe subscriptions immediately without proration.
  4. *
  5. * NOTE: this will email customers to inform them of the cancellation unless you turn off
  6. * the cancellation automation in Stripe beforehand: https://dashboard.stripe.com/<account>/revenue-recovery/automations
  7. *
  8. * ⚠️ WARNING: For customers with PayPal billing agreements, do NOT extend this script to
  9. * delete the Stripe customer (customers.del) or detach payment methods
  10. * (paymentMethods.detach). Doing so will permanently destroy the PayPal billing
  11. * agreement, which cannot be recovered without asking the customer to re-authorize.
  12. * Cancelling a subscription is safe — it does not affect the payment method.
  13. *
  14. * Usage:
  15. * node scripts/stripe/bulk-cancel-subscriptions.mjs [OPTS] [INPUT-FILE]
  16. *
  17. * Options:
  18. * --output PATH Output file path (default: /tmp/bulk_cancel_output_<timestamp>.csv)
  19. * Use '-' to write to stdout
  20. * --commit Apply changes (without this flag, 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 a 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. * Output:
  33. * Writes a CSV with columns:
  34. * - stripe_customer_id: The customer id processed
  35. * - target_stripe_account: The Stripe account
  36. * - subscription_id: The subscription id that was cancelled (if found)
  37. * - status: Result status (cancelled, validated, no-subscription, already-cancelled, or error)
  38. * - note: Additional information about the status
  39. */
  40. import fs from 'node:fs'
  41. import path from 'node:path'
  42. import * as csv from 'csv'
  43. import minimist from 'minimist'
  44. import PQueue from 'p-queue'
  45. import { z } from '../../app/src/infrastructure/Validation.mjs'
  46. import { scriptRunner } from '../lib/ScriptRunner.mjs'
  47. import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
  48. import { ReportError } from './helpers.mjs'
  49. import {
  50. createRateLimitedApiWrappers,
  51. DEFAULT_STRIPE_RATE_LIMIT,
  52. DEFAULT_STRIPE_API_RETRIES,
  53. DEFAULT_STRIPE_RETRY_DELAY_MS,
  54. } from './RateLimiter.mjs'
  55. const DEFAULT_CONCURRENCY = 10
  56. // rate limiters - initialized in main()
  57. let rateLimiters
  58. function usage() {
  59. console.error(`Usage: node scripts/stripe/bulk-cancel-subscriptions.mjs [OPTS] [INPUT-FILE]
  60. Options:
  61. --output PATH Output file path (default: /tmp/bulk_cancel_output_<timestamp>.csv)
  62. Use '-' to write to stdout
  63. --commit Apply changes (without this, runs in dry-run mode)
  64. --concurrency N Number of customers to process concurrently (default: ${DEFAULT_CONCURRENCY})
  65. --stripe-rate-limit N Requests per second for Stripe (default: ${DEFAULT_STRIPE_RATE_LIMIT})
  66. --stripe-api-retries N Number of retries on Stripe 429s (default: ${DEFAULT_STRIPE_API_RETRIES})
  67. --stripe-retry-delay-ms N Delay between Stripe retries in ms (default: ${DEFAULT_STRIPE_RETRY_DELAY_MS})
  68. --help Show this help message
  69. `)
  70. }
  71. async function main(trackProgress) {
  72. const opts = parseArgs()
  73. const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
  74. const outputFile = opts.output ?? `/tmp/bulk_cancel_output_${timestamp}.csv`
  75. // initialize rate limiters
  76. rateLimiters = createRateLimitedApiWrappers({
  77. stripeRateLimit: opts.stripeRateLimit,
  78. stripeApiRetries: opts.stripeApiRetries,
  79. stripeRetryDelayMs: opts.stripeRetryDelayMs,
  80. })
  81. await trackProgress('Starting bulk subscription cancellation for Stripe')
  82. await trackProgress(`Run mode: ${opts.commit ? 'COMMIT' : 'DRY RUN'}`)
  83. await trackProgress(`Rate limit: Stripe ${opts.stripeRateLimit}/s`)
  84. await trackProgress(`Concurrency: ${opts.concurrency}`)
  85. const inputStream = opts.inputFile
  86. ? fs.createReadStream(opts.inputFile)
  87. : process.stdin
  88. const csvReader = getCsvReader(inputStream)
  89. const csvWriter = getCsvWriter(outputFile)
  90. await trackProgress(`Output: ${outputFile === '-' ? 'stdout' : outputFile}`)
  91. let processedCount = 0
  92. let successCount = 0
  93. let errorCount = 0
  94. const queue = new PQueue({ concurrency: opts.concurrency })
  95. const maxQueueSize = opts.concurrency
  96. try {
  97. for await (const input of csvReader) {
  98. if (queue.size >= maxQueueSize) {
  99. await queue.onSizeLessThan(maxQueueSize)
  100. }
  101. queue.add(async () => {
  102. try {
  103. const result = await processCancellation(input, opts.commit)
  104. csvWriter.write({
  105. stripe_customer_id: input.stripe_customer_id,
  106. target_stripe_account: input.target_stripe_account,
  107. subscription_id: result.subscriptionId || '',
  108. status: result.status,
  109. note:
  110. result.note ||
  111. (opts.commit ? '' : 'dry run - no changes applied'),
  112. })
  113. if (result.status === 'cancelled' || result.status === 'validated') {
  114. successCount++
  115. } else {
  116. errorCount++
  117. }
  118. } catch (err) {
  119. errorCount++
  120. if (err instanceof ReportError) {
  121. csvWriter.write({
  122. stripe_customer_id: input.stripe_customer_id,
  123. target_stripe_account: input.target_stripe_account,
  124. subscription_id: '',
  125. status: err.status,
  126. note: err.message,
  127. })
  128. } else {
  129. csvWriter.write({
  130. stripe_customer_id: input.stripe_customer_id,
  131. target_stripe_account: input.target_stripe_account,
  132. subscription_id: '',
  133. status: 'error',
  134. note: err.message,
  135. })
  136. await trackProgress(
  137. `Error processing ${input.stripe_customer_id}: ${err.message}`
  138. )
  139. }
  140. }
  141. processedCount++
  142. if (processedCount % 10 === 0) {
  143. await trackProgress(
  144. `Processed ${processedCount} customers (${successCount} ${opts.commit ? 'cancelled' : 'validated'}, ${errorCount} errors)`
  145. )
  146. }
  147. })
  148. }
  149. } finally {
  150. await queue.onIdle()
  151. }
  152. await trackProgress(`✅ Total processed: ${processedCount}`)
  153. if (opts.commit) {
  154. await trackProgress(`✅ Successfully cancelled: ${successCount}`)
  155. } else {
  156. await trackProgress(`✅ Successfully validated: ${successCount}`)
  157. await trackProgress('ℹ️ DRY RUN: No changes were applied')
  158. }
  159. await trackProgress(`❌ Errors: ${errorCount}`)
  160. await trackProgress('🎉 Script completed!')
  161. csvWriter.end()
  162. }
  163. function parseArgs() {
  164. const args = minimist(process.argv.slice(2), {
  165. string: [
  166. 'output',
  167. 'concurrency',
  168. 'stripe-rate-limit',
  169. 'stripe-api-retries',
  170. 'stripe-retry-delay-ms',
  171. ],
  172. boolean: ['commit', 'help'],
  173. default: {
  174. commit: false,
  175. concurrency: DEFAULT_CONCURRENCY,
  176. 'stripe-rate-limit': DEFAULT_STRIPE_RATE_LIMIT,
  177. 'stripe-api-retries': DEFAULT_STRIPE_API_RETRIES,
  178. 'stripe-retry-delay-ms': DEFAULT_STRIPE_RETRY_DELAY_MS,
  179. },
  180. unknown: arg => {
  181. if (arg.startsWith('-')) {
  182. console.error(`Unknown option: ${arg}`)
  183. usage()
  184. process.exit(1)
  185. }
  186. return true
  187. },
  188. })
  189. if (args.help) {
  190. usage()
  191. process.exit(0)
  192. }
  193. const inputFile = args._[0]
  194. const paramsSchema = z.object({
  195. output: z.string().optional(),
  196. commit: z.boolean(),
  197. concurrency: z.number().int().positive(),
  198. stripeRateLimit: z.number().positive(),
  199. stripeApiRetries: z.number().int().nonnegative(),
  200. stripeRetryDelayMs: z.number().int().nonnegative(),
  201. inputFile: z.string().optional(),
  202. })
  203. try {
  204. return paramsSchema.parse({
  205. output: args.output,
  206. commit: args.commit,
  207. concurrency: Number(args.concurrency),
  208. stripeRateLimit: Number(args['stripe-rate-limit']),
  209. stripeApiRetries: Number(args['stripe-api-retries']),
  210. stripeRetryDelayMs: Number(args['stripe-retry-delay-ms']),
  211. inputFile,
  212. })
  213. } catch (err) {
  214. console.error('Invalid arguments:', err.message)
  215. usage()
  216. process.exit(1)
  217. }
  218. }
  219. function getCsvReader(inputStream) {
  220. const parser = csv.parse({ columns: true })
  221. inputStream.pipe(parser)
  222. return parser
  223. }
  224. function getCsvWriter(outputFile) {
  225. if (outputFile === '-') {
  226. const writer = csv.stringify({
  227. columns: [
  228. 'stripe_customer_id',
  229. 'target_stripe_account',
  230. 'subscription_id',
  231. 'status',
  232. 'note',
  233. ],
  234. header: true,
  235. })
  236. writer.on('error', err => {
  237. console.error(err)
  238. process.exit(1)
  239. })
  240. writer.pipe(process.stdout)
  241. return writer
  242. }
  243. fs.mkdirSync(path.dirname(outputFile), { recursive: true })
  244. const outputStream = fs.createWriteStream(outputFile)
  245. const writer = csv.stringify({
  246. columns: [
  247. 'stripe_customer_id',
  248. 'target_stripe_account',
  249. 'subscription_id',
  250. 'status',
  251. 'note',
  252. ],
  253. header: true,
  254. })
  255. writer.on('error', err => {
  256. console.error(err)
  257. process.exit(1)
  258. })
  259. writer.pipe(outputStream)
  260. return writer
  261. }
  262. async function processCancellation(input, commit) {
  263. const {
  264. stripe_customer_id: customerId,
  265. target_stripe_account: targetStripeAccount,
  266. } = input
  267. // get Stripe client for the target account (strip 'stripe-' prefix if present)
  268. const region = targetStripeAccount.replace(/^stripe-/, '')
  269. const stripeClient = getRegionClient(region)
  270. // fetch customer with subscriptions
  271. let customer
  272. try {
  273. customer = await rateLimiters.requestWithRetries(
  274. stripeClient.serviceName,
  275. () => stripeClient.getCustomerById(customerId, ['subscriptions']),
  276. {
  277. operation: 'getCustomerById',
  278. customerId,
  279. region: stripeClient.serviceName,
  280. }
  281. )
  282. } catch (err) {
  283. throw new ReportError(
  284. 'customer-not-found',
  285. `Customer not found: ${err.message}`
  286. )
  287. }
  288. // check for active subscriptions
  289. if (!customer.subscriptions || customer.subscriptions.data.length === 0) {
  290. throw new ReportError('no-subscriptions', 'Customer has no subscriptions')
  291. }
  292. // find the subscription with migration metadata
  293. const migrationSubscription = customer.subscriptions.data.find(
  294. sub => sub.metadata?.recurly_to_stripe_migration_status === 'in_progress'
  295. )
  296. if (!migrationSubscription) {
  297. throw new ReportError(
  298. 'no-migration-subscription',
  299. 'Could not find a subscription with migration metadata to cancel'
  300. )
  301. }
  302. // in dry-run mode, just validate
  303. if (!commit) {
  304. return {
  305. status: 'validated',
  306. note: 'Subscription can be cancelled',
  307. subscriptionId: migrationSubscription.id,
  308. }
  309. }
  310. // cancel the subscription immediately
  311. try {
  312. await rateLimiters.requestWithRetries(
  313. stripeClient.serviceName,
  314. () => stripeClient.terminateSubscription(migrationSubscription.id),
  315. {
  316. operation: 'terminateSubscription',
  317. subscriptionId: migrationSubscription.id,
  318. region: stripeClient.serviceName,
  319. }
  320. )
  321. await rateLimiters.requestWithRetries(
  322. stripeClient.serviceName,
  323. () =>
  324. stripeClient.updateSubscriptionMetadata(migrationSubscription.id, {
  325. recurly_to_stripe_migration_status: 'cancelled',
  326. }),
  327. {
  328. operation: 'updateSubscriptionMetadata',
  329. subscriptionId: migrationSubscription.id,
  330. region: stripeClient.serviceName,
  331. }
  332. )
  333. return {
  334. status: 'cancelled',
  335. note: `Cancelled subscription ${migrationSubscription.id}`,
  336. subscriptionId: migrationSubscription.id,
  337. }
  338. } catch (err) {
  339. throw new ReportError(
  340. 'cancellation-failed',
  341. `Failed to cancel subscription: ${err.message}`
  342. )
  343. }
  344. }
  345. try {
  346. await scriptRunner(main)
  347. process.exit(0)
  348. } catch (error) {
  349. console.error(error)
  350. process.exit(1)
  351. }