bulk-cancel-subscriptions.mjs 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  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. * Usage:
  9. * node scripts/stripe/bulk-cancel-subscriptions.mjs [OPTS] [INPUT-FILE]
  10. *
  11. * Options:
  12. * --output PATH Output file path (default: /tmp/bulk_cancel_output_<timestamp>.csv)
  13. * Use '-' to write to stdout
  14. * --commit Apply changes (without this flag, runs in dry-run mode)
  15. * --throttle DURATION Minimum time (in ms) between subscriptions processed (default: 100)
  16. * --help Show a help message
  17. *
  18. * CSV Input Format:
  19. * The CSV must have the following columns:
  20. * - stripe_customer_id: Stripe customer id
  21. * - target_stripe_account: Either 'stripe-uk' or 'stripe-us'
  22. *
  23. * Output:
  24. * Writes a CSV with columns:
  25. * - stripe_customer_id: The customer id processed
  26. * - target_stripe_account: The Stripe account
  27. * - subscription_id: The subscription id that was cancelled (if found)
  28. * - status: Result status (cancelled, validated, no-subscription, already-cancelled, or error)
  29. * - note: Additional information about the status
  30. */
  31. import fs from 'node:fs'
  32. import path from 'node:path'
  33. import { setTimeout } from 'node:timers/promises'
  34. import * as csv from 'csv'
  35. import minimist from 'minimist'
  36. import { scriptRunner } from '../lib/ScriptRunner.mjs'
  37. import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
  38. import { ReportError } from './helpers.mjs'
  39. const DEFAULT_THROTTLE = 40
  40. function usage() {
  41. console.error(`Usage: node scripts/stripe/bulk-cancel-subscriptions.mjs [OPTS] [INPUT-FILE]
  42. Options:
  43. --output PATH Output file path (default: /tmp/bulk_cancel_output_<timestamp>.csv)
  44. Use '-' to write to stdout
  45. --commit Apply changes (without this, runs in dry-run mode)
  46. --throttle DURATION Minimum time between requests in ms (default: ${DEFAULT_THROTTLE})
  47. --help Show this help message
  48. `)
  49. }
  50. async function main(trackProgress) {
  51. const opts = parseArgs()
  52. const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
  53. const outputFile = opts.output ?? `/tmp/bulk_cancel_output_${timestamp}.csv`
  54. await trackProgress('Starting bulk subscription cancellation for Stripe')
  55. await trackProgress(`Run mode: ${opts.commit ? 'COMMIT' : 'DRY RUN'}`)
  56. await trackProgress(`Throttle: ${opts.throttle}ms between requests`)
  57. const inputStream = opts.inputFile
  58. ? fs.createReadStream(opts.inputFile)
  59. : process.stdin
  60. const csvReader = getCsvReader(inputStream)
  61. const csvWriter = getCsvWriter(outputFile)
  62. await trackProgress(`Output: ${outputFile === '-' ? 'stdout' : outputFile}`)
  63. let processedCount = 0
  64. let successCount = 0
  65. let errorCount = 0
  66. let lastLoopTimestamp = 0
  67. for await (const input of csvReader) {
  68. const timeSinceLastLoop = Date.now() - lastLoopTimestamp
  69. if (timeSinceLastLoop < opts.throttle) {
  70. await setTimeout(opts.throttle - timeSinceLastLoop)
  71. }
  72. lastLoopTimestamp = Date.now()
  73. processedCount++
  74. try {
  75. const result = await processCancellation(input, opts.commit)
  76. csvWriter.write({
  77. stripe_customer_id: input.stripe_customer_id,
  78. target_stripe_account: input.target_stripe_account,
  79. subscription_id: result.subscriptionId || '',
  80. status: result.status,
  81. note:
  82. result.note || (opts.commit ? '' : 'dry run - no changes applied'),
  83. })
  84. if (result.status === 'cancelled' || result.status === 'validated') {
  85. successCount++
  86. } else {
  87. errorCount++
  88. }
  89. if (processedCount % 10 === 0) {
  90. await trackProgress(
  91. `Processed ${processedCount} customers (${successCount} ${opts.commit ? 'cancelled' : 'validated'}, ${errorCount} errors)`
  92. )
  93. }
  94. } catch (err) {
  95. errorCount++
  96. if (err instanceof ReportError) {
  97. csvWriter.write({
  98. stripe_customer_id: input.stripe_customer_id,
  99. target_stripe_account: input.target_stripe_account,
  100. subscription_id: '',
  101. status: err.status,
  102. note: err.message,
  103. })
  104. } else {
  105. csvWriter.write({
  106. stripe_customer_id: input.stripe_customer_id,
  107. target_stripe_account: input.target_stripe_account,
  108. subscription_id: '',
  109. status: 'error',
  110. note: err.message,
  111. })
  112. await trackProgress(
  113. `Error processing ${input.stripe_customer_id}: ${err.message}`
  114. )
  115. }
  116. }
  117. }
  118. await trackProgress(`✅ Total processed: ${processedCount}`)
  119. if (opts.commit) {
  120. await trackProgress(`✅ Successfully cancelled: ${successCount}`)
  121. } else {
  122. await trackProgress(`✅ Successfully validated: ${successCount}`)
  123. await trackProgress('ℹ️ DRY RUN: No changes were applied')
  124. }
  125. await trackProgress(`❌ Errors: ${errorCount}`)
  126. await trackProgress('🎉 Script completed!')
  127. csvWriter.end()
  128. }
  129. function parseArgs() {
  130. const args = minimist(process.argv.slice(2), {
  131. string: ['output', 'throttle'],
  132. boolean: ['commit', 'help'],
  133. default: {
  134. throttle: DEFAULT_THROTTLE.toString(),
  135. },
  136. unknown: arg => {
  137. if (arg.startsWith('-')) {
  138. console.error(`Unknown option: ${arg}`)
  139. usage()
  140. process.exit(1)
  141. }
  142. return true
  143. },
  144. })
  145. if (args.help) {
  146. usage()
  147. process.exit(0)
  148. }
  149. const throttle = parseInt(args.throttle, 10)
  150. if (isNaN(throttle) || throttle < 0) {
  151. console.error('Error: --throttle must be a non-negative integer')
  152. usage()
  153. process.exit(1)
  154. }
  155. return {
  156. output: args.output,
  157. commit: args.commit,
  158. throttle,
  159. inputFile: args._[0],
  160. }
  161. }
  162. function getCsvReader(inputStream) {
  163. const parser = csv.parse({ columns: true })
  164. inputStream.pipe(parser)
  165. return parser
  166. }
  167. function getCsvWriter(outputFile) {
  168. if (outputFile === '-') {
  169. const writer = csv.stringify({
  170. columns: [
  171. 'stripe_customer_id',
  172. 'target_stripe_account',
  173. 'subscription_id',
  174. 'status',
  175. 'note',
  176. ],
  177. header: true,
  178. })
  179. writer.on('error', err => {
  180. console.error(err)
  181. process.exit(1)
  182. })
  183. writer.pipe(process.stdout)
  184. return writer
  185. }
  186. fs.mkdirSync(path.dirname(outputFile), { recursive: true })
  187. const outputStream = fs.createWriteStream(outputFile)
  188. const writer = csv.stringify({
  189. columns: [
  190. 'stripe_customer_id',
  191. 'target_stripe_account',
  192. 'subscription_id',
  193. 'status',
  194. 'note',
  195. ],
  196. header: true,
  197. })
  198. writer.on('error', err => {
  199. console.error(err)
  200. process.exit(1)
  201. })
  202. writer.pipe(outputStream)
  203. return writer
  204. }
  205. async function processCancellation(input, commit) {
  206. const {
  207. stripe_customer_id: customerId,
  208. target_stripe_account: targetStripeAccount,
  209. } = input
  210. // get Stripe client for the target account (strip 'stripe-' prefix if present)
  211. const region = targetStripeAccount.replace(/^stripe-/, '')
  212. const stripeClient = getRegionClient(region)
  213. // fetch customer with subscriptions
  214. let customer
  215. try {
  216. customer = await stripeClient.getCustomerById(customerId, ['subscriptions'])
  217. } catch (err) {
  218. throw new ReportError(
  219. 'customer-not-found',
  220. `Customer not found: ${err.message}`
  221. )
  222. }
  223. // check for active subscriptions
  224. if (!customer.subscriptions || customer.subscriptions.data.length === 0) {
  225. throw new ReportError('no-subscriptions', 'Customer has no subscriptions')
  226. }
  227. // find the subscription with migration metadata
  228. const migrationSubscription = customer.subscriptions.data.find(
  229. sub => sub.metadata?.recurly_to_stripe_migration_status === 'in_progress'
  230. )
  231. if (!migrationSubscription) {
  232. throw new ReportError(
  233. 'no-migration-subscription',
  234. 'Could not find a subscription with migration metadata to cancel'
  235. )
  236. }
  237. // in dry-run mode, just validate
  238. if (!commit) {
  239. return {
  240. status: 'validated',
  241. note: 'Subscription can be cancelled',
  242. subscriptionId: migrationSubscription.id,
  243. }
  244. }
  245. // cancel the subscription immediately
  246. try {
  247. await stripeClient.terminateSubscription(migrationSubscription.id)
  248. return {
  249. status: 'cancelled',
  250. note: `Cancelled subscription ${migrationSubscription.id}`,
  251. subscriptionId: migrationSubscription.id,
  252. }
  253. } catch (err) {
  254. throw new ReportError(
  255. 'cancellation-failed',
  256. `Failed to cancel subscription: ${err.message}`
  257. )
  258. }
  259. }
  260. try {
  261. await scriptRunner(main)
  262. process.exit(0)
  263. } catch (error) {
  264. console.error(error)
  265. process.exit(1)
  266. }