|
@@ -10,11 +10,14 @@
|
|
|
* node scripts/stripe/bulk-cancel-subscriptions.mjs [OPTS] [INPUT-FILE]
|
|
* node scripts/stripe/bulk-cancel-subscriptions.mjs [OPTS] [INPUT-FILE]
|
|
|
*
|
|
*
|
|
|
* Options:
|
|
* Options:
|
|
|
- * --output PATH Output file path (default: /tmp/bulk_cancel_output_<timestamp>.csv)
|
|
|
|
|
- * Use '-' to write to stdout
|
|
|
|
|
- * --commit Apply changes (without this flag, runs in dry-run mode)
|
|
|
|
|
- * --throttle DURATION Minimum time (in ms) between subscriptions processed (default: 100)
|
|
|
|
|
- * --help Show a help message
|
|
|
|
|
|
|
+ * --output PATH Output file path (default: /tmp/bulk_cancel_output_<timestamp>.csv)
|
|
|
|
|
+ * Use '-' to write to stdout
|
|
|
|
|
+ * --commit Apply changes (without this flag, runs in dry-run mode)
|
|
|
|
|
+ * --concurrency N Number of customers to process concurrently (default: 10)
|
|
|
|
|
+ * --stripe-rate-limit N Requests per second for Stripe (default: 50)
|
|
|
|
|
+ * --stripe-api-retries N Number of retries on Stripe 429s (default: 5)
|
|
|
|
|
+ * --stripe-retry-delay-ms N Delay between Stripe retries in ms (default: 1000)
|
|
|
|
|
+ * --help Show a help message
|
|
|
*
|
|
*
|
|
|
* CSV Input Format:
|
|
* CSV Input Format:
|
|
|
* The CSV must have the following columns:
|
|
* The CSV must have the following columns:
|
|
@@ -32,24 +35,37 @@
|
|
|
|
|
|
|
|
import fs from 'node:fs'
|
|
import fs from 'node:fs'
|
|
|
import path from 'node:path'
|
|
import path from 'node:path'
|
|
|
-import { setTimeout } from 'node:timers/promises'
|
|
|
|
|
import * as csv from 'csv'
|
|
import * as csv from 'csv'
|
|
|
import minimist from 'minimist'
|
|
import minimist from 'minimist'
|
|
|
|
|
+import PQueue from 'p-queue'
|
|
|
|
|
+import { z } from '../../app/src/infrastructure/Validation.mjs'
|
|
|
import { scriptRunner } from '../lib/ScriptRunner.mjs'
|
|
import { scriptRunner } from '../lib/ScriptRunner.mjs'
|
|
|
import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
|
|
import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
|
|
|
import { ReportError } from './helpers.mjs'
|
|
import { ReportError } from './helpers.mjs'
|
|
|
|
|
+import {
|
|
|
|
|
+ createRateLimitedApiWrappers,
|
|
|
|
|
+ DEFAULT_STRIPE_RATE_LIMIT,
|
|
|
|
|
+ DEFAULT_STRIPE_API_RETRIES,
|
|
|
|
|
+ DEFAULT_STRIPE_RETRY_DELAY_MS,
|
|
|
|
|
+} from './RateLimiter.mjs'
|
|
|
|
|
|
|
|
-const DEFAULT_THROTTLE = 40
|
|
|
|
|
|
|
+const DEFAULT_CONCURRENCY = 10
|
|
|
|
|
+
|
|
|
|
|
+// rate limiters - initialized in main()
|
|
|
|
|
+let rateLimiters
|
|
|
|
|
|
|
|
function usage() {
|
|
function usage() {
|
|
|
console.error(`Usage: node scripts/stripe/bulk-cancel-subscriptions.mjs [OPTS] [INPUT-FILE]
|
|
console.error(`Usage: node scripts/stripe/bulk-cancel-subscriptions.mjs [OPTS] [INPUT-FILE]
|
|
|
|
|
|
|
|
Options:
|
|
Options:
|
|
|
- --output PATH Output file path (default: /tmp/bulk_cancel_output_<timestamp>.csv)
|
|
|
|
|
- Use '-' to write to stdout
|
|
|
|
|
- --commit Apply changes (without this, runs in dry-run mode)
|
|
|
|
|
- --throttle DURATION Minimum time between requests in ms (default: ${DEFAULT_THROTTLE})
|
|
|
|
|
- --help Show this help message
|
|
|
|
|
|
|
+ --output PATH Output file path (default: /tmp/bulk_cancel_output_<timestamp>.csv)
|
|
|
|
|
+ Use '-' to write to stdout
|
|
|
|
|
+ --commit Apply changes (without this, runs in dry-run mode)
|
|
|
|
|
+ --concurrency N Number of customers to process concurrently (default: ${DEFAULT_CONCURRENCY})
|
|
|
|
|
+ --stripe-rate-limit N Requests per second for Stripe (default: ${DEFAULT_STRIPE_RATE_LIMIT})
|
|
|
|
|
+ --stripe-api-retries N Number of retries on Stripe 429s (default: ${DEFAULT_STRIPE_API_RETRIES})
|
|
|
|
|
+ --stripe-retry-delay-ms N Delay between Stripe retries in ms (default: ${DEFAULT_STRIPE_RETRY_DELAY_MS})
|
|
|
|
|
+ --help Show this help message
|
|
|
`)
|
|
`)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -58,9 +74,17 @@ async function main(trackProgress) {
|
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
|
const outputFile = opts.output ?? `/tmp/bulk_cancel_output_${timestamp}.csv`
|
|
const outputFile = opts.output ?? `/tmp/bulk_cancel_output_${timestamp}.csv`
|
|
|
|
|
|
|
|
|
|
+ // initialize rate limiters
|
|
|
|
|
+ rateLimiters = createRateLimitedApiWrappers({
|
|
|
|
|
+ stripeRateLimit: opts.stripeRateLimit,
|
|
|
|
|
+ stripeApiRetries: opts.stripeApiRetries,
|
|
|
|
|
+ stripeRetryDelayMs: opts.stripeRetryDelayMs,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
await trackProgress('Starting bulk subscription cancellation for Stripe')
|
|
await trackProgress('Starting bulk subscription cancellation for Stripe')
|
|
|
await trackProgress(`Run mode: ${opts.commit ? 'COMMIT' : 'DRY RUN'}`)
|
|
await trackProgress(`Run mode: ${opts.commit ? 'COMMIT' : 'DRY RUN'}`)
|
|
|
- await trackProgress(`Throttle: ${opts.throttle}ms between requests`)
|
|
|
|
|
|
|
+ await trackProgress(`Rate limit: Stripe ${opts.stripeRateLimit}/s`)
|
|
|
|
|
+ await trackProgress(`Concurrency: ${opts.concurrency}`)
|
|
|
|
|
|
|
|
const inputStream = opts.inputFile
|
|
const inputStream = opts.inputFile
|
|
|
? fs.createReadStream(opts.inputFile)
|
|
? fs.createReadStream(opts.inputFile)
|
|
@@ -74,62 +98,69 @@ async function main(trackProgress) {
|
|
|
let successCount = 0
|
|
let successCount = 0
|
|
|
let errorCount = 0
|
|
let errorCount = 0
|
|
|
|
|
|
|
|
- let lastLoopTimestamp = 0
|
|
|
|
|
- for await (const input of csvReader) {
|
|
|
|
|
- const timeSinceLastLoop = Date.now() - lastLoopTimestamp
|
|
|
|
|
- if (timeSinceLastLoop < opts.throttle) {
|
|
|
|
|
- await setTimeout(opts.throttle - timeSinceLastLoop)
|
|
|
|
|
- }
|
|
|
|
|
- lastLoopTimestamp = Date.now()
|
|
|
|
|
-
|
|
|
|
|
- processedCount++
|
|
|
|
|
-
|
|
|
|
|
- try {
|
|
|
|
|
- const result = await processCancellation(input, opts.commit)
|
|
|
|
|
|
|
+ const queue = new PQueue({ concurrency: opts.concurrency })
|
|
|
|
|
+ const maxQueueSize = opts.concurrency
|
|
|
|
|
|
|
|
- csvWriter.write({
|
|
|
|
|
- stripe_customer_id: input.stripe_customer_id,
|
|
|
|
|
- target_stripe_account: input.target_stripe_account,
|
|
|
|
|
- subscription_id: result.subscriptionId || '',
|
|
|
|
|
- status: result.status,
|
|
|
|
|
- note:
|
|
|
|
|
- result.note || (opts.commit ? '' : 'dry run - no changes applied'),
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- if (result.status === 'cancelled' || result.status === 'validated') {
|
|
|
|
|
- successCount++
|
|
|
|
|
- } else {
|
|
|
|
|
- errorCount++
|
|
|
|
|
|
|
+ try {
|
|
|
|
|
+ for await (const input of csvReader) {
|
|
|
|
|
+ if (queue.size >= maxQueueSize) {
|
|
|
|
|
+ await queue.onSizeLessThan(maxQueueSize)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- if (processedCount % 10 === 0) {
|
|
|
|
|
- await trackProgress(
|
|
|
|
|
- `Processed ${processedCount} customers (${successCount} ${opts.commit ? 'cancelled' : 'validated'}, ${errorCount} errors)`
|
|
|
|
|
- )
|
|
|
|
|
- }
|
|
|
|
|
- } catch (err) {
|
|
|
|
|
- errorCount++
|
|
|
|
|
- if (err instanceof ReportError) {
|
|
|
|
|
- csvWriter.write({
|
|
|
|
|
- stripe_customer_id: input.stripe_customer_id,
|
|
|
|
|
- target_stripe_account: input.target_stripe_account,
|
|
|
|
|
- subscription_id: '',
|
|
|
|
|
- status: err.status,
|
|
|
|
|
- note: err.message,
|
|
|
|
|
- })
|
|
|
|
|
- } else {
|
|
|
|
|
- csvWriter.write({
|
|
|
|
|
- stripe_customer_id: input.stripe_customer_id,
|
|
|
|
|
- target_stripe_account: input.target_stripe_account,
|
|
|
|
|
- subscription_id: '',
|
|
|
|
|
- status: 'error',
|
|
|
|
|
- note: err.message,
|
|
|
|
|
- })
|
|
|
|
|
- await trackProgress(
|
|
|
|
|
- `Error processing ${input.stripe_customer_id}: ${err.message}`
|
|
|
|
|
- )
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ queue.add(async () => {
|
|
|
|
|
+ processedCount++
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const result = await processCancellation(input, opts.commit)
|
|
|
|
|
+
|
|
|
|
|
+ csvWriter.write({
|
|
|
|
|
+ stripe_customer_id: input.stripe_customer_id,
|
|
|
|
|
+ target_stripe_account: input.target_stripe_account,
|
|
|
|
|
+ subscription_id: result.subscriptionId || '',
|
|
|
|
|
+ status: result.status,
|
|
|
|
|
+ note:
|
|
|
|
|
+ result.note ||
|
|
|
|
|
+ (opts.commit ? '' : 'dry run - no changes applied'),
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ if (result.status === 'cancelled' || result.status === 'validated') {
|
|
|
|
|
+ successCount++
|
|
|
|
|
+ } else {
|
|
|
|
|
+ errorCount++
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (processedCount % 10 === 0) {
|
|
|
|
|
+ await trackProgress(
|
|
|
|
|
+ `Processed ${processedCount} customers (${successCount} ${opts.commit ? 'cancelled' : 'validated'}, ${errorCount} errors)`
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ errorCount++
|
|
|
|
|
+ if (err instanceof ReportError) {
|
|
|
|
|
+ csvWriter.write({
|
|
|
|
|
+ stripe_customer_id: input.stripe_customer_id,
|
|
|
|
|
+ target_stripe_account: input.target_stripe_account,
|
|
|
|
|
+ subscription_id: '',
|
|
|
|
|
+ status: err.status,
|
|
|
|
|
+ note: err.message,
|
|
|
|
|
+ })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ csvWriter.write({
|
|
|
|
|
+ stripe_customer_id: input.stripe_customer_id,
|
|
|
|
|
+ target_stripe_account: input.target_stripe_account,
|
|
|
|
|
+ subscription_id: '',
|
|
|
|
|
+ status: 'error',
|
|
|
|
|
+ note: err.message,
|
|
|
|
|
+ })
|
|
|
|
|
+ await trackProgress(
|
|
|
|
|
+ `Error processing ${input.stripe_customer_id}: ${err.message}`
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
}
|
|
}
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ await queue.onIdle()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
await trackProgress(`✅ Total processed: ${processedCount}`)
|
|
await trackProgress(`✅ Total processed: ${processedCount}`)
|
|
@@ -147,10 +178,20 @@ async function main(trackProgress) {
|
|
|
|
|
|
|
|
function parseArgs() {
|
|
function parseArgs() {
|
|
|
const args = minimist(process.argv.slice(2), {
|
|
const args = minimist(process.argv.slice(2), {
|
|
|
- string: ['output', 'throttle'],
|
|
|
|
|
|
|
+ string: [
|
|
|
|
|
+ 'output',
|
|
|
|
|
+ 'concurrency',
|
|
|
|
|
+ 'stripe-rate-limit',
|
|
|
|
|
+ 'stripe-api-retries',
|
|
|
|
|
+ 'stripe-retry-delay-ms',
|
|
|
|
|
+ ],
|
|
|
boolean: ['commit', 'help'],
|
|
boolean: ['commit', 'help'],
|
|
|
default: {
|
|
default: {
|
|
|
- throttle: DEFAULT_THROTTLE.toString(),
|
|
|
|
|
|
|
+ commit: false,
|
|
|
|
|
+ concurrency: DEFAULT_CONCURRENCY,
|
|
|
|
|
+ 'stripe-rate-limit': DEFAULT_STRIPE_RATE_LIMIT,
|
|
|
|
|
+ 'stripe-api-retries': DEFAULT_STRIPE_API_RETRIES,
|
|
|
|
|
+ 'stripe-retry-delay-ms': DEFAULT_STRIPE_RETRY_DELAY_MS,
|
|
|
},
|
|
},
|
|
|
unknown: arg => {
|
|
unknown: arg => {
|
|
|
if (arg.startsWith('-')) {
|
|
if (arg.startsWith('-')) {
|
|
@@ -167,19 +208,32 @@ function parseArgs() {
|
|
|
process.exit(0)
|
|
process.exit(0)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const throttle = parseInt(args.throttle, 10)
|
|
|
|
|
- if (isNaN(throttle) || throttle < 0) {
|
|
|
|
|
- console.error('Error: --throttle must be a non-negative integer')
|
|
|
|
|
|
|
+ const inputFile = args._[0]
|
|
|
|
|
+ const paramsSchema = z.object({
|
|
|
|
|
+ output: z.string().optional(),
|
|
|
|
|
+ commit: z.boolean(),
|
|
|
|
|
+ concurrency: z.number().int().positive(),
|
|
|
|
|
+ stripeRateLimit: z.number().positive(),
|
|
|
|
|
+ stripeApiRetries: z.number().int().nonnegative(),
|
|
|
|
|
+ stripeRetryDelayMs: z.number().int().nonnegative(),
|
|
|
|
|
+ inputFile: z.string().optional(),
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ return paramsSchema.parse({
|
|
|
|
|
+ output: args.output,
|
|
|
|
|
+ commit: args.commit,
|
|
|
|
|
+ concurrency: Number(args.concurrency),
|
|
|
|
|
+ stripeRateLimit: Number(args['stripe-rate-limit']),
|
|
|
|
|
+ stripeApiRetries: Number(args['stripe-api-retries']),
|
|
|
|
|
+ stripeRetryDelayMs: Number(args['stripe-retry-delay-ms']),
|
|
|
|
|
+ inputFile,
|
|
|
|
|
+ })
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('Invalid arguments:', err.message)
|
|
|
usage()
|
|
usage()
|
|
|
process.exit(1)
|
|
process.exit(1)
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- return {
|
|
|
|
|
- output: args.output,
|
|
|
|
|
- commit: args.commit,
|
|
|
|
|
- throttle,
|
|
|
|
|
- inputFile: args._[0],
|
|
|
|
|
- }
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function getCsvReader(inputStream) {
|
|
function getCsvReader(inputStream) {
|
|
@@ -244,7 +298,15 @@ async function processCancellation(input, commit) {
|
|
|
// fetch customer with subscriptions
|
|
// fetch customer with subscriptions
|
|
|
let customer
|
|
let customer
|
|
|
try {
|
|
try {
|
|
|
- customer = await stripeClient.getCustomerById(customerId, ['subscriptions'])
|
|
|
|
|
|
|
+ customer = await rateLimiters.requestWithRetries(
|
|
|
|
|
+ stripeClient.serviceName,
|
|
|
|
|
+ () => stripeClient.getCustomerById(customerId, ['subscriptions']),
|
|
|
|
|
+ {
|
|
|
|
|
+ operation: 'getCustomerById',
|
|
|
|
|
+ customerId,
|
|
|
|
|
+ region: stripeClient.serviceName,
|
|
|
|
|
+ }
|
|
|
|
|
+ )
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
throw new ReportError(
|
|
throw new ReportError(
|
|
|
'customer-not-found',
|
|
'customer-not-found',
|
|
@@ -279,7 +341,15 @@ async function processCancellation(input, commit) {
|
|
|
|
|
|
|
|
// cancel the subscription immediately
|
|
// cancel the subscription immediately
|
|
|
try {
|
|
try {
|
|
|
- await stripeClient.terminateSubscription(migrationSubscription.id)
|
|
|
|
|
|
|
+ await rateLimiters.requestWithRetries(
|
|
|
|
|
+ stripeClient.serviceName,
|
|
|
|
|
+ () => stripeClient.terminateSubscription(migrationSubscription.id),
|
|
|
|
|
+ {
|
|
|
|
|
+ operation: 'terminateSubscription',
|
|
|
|
|
+ subscriptionId: migrationSubscription.id,
|
|
|
|
|
+ region: stripeClient.serviceName,
|
|
|
|
|
+ }
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
return {
|
|
return {
|
|
|
status: 'cancelled',
|
|
status: 'cancelled',
|