mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-06-02 21:59:00 +02:00
[web] add scripts to finalize recurly -> stripe migration (#30925)
GitOrigin-RevId: 2149aa516a00b18927fea46e9241496b74478152
This commit is contained in:
+262
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* This script CLEANS UP Recurly subscriptions after migration to Stripe is finalized.
|
||||
*
|
||||
* IMPORTANT: Only run this AFTER the cutover is complete, verified, and
|
||||
* we've confirmed that Stripe is working correctly.
|
||||
*
|
||||
* WARNING: After running this script, rollback is NO LONGER POSSIBLE.
|
||||
*
|
||||
* NOTE: This script will trigger lifecycle emails to be sent. Please turn off:
|
||||
* - "Subscription Expired Template" (https://sharelatex.recurly.com/emails/subscription_expired/template/edit)
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/recurly/cleanup-recurly-subscriptions-post-migration.mjs [OPTS] [INPUT-FILE]
|
||||
*
|
||||
* Options:
|
||||
* --output PATH Output file path (default: /tmp/cancel_output_<timestamp>.csv)
|
||||
* --commit Apply changes (without this, runs in dry-run mode)
|
||||
* --throttle DURATION Minimum time between requests in ms (default: 100)
|
||||
* --help Show help message
|
||||
*
|
||||
* CSV Input Format:
|
||||
* recurly_account_code,previous_recurly_subscription_id
|
||||
* 507f1f77bcf86cd799439011,abcd1234efgh5678
|
||||
*
|
||||
* CSV Output Format:
|
||||
* recurly_account_code,previous_recurly_subscription_id,status,note
|
||||
*
|
||||
* Note: recurly_account_code is the Overleaf user ID (admin_id)
|
||||
*/
|
||||
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { setTimeout } from 'node:timers/promises'
|
||||
import * as csv from 'csv'
|
||||
import minimist from 'minimist'
|
||||
import RecurlyClient from '../../app/src/Features/Subscription/RecurlyClient.mjs'
|
||||
import { z } from '../../app/src/infrastructure/Validation.mjs'
|
||||
import { scriptRunner } from '../lib/ScriptRunner.mjs'
|
||||
import { Subscription } from '../../app/src/models/Subscription.mjs'
|
||||
import { ReportError } from '../stripe/helpers.mjs'
|
||||
|
||||
const DEFAULT_THROTTLE = 100
|
||||
|
||||
function usage() {
|
||||
console.error(`Usage: node scripts/recurly/cleanup-recurly-subscriptions-post-migration.mjs [OPTS] [INPUT-FILE]
|
||||
|
||||
Options:
|
||||
--output PATH Output file path (default: /tmp/terminate_output_<timestamp>.csv)
|
||||
--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
|
||||
`)
|
||||
}
|
||||
|
||||
async function main(trackProgress) {
|
||||
const opts = parseArgs()
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const outputFile = opts.output ?? `/tmp/terminate_output_${timestamp}.csv`
|
||||
|
||||
await trackProgress('Starting Recurly subscription termination')
|
||||
await trackProgress(`Run mode: ${opts.commit ? 'COMMIT' : 'DRY RUN'}`)
|
||||
await trackProgress(`Throttle: ${opts.throttle}ms between requests`)
|
||||
|
||||
const inputStream = opts.inputFile
|
||||
? fs.createReadStream(opts.inputFile)
|
||||
: process.stdin
|
||||
const csvReader = getCsvReader(inputStream)
|
||||
const csvWriter = getCsvWriter(outputFile)
|
||||
|
||||
await trackProgress(`Output: ${outputFile}`)
|
||||
|
||||
let processedCount = 0
|
||||
let successCount = 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 processTermination(input, opts.commit)
|
||||
|
||||
csvWriter.write({
|
||||
recurly_account_code: input.recurly_account_code,
|
||||
status: result.status,
|
||||
note: result.note,
|
||||
previous_recurly_subscription_id:
|
||||
input.previous_recurly_subscription_id,
|
||||
})
|
||||
|
||||
if (result.status === 'terminated' || result.status === 'validated') {
|
||||
successCount++
|
||||
} else {
|
||||
errorCount++
|
||||
}
|
||||
|
||||
if (processedCount % 25 === 0) {
|
||||
await trackProgress(
|
||||
`Progress: ${processedCount} processed, ${successCount} successful, ${errorCount} errors`
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
errorCount++
|
||||
if (err instanceof ReportError) {
|
||||
csvWriter.write({
|
||||
recurly_account_code: input.recurly_account_code,
|
||||
previous_recurly_subscription_id:
|
||||
input.previous_recurly_subscription_id,
|
||||
status: err.status,
|
||||
note: err.message,
|
||||
})
|
||||
} else {
|
||||
csvWriter.write({
|
||||
recurly_account_code: input.recurly_account_code,
|
||||
previous_recurly_subscription_id:
|
||||
input.previous_recurly_subscription_id,
|
||||
status: 'error',
|
||||
note: err.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await trackProgress(`✅ Total processed: ${processedCount}`)
|
||||
if (opts.commit) {
|
||||
await trackProgress(`✅ Successfully terminated: ${successCount}`)
|
||||
} else {
|
||||
await trackProgress(`✅ Successfully validated: ${successCount}`)
|
||||
await trackProgress('ℹ️ DRY RUN: No changes were applied')
|
||||
}
|
||||
await trackProgress(`❌ Errors: ${errorCount}`)
|
||||
await trackProgress('🎉 Script completed!')
|
||||
|
||||
csvWriter.end()
|
||||
}
|
||||
|
||||
function getCsvReader(inputStream) {
|
||||
const parser = csv.parse({ columns: true })
|
||||
inputStream.pipe(parser)
|
||||
return parser
|
||||
}
|
||||
|
||||
function getCsvWriter(outputFile) {
|
||||
fs.mkdirSync(path.dirname(outputFile), { recursive: true })
|
||||
const outputStream = fs.createWriteStream(outputFile)
|
||||
|
||||
const writer = csv.stringify({
|
||||
columns: [
|
||||
'recurly_account_code',
|
||||
'previous_recurly_subscription_id',
|
||||
'status',
|
||||
'note',
|
||||
],
|
||||
header: true,
|
||||
})
|
||||
|
||||
writer.on('error', err => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
writer.pipe(outputStream)
|
||||
return writer
|
||||
}
|
||||
|
||||
async function processTermination(input, commit) {
|
||||
const {
|
||||
recurly_account_code: adminUserId,
|
||||
previous_recurly_subscription_id: subscriptionUuid,
|
||||
} = input
|
||||
|
||||
// 1. Fetch Mongo subscription
|
||||
const mongoSubscription = await Subscription.findOne({
|
||||
admin_id: adminUserId,
|
||||
}).exec()
|
||||
|
||||
// 2. Verify subscription has been migrated to Stripe (skipping if the
|
||||
// Mongo subscription is missing, which would indicate that the Stripe
|
||||
// subscription has expired after the cutover)
|
||||
if (
|
||||
mongoSubscription &&
|
||||
!mongoSubscription.paymentProvider?.service?.includes('stripe')
|
||||
) {
|
||||
throw new ReportError(
|
||||
'not-migrated',
|
||||
'Subscription has not been migrated to Stripe yet'
|
||||
)
|
||||
}
|
||||
|
||||
// 3. If commit mode, terminate the subscription
|
||||
if (commit) {
|
||||
try {
|
||||
await RecurlyClient.promises.terminateSubscriptionByUuid(subscriptionUuid)
|
||||
|
||||
return {
|
||||
status: 'terminated',
|
||||
note: 'Successfully terminated Recurly subscription',
|
||||
}
|
||||
} catch (err) {
|
||||
throw new ReportError(
|
||||
'terminate-failed',
|
||||
`Failed to terminate: ${err.message}`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
status: 'validated',
|
||||
note: 'DRY RUN: Ready to terminate',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs() {
|
||||
const args = minimist(process.argv.slice(2), {
|
||||
string: ['output'],
|
||||
number: ['throttle'],
|
||||
boolean: ['commit', 'help'],
|
||||
default: { commit: false, throttle: DEFAULT_THROTTLE },
|
||||
})
|
||||
|
||||
if (args.help) {
|
||||
usage()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const inputFile = args._[0]
|
||||
const paramsSchema = z.object({
|
||||
output: z.string().optional(),
|
||||
commit: z.boolean(),
|
||||
throttle: z.number().int().positive(),
|
||||
inputFile: z.string().optional(),
|
||||
})
|
||||
|
||||
try {
|
||||
return paramsSchema.parse({
|
||||
output: args.output,
|
||||
commit: args.commit,
|
||||
throttle: args.throttle,
|
||||
inputFile,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Invalid arguments:', err.message)
|
||||
usage()
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await scriptRunner(main)
|
||||
process.exit(0)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
}
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* This script bulk cancels active Stripe subscriptions immediately without proration.
|
||||
*
|
||||
* NOTE: this will email customers to inform them of the cancellation unless you turn off
|
||||
* the cancellation automation in Stripe beforehand: https://dashboard.stripe.com/<account>/revenue-recovery/automations
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/stripe/bulk-cancel-subscriptions.mjs [OPTS] [INPUT-FILE]
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* CSV Input Format:
|
||||
* The CSV must have the following columns:
|
||||
* - stripe_customer_id: Stripe customer id
|
||||
* - target_stripe_account: Either 'stripe-uk' or 'stripe-us'
|
||||
*
|
||||
* Output:
|
||||
* Writes a CSV with columns:
|
||||
* - stripe_customer_id: The customer id processed
|
||||
* - target_stripe_account: The Stripe account
|
||||
* - subscription_id: The subscription id that was cancelled (if found)
|
||||
* - status: Result status (cancelled, validated, no-subscription, already-cancelled, or error)
|
||||
* - note: Additional information about the status
|
||||
*/
|
||||
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { setTimeout } from 'node:timers/promises'
|
||||
import * as csv from 'csv'
|
||||
import minimist from 'minimist'
|
||||
import { scriptRunner } from '../lib/ScriptRunner.mjs'
|
||||
import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
|
||||
import { ReportError } from './helpers.mjs'
|
||||
|
||||
const DEFAULT_THROTTLE = 40
|
||||
|
||||
function usage() {
|
||||
console.error(`Usage: node scripts/stripe/bulk-cancel-subscriptions.mjs [OPTS] [INPUT-FILE]
|
||||
|
||||
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
|
||||
`)
|
||||
}
|
||||
|
||||
async function main(trackProgress) {
|
||||
const opts = parseArgs()
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const outputFile = opts.output ?? `/tmp/bulk_cancel_output_${timestamp}.csv`
|
||||
|
||||
await trackProgress('Starting bulk subscription cancellation for Stripe')
|
||||
await trackProgress(`Run mode: ${opts.commit ? 'COMMIT' : 'DRY RUN'}`)
|
||||
await trackProgress(`Throttle: ${opts.throttle}ms between requests`)
|
||||
|
||||
const inputStream = opts.inputFile
|
||||
? fs.createReadStream(opts.inputFile)
|
||||
: process.stdin
|
||||
const csvReader = getCsvReader(inputStream)
|
||||
const csvWriter = getCsvWriter(outputFile)
|
||||
|
||||
await trackProgress(`Output: ${outputFile === '-' ? 'stdout' : outputFile}`)
|
||||
|
||||
let processedCount = 0
|
||||
let successCount = 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)
|
||||
|
||||
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}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await trackProgress(`✅ Total processed: ${processedCount}`)
|
||||
if (opts.commit) {
|
||||
await trackProgress(`✅ Successfully cancelled: ${successCount}`)
|
||||
} else {
|
||||
await trackProgress(`✅ Successfully validated: ${successCount}`)
|
||||
await trackProgress('ℹ️ DRY RUN: No changes were applied')
|
||||
}
|
||||
await trackProgress(`❌ Errors: ${errorCount}`)
|
||||
await trackProgress('🎉 Script completed!')
|
||||
|
||||
csvWriter.end()
|
||||
}
|
||||
|
||||
function parseArgs() {
|
||||
const args = minimist(process.argv.slice(2), {
|
||||
string: ['output', 'throttle'],
|
||||
boolean: ['commit', 'help'],
|
||||
default: {
|
||||
throttle: DEFAULT_THROTTLE.toString(),
|
||||
},
|
||||
unknown: arg => {
|
||||
if (arg.startsWith('-')) {
|
||||
console.error(`Unknown option: ${arg}`)
|
||||
usage()
|
||||
process.exit(1)
|
||||
}
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
if (args.help) {
|
||||
usage()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const throttle = parseInt(args.throttle, 10)
|
||||
if (isNaN(throttle) || throttle < 0) {
|
||||
console.error('Error: --throttle must be a non-negative integer')
|
||||
usage()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
return {
|
||||
output: args.output,
|
||||
commit: args.commit,
|
||||
throttle,
|
||||
inputFile: args._[0],
|
||||
}
|
||||
}
|
||||
|
||||
function getCsvReader(inputStream) {
|
||||
const parser = csv.parse({ columns: true })
|
||||
inputStream.pipe(parser)
|
||||
return parser
|
||||
}
|
||||
|
||||
function getCsvWriter(outputFile) {
|
||||
if (outputFile === '-') {
|
||||
const writer = csv.stringify({
|
||||
columns: [
|
||||
'stripe_customer_id',
|
||||
'target_stripe_account',
|
||||
'subscription_id',
|
||||
'status',
|
||||
'note',
|
||||
],
|
||||
header: true,
|
||||
})
|
||||
writer.on('error', err => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
writer.pipe(process.stdout)
|
||||
return writer
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(outputFile), { recursive: true })
|
||||
const outputStream = fs.createWriteStream(outputFile)
|
||||
|
||||
const writer = csv.stringify({
|
||||
columns: [
|
||||
'stripe_customer_id',
|
||||
'target_stripe_account',
|
||||
'subscription_id',
|
||||
'status',
|
||||
'note',
|
||||
],
|
||||
header: true,
|
||||
})
|
||||
|
||||
writer.on('error', err => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
writer.pipe(outputStream)
|
||||
return writer
|
||||
}
|
||||
|
||||
async function processCancellation(input, commit) {
|
||||
const {
|
||||
stripe_customer_id: customerId,
|
||||
target_stripe_account: targetStripeAccount,
|
||||
} = input
|
||||
|
||||
// get Stripe client for the target account (strip 'stripe-' prefix if present)
|
||||
const region = targetStripeAccount.replace(/^stripe-/, '')
|
||||
const stripeClient = getRegionClient(region)
|
||||
|
||||
// fetch customer with subscriptions
|
||||
let customer
|
||||
try {
|
||||
customer = await stripeClient.getCustomerById(customerId, ['subscriptions'])
|
||||
} catch (err) {
|
||||
throw new ReportError(
|
||||
'customer-not-found',
|
||||
`Customer not found: ${err.message}`
|
||||
)
|
||||
}
|
||||
|
||||
// check for active subscriptions
|
||||
if (!customer.subscriptions || customer.subscriptions.data.length === 0) {
|
||||
throw new ReportError('no-subscriptions', 'Customer has no subscriptions')
|
||||
}
|
||||
|
||||
// find the subscription with migration metadata
|
||||
const migrationSubscription = customer.subscriptions.data.find(
|
||||
sub => sub.metadata?.recurly_to_stripe_migration_status === 'in_progress'
|
||||
)
|
||||
if (!migrationSubscription) {
|
||||
throw new ReportError(
|
||||
'no-migration-subscription',
|
||||
'Could not find a subscription with migration metadata to cancel'
|
||||
)
|
||||
}
|
||||
|
||||
// in dry-run mode, just validate
|
||||
if (!commit) {
|
||||
return {
|
||||
status: 'validated',
|
||||
note: 'Subscription can be cancelled',
|
||||
subscriptionId: migrationSubscription.id,
|
||||
}
|
||||
}
|
||||
|
||||
// cancel the subscription immediately
|
||||
try {
|
||||
await stripeClient.terminateSubscription(migrationSubscription.id)
|
||||
|
||||
return {
|
||||
status: 'cancelled',
|
||||
note: `Cancelled subscription ${migrationSubscription.id}`,
|
||||
subscriptionId: migrationSubscription.id,
|
||||
}
|
||||
} catch (err) {
|
||||
throw new ReportError(
|
||||
'cancellation-failed',
|
||||
`Failed to cancel subscription: ${err.message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await scriptRunner(main)
|
||||
process.exit(0)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* This script handles the cutover for subscriptions migrating from Recurly to Stripe.
|
||||
*
|
||||
* IMPORTANT: Only run this after Stripe subscriptions have been created in Stripe and
|
||||
* are ready to take over billing from Recurly.
|
||||
*
|
||||
* NOTE: This script will trigger lifecycle emails to be sent. Please turn off:
|
||||
* - "Send emails about upcoming renewals" (https://dashboard.stripe.com/<account>/settings/billing/subscriptions)
|
||||
* - "Subscription Change Template" (https://sharelatex.recurly.com/emails/subscription_change/template/edit)
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/stripe/finalize-stripe-subscription-migration.mjs [OPTS] [INPUT-FILE]
|
||||
*
|
||||
* Options:
|
||||
* --output PATH Output file path (default: /tmp/migrate_output_<timestamp>.csv)
|
||||
* --commit Apply changes (without this, runs in dry-run mode)
|
||||
* --throttle DURATION Minimum time between requests in ms (default: 40)
|
||||
* --help Show help message
|
||||
*
|
||||
* CSV Input Format:
|
||||
* recurly_account_code,target_stripe_account,stripe_customer_id
|
||||
* 507f1f77bcf86cd799439011,stripe-uk,cus_1234567890abcdef
|
||||
*
|
||||
* CSV Output Format:
|
||||
* recurly_account_code,target_stripe_account,stripe_customer_id,previous_recurly_status,previous_recurly_subscription_id,status,note
|
||||
*
|
||||
* Note: recurly_account_code is the Overleaf user ID (admin_id)
|
||||
*/
|
||||
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { setTimeout } from 'node:timers/promises'
|
||||
import * as csv from 'csv'
|
||||
import minimist from 'minimist'
|
||||
import { z } from '../../app/src/infrastructure/Validation.mjs'
|
||||
import { scriptRunner } from '../lib/ScriptRunner.mjs'
|
||||
import {
|
||||
getRegionClient,
|
||||
convertStripeStatusToSubscriptionState,
|
||||
} from '../../modules/subscriptions/app/src/StripeClient.mjs'
|
||||
import RecurlyWrapper from '../../app/src/Features/Subscription/RecurlyWrapper.mjs'
|
||||
import { Subscription } from '../../app/src/models/Subscription.mjs'
|
||||
import AnalyticsManager from '../../app/src/Features/Analytics/AnalyticsManager.mjs'
|
||||
import AccountMappingHelper from '../../app/src/Features/Analytics/AccountMappingHelper.mjs'
|
||||
import { ReportError } from './helpers.mjs'
|
||||
|
||||
const DEFAULT_THROTTLE = 40
|
||||
|
||||
const preloadedProductMetadata = new Map()
|
||||
|
||||
function usage() {
|
||||
console.error(`Usage: node scripts/stripe/finalize-stripe-subscription-migration.mjs [OPTS] [INPUT-FILE]
|
||||
|
||||
Options:
|
||||
--output PATH Output file path (default: /tmp/migrate_output_<timestamp>.csv)
|
||||
--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
|
||||
`)
|
||||
}
|
||||
|
||||
async function main(trackProgress) {
|
||||
const opts = parseArgs()
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const outputFile = opts.output ?? `/tmp/migrate_output_${timestamp}.csv`
|
||||
|
||||
await trackProgress('Starting Recurly to Stripe migration cutover')
|
||||
await trackProgress(`Run mode: ${opts.commit ? 'COMMIT' : 'DRY RUN'}`)
|
||||
await trackProgress(`Throttle: ${opts.throttle}ms between requests`)
|
||||
|
||||
const inputStream = opts.inputFile
|
||||
? fs.createReadStream(opts.inputFile)
|
||||
: process.stdin
|
||||
const csvReader = getCsvReader(inputStream)
|
||||
const csvWriter = getCsvWriter(outputFile)
|
||||
|
||||
await trackProgress('Populating product metadata cache...')
|
||||
await preloadProductMetadata('uk')
|
||||
await preloadProductMetadata('us')
|
||||
await trackProgress('Product metadata cache populated')
|
||||
|
||||
await trackProgress(`Output: ${outputFile}`)
|
||||
|
||||
let processedCount = 0
|
||||
let successCount = 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 processMigration(input, opts.commit)
|
||||
|
||||
csvWriter.write({
|
||||
recurly_account_code: input.recurly_account_code,
|
||||
target_stripe_account: input.target_stripe_account,
|
||||
stripe_customer_id: input.stripe_customer_id,
|
||||
previous_recurly_status: result.previousRecurlyStatus || '',
|
||||
previous_recurly_subscription_id:
|
||||
result.previousRecurlySubscriptionId || '',
|
||||
email: result.email || '',
|
||||
status: result.status,
|
||||
note: result.note,
|
||||
})
|
||||
|
||||
if (result.status === 'migrated' || result.status === 'validated') {
|
||||
successCount++
|
||||
} else {
|
||||
errorCount++
|
||||
}
|
||||
|
||||
if (processedCount % 25 === 0) {
|
||||
await trackProgress(
|
||||
`Progress: ${processedCount} processed, ${successCount} successful, ${errorCount} errors`
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
errorCount++
|
||||
if (err instanceof ReportError) {
|
||||
csvWriter.write({
|
||||
recurly_account_code: input.recurly_account_code,
|
||||
target_stripe_account: input.target_stripe_account,
|
||||
stripe_customer_id: input.stripe_customer_id,
|
||||
previous_recurly_status: '',
|
||||
previous_recurly_subscription_id: '',
|
||||
email: '',
|
||||
status: err.status,
|
||||
note: err.message,
|
||||
})
|
||||
} else {
|
||||
csvWriter.write({
|
||||
recurly_account_code: input.recurly_account_code,
|
||||
target_stripe_account: input.target_stripe_account,
|
||||
stripe_customer_id: input.stripe_customer_id,
|
||||
previous_recurly_status: '',
|
||||
previous_recurly_subscription_id: '',
|
||||
email: '',
|
||||
status: 'error',
|
||||
note: err.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await trackProgress(`✅ Total processed: ${processedCount}`)
|
||||
if (opts.commit) {
|
||||
await trackProgress(`✅ Successfully migrated: ${successCount}`)
|
||||
} else {
|
||||
await trackProgress(`✅ Successfully validated: ${successCount}`)
|
||||
await trackProgress('ℹ️ DRY RUN: No changes were applied')
|
||||
}
|
||||
await trackProgress(`❌ Errors: ${errorCount}`)
|
||||
await trackProgress('🎉 Script completed!')
|
||||
|
||||
csvWriter.end()
|
||||
}
|
||||
|
||||
function getCsvReader(inputStream) {
|
||||
const parser = csv.parse({ columns: true })
|
||||
inputStream.pipe(parser)
|
||||
return parser
|
||||
}
|
||||
|
||||
function getCsvWriter(outputFile) {
|
||||
fs.mkdirSync(path.dirname(outputFile), { recursive: true })
|
||||
const outputStream = fs.createWriteStream(outputFile)
|
||||
|
||||
const writer = csv.stringify({
|
||||
columns: [
|
||||
'recurly_account_code',
|
||||
'target_stripe_account',
|
||||
'stripe_customer_id',
|
||||
'previous_recurly_status',
|
||||
'previous_recurly_subscription_id',
|
||||
'email',
|
||||
'status',
|
||||
'note',
|
||||
],
|
||||
header: true,
|
||||
})
|
||||
|
||||
writer.on('error', err => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
writer.pipe(outputStream)
|
||||
return writer
|
||||
}
|
||||
|
||||
async function preloadProductMetadata(region) {
|
||||
if (preloadedProductMetadata.has(region)) return
|
||||
|
||||
const stripeClient = getRegionClient(region)
|
||||
const products = await stripeClient.stripe.products.list({
|
||||
active: true,
|
||||
limit: 100,
|
||||
})
|
||||
|
||||
const cache = new Map()
|
||||
for (const product of products.data) {
|
||||
cache.set(product.id, product.metadata)
|
||||
}
|
||||
|
||||
preloadedProductMetadata.set(region, cache)
|
||||
}
|
||||
|
||||
async function processMigration(input, commit) {
|
||||
const {
|
||||
recurly_account_code: accountCode,
|
||||
target_stripe_account: targetStripeAccount,
|
||||
stripe_customer_id: stripeCustomerId,
|
||||
} = input
|
||||
|
||||
// Get Stripe client for the target account (strip 'stripe-' prefix if present)
|
||||
const region = targetStripeAccount.replace(/^stripe-/, '')
|
||||
const stripeClient = getRegionClient(region)
|
||||
|
||||
// 1. Fetch Mongo subscription
|
||||
const mongoSubscription = await Subscription.findOne({
|
||||
admin_id: accountCode,
|
||||
}).exec()
|
||||
if (!mongoSubscription) {
|
||||
throw new ReportError(
|
||||
'no-mongo-subscription',
|
||||
'No subscription found in Mongo'
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Check if already migrated to Stripe
|
||||
if (mongoSubscription.paymentProvider?.service?.includes('stripe')) {
|
||||
throw new ReportError('already-stripe', 'Subscription already using Stripe')
|
||||
}
|
||||
|
||||
// 3. Store previous state for output
|
||||
const previousRecurlyStatus = mongoSubscription.recurlyStatus
|
||||
? JSON.stringify(mongoSubscription.recurlyStatus)
|
||||
: ''
|
||||
const previousRecurlySubscriptionId =
|
||||
mongoSubscription.recurlySubscription_id || ''
|
||||
|
||||
// 4. Find Stripe subscription for this customer
|
||||
let stripeCustomer
|
||||
let stripeSubscription
|
||||
try {
|
||||
stripeCustomer = await stripeClient.getCustomerById(stripeCustomerId, [
|
||||
'subscriptions',
|
||||
])
|
||||
if (
|
||||
!stripeCustomer.subscriptions ||
|
||||
stripeCustomer.subscriptions.data.length === 0
|
||||
) {
|
||||
throw new ReportError(
|
||||
'no-stripe-subscription',
|
||||
'No Stripe subscriptions found for customer'
|
||||
)
|
||||
}
|
||||
// find the subscription with migration metadata
|
||||
stripeSubscription = stripeCustomer.subscriptions.data.find(
|
||||
sub => sub.metadata?.recurly_to_stripe_migration_status === 'in_progress'
|
||||
)
|
||||
if (!stripeSubscription) {
|
||||
throw new ReportError(
|
||||
'no-stripe-subscription',
|
||||
'No target Stripe subscription found for customer'
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ReportError) throw err
|
||||
throw new ReportError(
|
||||
'stripe-fetch-error',
|
||||
`Failed to fetch Stripe subscription: ${err.message}`
|
||||
)
|
||||
}
|
||||
|
||||
// 5. Fetch Recurly subscription
|
||||
let recurlySubscription
|
||||
try {
|
||||
recurlySubscription = await RecurlyWrapper.promises.getSubscription(
|
||||
previousRecurlySubscriptionId,
|
||||
{}
|
||||
)
|
||||
} catch (err) {
|
||||
throw new ReportError(
|
||||
'no-recurly-subscription',
|
||||
`Recurly subscription not found: ${err.message}`
|
||||
)
|
||||
}
|
||||
|
||||
// 6. Detect changes between Recurly and Stripe
|
||||
const changes = detectChanges(recurlySubscription, stripeSubscription, region)
|
||||
if (changes.length > 0) {
|
||||
return {
|
||||
status: 'changes-detected',
|
||||
note: `Changes found: ${changes.join('; ')}`,
|
||||
previousRecurlyStatus,
|
||||
previousRecurlySubscriptionId,
|
||||
email: stripeCustomer.email,
|
||||
}
|
||||
}
|
||||
|
||||
// 7. If commit mode, perform migration
|
||||
if (commit) {
|
||||
await performCutover(
|
||||
mongoSubscription,
|
||||
stripeSubscription,
|
||||
recurlySubscription,
|
||||
stripeClient
|
||||
)
|
||||
return {
|
||||
status: 'migrated',
|
||||
note: 'Successfully migrated to Stripe',
|
||||
previousRecurlyStatus,
|
||||
previousRecurlySubscriptionId,
|
||||
email: stripeCustomer.email,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
status: 'validated',
|
||||
note: 'DRY RUN: Ready to migrate',
|
||||
previousRecurlyStatus,
|
||||
previousRecurlySubscriptionId,
|
||||
email: stripeCustomer.email,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: add other plan codes as needed
|
||||
const RECURLY_PLAN_CODE_TO_STRIPE_PLAN_CODE = {
|
||||
student_free_trial_7_days: 'student',
|
||||
collaborator_free_trial_7_days: 'collaborator',
|
||||
student: 'student',
|
||||
collaborator: 'collaborator',
|
||||
'collaborator-annual': 'collaborator-annual',
|
||||
'collaborator-annual_free_trial_7_days': 'collaborator-annual',
|
||||
professional_free_trial_7_days: 'professional',
|
||||
professional: 'professional',
|
||||
'professional-annual': 'professional-annual',
|
||||
'student-annual': 'student-annual',
|
||||
}
|
||||
|
||||
function detectChanges(recurlySubscription, stripeSubscription, region) {
|
||||
const changes = []
|
||||
|
||||
// Extract item codes from Recurly subscription (excluding additional-license
|
||||
// add-on, which is not a separate add-on in Stripe)
|
||||
const planCode = recurlySubscription.plan.plan_code
|
||||
const recurlyItemCodes = JSON.stringify(
|
||||
[
|
||||
RECURLY_PLAN_CODE_TO_STRIPE_PLAN_CODE[planCode] || planCode,
|
||||
...(recurlySubscription.subscription_add_ons || [])
|
||||
.filter(addOn => addOn.add_on_code !== 'additional-license')
|
||||
.map(addOn => addOn.add_on_code),
|
||||
].sort()
|
||||
)
|
||||
|
||||
// Extract item codes from Stripe subscription
|
||||
const cache = preloadedProductMetadata.get(region)
|
||||
const stripeItemCodes = JSON.stringify(
|
||||
stripeSubscription.items.data
|
||||
.map(item => {
|
||||
const productMetadata = cache.get(item.price.product)
|
||||
return productMetadata?.planCode || productMetadata?.addOnCode || null
|
||||
})
|
||||
.filter(code => code !== null)
|
||||
.sort()
|
||||
)
|
||||
|
||||
// Compare item codes
|
||||
if (recurlyItemCodes !== stripeItemCodes) {
|
||||
changes.push(
|
||||
`Items: Recurly=[${recurlyItemCodes}], Stripe=[${stripeItemCodes}]`
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: compare quantities for each item, taking additional-license add-ons into account
|
||||
|
||||
// Compare states
|
||||
const recurlyState = recurlySubscription.state
|
||||
const stripeState = convertStripeStatusToSubscriptionState(stripeSubscription)
|
||||
if (recurlyState !== stripeState) {
|
||||
changes.push(`State: Recurly=${recurlyState}, Stripe=${stripeState}`)
|
||||
}
|
||||
|
||||
// Verify no changes have been scheduled in Recurly
|
||||
if (recurlySubscription.pending_subscription != null) {
|
||||
changes.push('Pending change now exists in Recurly subscription')
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
async function performCutover(
|
||||
mongoSubscription,
|
||||
stripeSubscription,
|
||||
recurlySubscription,
|
||||
stripeClient
|
||||
) {
|
||||
const adminUserId = mongoSubscription.admin_id.toString()
|
||||
|
||||
// Step 1: Update Mongo subscription to point to Stripe
|
||||
mongoSubscription.paymentProvider = {
|
||||
service: stripeClient.serviceName,
|
||||
subscriptionId: stripeSubscription.id,
|
||||
state: convertStripeStatusToSubscriptionState(stripeSubscription),
|
||||
}
|
||||
|
||||
mongoSubscription.recurlySubscription_id = undefined
|
||||
mongoSubscription.recurlyStatus = undefined
|
||||
|
||||
await mongoSubscription.save()
|
||||
|
||||
// Step 2: Emit migration analytics event
|
||||
AnalyticsManager.recordEventForUserInBackground(
|
||||
adminUserId,
|
||||
'subscription-migrated-to-stripe',
|
||||
{
|
||||
subscriptionId: mongoSubscription._id.toString(),
|
||||
migrationDirection: 'recurly-to-stripe',
|
||||
}
|
||||
)
|
||||
|
||||
// Step 3: Postpone Recurly billing by +10 years
|
||||
const currentBillingDate = new Date(
|
||||
recurlySubscription.current_period_ends_at
|
||||
)
|
||||
const postponedDate = new Date(currentBillingDate)
|
||||
postponedDate.setFullYear(currentBillingDate.getFullYear() + 10)
|
||||
|
||||
try {
|
||||
await RecurlyWrapper.promises.apiRequest({
|
||||
url: `subscriptions/${recurlySubscription.uuid}/postpone`,
|
||||
qs: { bulk: true, next_bill_date: postponedDate },
|
||||
method: 'PUT',
|
||||
})
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to postpone Recurly billing: ${err.message}`)
|
||||
}
|
||||
|
||||
// Step 4: Remove migration metadata from Stripe
|
||||
try {
|
||||
await stripeClient.updateSubscriptionMetadata(stripeSubscription.id, {
|
||||
recurly_to_stripe_migration_status: '',
|
||||
})
|
||||
} catch (err) {
|
||||
throw new ReportError(
|
||||
'migrated-metadata-removal-failed',
|
||||
`Successfully migrated to Stripe but failed to remove metadata: ${err.message}`
|
||||
)
|
||||
}
|
||||
|
||||
// Step 5: Register analytics mapping
|
||||
try {
|
||||
AnalyticsManager.registerAccountMapping(
|
||||
AccountMappingHelper.generateSubscriptionToStripeMapping(
|
||||
mongoSubscription._id,
|
||||
stripeSubscription.id,
|
||||
stripeSubscription.service
|
||||
)
|
||||
)
|
||||
} catch (err) {
|
||||
throw new ReportError(
|
||||
'analytics-mapping-failed',
|
||||
`Successfully migrated to Stripe but failed to register analytics mapping: ${err.message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs() {
|
||||
const args = minimist(process.argv.slice(2), {
|
||||
string: ['output'],
|
||||
number: ['throttle'],
|
||||
boolean: ['commit', 'help'],
|
||||
default: { commit: false, throttle: DEFAULT_THROTTLE },
|
||||
})
|
||||
|
||||
if (args.help) {
|
||||
usage()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const inputFile = args._[0]
|
||||
const paramsSchema = z.object({
|
||||
output: z.string().optional(),
|
||||
commit: z.boolean(),
|
||||
throttle: z.number().int().positive(),
|
||||
inputFile: z.string().optional(),
|
||||
})
|
||||
|
||||
try {
|
||||
return paramsSchema.parse({
|
||||
output: args.output,
|
||||
commit: args.commit,
|
||||
throttle: args.throttle,
|
||||
inputFile,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Invalid arguments:', err.message)
|
||||
usage()
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await scriptRunner(main)
|
||||
process.exit(0)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* This script rolls back the cutover of a subscription from Recurly to Stripe.
|
||||
*
|
||||
* IMPORTANT: This script does NOT cancel the Stripe subscription.
|
||||
* Use scripts/stripe/bulk-cancel-subscriptions.mjs to cancel them separately.
|
||||
*
|
||||
* It undoes everything done by finalize-stripe-subscription-migration.mjs
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/stripe/rollback-finalized-stripe-migration.mjs [OPTS] [INPUT-FILE]
|
||||
*
|
||||
* Options:
|
||||
* --output PATH Output file path (default: /tmp/rollback_output_<timestamp>.csv)
|
||||
* --commit Apply changes (without this, runs in dry-run mode)
|
||||
* --throttle DURATION Minimum time between requests in ms (default: 40)
|
||||
* --help Show help message
|
||||
*
|
||||
* CSV Input Format:
|
||||
* recurly_account_code,target_stripe_account,stripe_customer_id
|
||||
* 507f1f77bcf86cd799439011,stripe-uk,cus_1234567890abcdef
|
||||
*
|
||||
* CSV Output Format:
|
||||
* recurly_account_code,target_stripe_account,stripe_customer_id,status,note
|
||||
*
|
||||
* Note: recurly_account_code is the Overleaf user ID (admin_id)
|
||||
*/
|
||||
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { setTimeout } from 'node:timers/promises'
|
||||
import * as csv from 'csv'
|
||||
import minimist from 'minimist'
|
||||
import { z } from '../../app/src/infrastructure/Validation.mjs'
|
||||
import { scriptRunner } from '../lib/ScriptRunner.mjs'
|
||||
import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
|
||||
import RecurlyWrapper from '../../app/src/Features/Subscription/RecurlyWrapper.mjs'
|
||||
import { Subscription } from '../../app/src/models/Subscription.mjs'
|
||||
import AnalyticsManager from '../../app/src/Features/Analytics/AnalyticsManager.mjs'
|
||||
import { ReportError } from './helpers.mjs'
|
||||
import AccountMappingHelper from '../../app/src/Features/Analytics/AccountMappingHelper.mjs'
|
||||
|
||||
const DEFAULT_THROTTLE = 40
|
||||
|
||||
function usage() {
|
||||
console.error(`Usage: node scripts/stripe/rollback-finalized-stripe-migration.mjs [OPTS] [INPUT-FILE]
|
||||
|
||||
Options:
|
||||
--output PATH Output file path (default: /tmp/rollback_output_<timestamp>.csv)
|
||||
--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
|
||||
|
||||
Note: This script does NOT cancel Stripe subscriptions. Use scripts/stripe/bulk-cancel-subscriptions.mjs separately.
|
||||
`)
|
||||
}
|
||||
|
||||
async function main(trackProgress) {
|
||||
const opts = parseArgs()
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const outputFile = opts.output ?? `/tmp/rollback_output_${timestamp}.csv`
|
||||
|
||||
await trackProgress('Starting Stripe to Recurly rollback')
|
||||
await trackProgress(`Run mode: ${opts.commit ? 'COMMIT' : 'DRY RUN'}`)
|
||||
await trackProgress(
|
||||
'Note: Stripe subscriptions are NOT cancelled by this script'
|
||||
)
|
||||
await trackProgress(`Throttle: ${opts.throttle}ms between requests`)
|
||||
|
||||
const inputStream = opts.inputFile
|
||||
? fs.createReadStream(opts.inputFile)
|
||||
: process.stdin
|
||||
const csvReader = getCsvReader(inputStream)
|
||||
const csvWriter = getCsvWriter(outputFile)
|
||||
|
||||
await trackProgress(`Output: ${outputFile}`)
|
||||
|
||||
let processedCount = 0
|
||||
let successCount = 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 processRollback(input, opts.commit)
|
||||
|
||||
csvWriter.write({
|
||||
recurly_account_code: input.recurly_account_code,
|
||||
target_stripe_account: input.target_stripe_account,
|
||||
stripe_customer_id: input.stripe_customer_id,
|
||||
status: result.status,
|
||||
note: result.note,
|
||||
})
|
||||
|
||||
if (
|
||||
result.status === 'rolled-back' ||
|
||||
result.status === 'validated' ||
|
||||
result.status === 'already-recurly'
|
||||
) {
|
||||
successCount++
|
||||
} else {
|
||||
errorCount++
|
||||
}
|
||||
|
||||
if (processedCount % 25 === 0) {
|
||||
await trackProgress(
|
||||
`Progress: ${processedCount} processed, ${successCount} successful, ${errorCount} errors`
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
errorCount++
|
||||
if (err instanceof ReportError) {
|
||||
csvWriter.write({
|
||||
recurly_account_code: input.recurly_account_code,
|
||||
target_stripe_account: input.target_stripe_account,
|
||||
stripe_customer_id: input.stripe_customer_id,
|
||||
status: err.status,
|
||||
note: err.message,
|
||||
})
|
||||
} else {
|
||||
csvWriter.write({
|
||||
recurly_account_code: input.recurly_account_code,
|
||||
target_stripe_account: input.target_stripe_account,
|
||||
stripe_customer_id: input.stripe_customer_id,
|
||||
status: 'error',
|
||||
note: err.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await trackProgress(`✅ Total processed: ${processedCount}`)
|
||||
if (opts.commit) {
|
||||
await trackProgress(`✅ Successfully rolled back: ${successCount}`)
|
||||
} else {
|
||||
await trackProgress(`✅ Successfully validated: ${successCount}`)
|
||||
await trackProgress('ℹ️ DRY RUN: No changes were applied')
|
||||
}
|
||||
await trackProgress(`❌ Errors: ${errorCount}`)
|
||||
await trackProgress('🎉 Script completed!')
|
||||
|
||||
csvWriter.end()
|
||||
}
|
||||
|
||||
function getCsvReader(inputStream) {
|
||||
const parser = csv.parse({ columns: true })
|
||||
inputStream.pipe(parser)
|
||||
return parser
|
||||
}
|
||||
|
||||
function getCsvWriter(outputFile) {
|
||||
fs.mkdirSync(path.dirname(outputFile), { recursive: true })
|
||||
const outputStream = fs.createWriteStream(outputFile)
|
||||
|
||||
const writer = csv.stringify({
|
||||
columns: [
|
||||
'recurly_account_code',
|
||||
'target_stripe_account',
|
||||
'stripe_customer_id',
|
||||
'status',
|
||||
'note',
|
||||
],
|
||||
header: true,
|
||||
})
|
||||
|
||||
writer.on('error', err => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
writer.pipe(outputStream)
|
||||
return writer
|
||||
}
|
||||
|
||||
async function processRollback(input, commit) {
|
||||
const {
|
||||
recurly_account_code: accountCode,
|
||||
target_stripe_account: targetStripeAccount,
|
||||
} = input
|
||||
|
||||
// Get Stripe client for the target account (strip 'stripe-' prefix if present)
|
||||
const region = targetStripeAccount.replace(/^stripe-/, '')
|
||||
const stripeClient = getRegionClient(region)
|
||||
|
||||
// 1. Fetch Mongo subscription
|
||||
const mongoSubscription = await Subscription.findOne({
|
||||
admin_id: accountCode,
|
||||
}).exec()
|
||||
if (!mongoSubscription) {
|
||||
throw new ReportError(
|
||||
'no-mongo-subscription',
|
||||
'No subscription found in Mongo'
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Check if already using Recurly
|
||||
if (
|
||||
mongoSubscription.recurlySubscription_id &&
|
||||
!mongoSubscription.paymentProvider?.service?.includes('stripe')
|
||||
) {
|
||||
throw new ReportError(
|
||||
'already-recurly',
|
||||
'Subscription already using Recurly'
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Verify subscription is using Stripe
|
||||
if (!mongoSubscription.paymentProvider?.service?.includes('stripe')) {
|
||||
throw new ReportError(
|
||||
'not-using-stripe',
|
||||
'Subscription is not using Stripe'
|
||||
)
|
||||
}
|
||||
|
||||
const stripeSubscriptionId = mongoSubscription.paymentProvider.subscriptionId
|
||||
|
||||
// 4. Find Recurly subscription ID from Stripe metadata
|
||||
let recurlySubscriptionId
|
||||
try {
|
||||
const stripeSubData =
|
||||
await stripeClient.stripe.subscriptions.retrieve(stripeSubscriptionId)
|
||||
recurlySubscriptionId = stripeSubData.metadata?.recurly_subscription_id
|
||||
if (!recurlySubscriptionId) {
|
||||
throw new ReportError(
|
||||
'no-recurly-id-in-metadata',
|
||||
'No recurly_subscription_id found in Stripe metadata'
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ReportError) throw err
|
||||
throw new ReportError(
|
||||
'stripe-fetch-error',
|
||||
`Failed to fetch Stripe subscription: ${err.message}`
|
||||
)
|
||||
}
|
||||
|
||||
// 5. Fetch Recurly subscription to get original billing date
|
||||
let recurlySubscription
|
||||
try {
|
||||
recurlySubscription = await RecurlyWrapper.promises.getSubscription(
|
||||
recurlySubscriptionId,
|
||||
{}
|
||||
)
|
||||
} catch (err) {
|
||||
throw new ReportError(
|
||||
'no-recurly-subscription',
|
||||
`Recurly subscription not found: ${err.message}`
|
||||
)
|
||||
}
|
||||
|
||||
// 6. If commit mode, perform rollback
|
||||
if (commit) {
|
||||
await performRollback(mongoSubscription, recurlySubscription, stripeClient)
|
||||
return {
|
||||
status: 'rolled-back',
|
||||
note: 'Successfully rolled back to Recurly',
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
status: 'validated',
|
||||
note: 'DRY RUN: Ready to rollback to Recurly',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function performRollback(
|
||||
mongoSubscription,
|
||||
recurlySubscription,
|
||||
stripeClient
|
||||
) {
|
||||
const adminUserId = mongoSubscription.admin_id.toString()
|
||||
const recurlySubscriptionId = recurlySubscription.uuid
|
||||
const stripeSubscriptionId = mongoSubscription.paymentProvider.subscriptionId
|
||||
|
||||
// Step 1: Restore Recurly fields in Mongo
|
||||
mongoSubscription.recurlySubscription_id = recurlySubscriptionId
|
||||
mongoSubscription.recurlyStatus = {
|
||||
state: recurlySubscription.state,
|
||||
trialStartedAt: recurlySubscription.trial_started_at,
|
||||
trialEndsAt: recurlySubscription.trial_ends_at,
|
||||
}
|
||||
mongoSubscription.paymentProvider = undefined
|
||||
await mongoSubscription.save()
|
||||
|
||||
// Step 2: Emit rollback analytics event
|
||||
AnalyticsManager.recordEventForUserInBackground(
|
||||
adminUserId,
|
||||
'subscription-rolled-back-from-stripe',
|
||||
{
|
||||
subscriptionId: mongoSubscription._id.toString(),
|
||||
migrationDirection: 'stripe-to-recurly',
|
||||
}
|
||||
)
|
||||
|
||||
// Step 3: Un-postpone Recurly billing by 10 years
|
||||
const currentPeriodEnd = new Date(recurlySubscription.current_period_ends_at)
|
||||
const nextBillingDate = new Date(currentPeriodEnd)
|
||||
nextBillingDate.setFullYear(currentPeriodEnd.getFullYear() - 10)
|
||||
const targetBillingDateIsInFuture = nextBillingDate.getTime() > Date.now()
|
||||
|
||||
if (targetBillingDateIsInFuture) {
|
||||
try {
|
||||
await RecurlyWrapper.promises.apiRequest({
|
||||
url: `subscriptions/${recurlySubscriptionId}/postpone`,
|
||||
qs: { bulk: true, next_bill_date: nextBillingDate },
|
||||
method: 'PUT',
|
||||
})
|
||||
} catch (err) {
|
||||
throw new ReportError(
|
||||
'rolled-back-recurly-restore-failed',
|
||||
`Restored Mongo but failed to restore Recurly billing: ${err.message}`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
throw new ReportError(
|
||||
'rolled-back-recurly-restore-failed',
|
||||
`Restored Mongo and Recurly but failed to restore Recurly billing: target next billing date is in the past (${nextBillingDate.toISOString()})`
|
||||
)
|
||||
}
|
||||
|
||||
// Step 4: Restore migration metadata to Stripe
|
||||
try {
|
||||
await stripeClient.updateSubscriptionMetadata(stripeSubscriptionId, {
|
||||
recurly_to_stripe_migration_status: 'in_progress',
|
||||
})
|
||||
} catch (err) {
|
||||
throw new ReportError(
|
||||
'rolled-back-metadata-restore-failed',
|
||||
`Restored Mongo and Recurly but failed to restore Stripe metadata: ${err.message}`
|
||||
)
|
||||
}
|
||||
|
||||
// Step 5: Register analytics mapping for the Recurly subscription
|
||||
try {
|
||||
AnalyticsManager.registerAccountMapping(
|
||||
AccountMappingHelper.generateSubscriptionToRecurlyMapping(
|
||||
mongoSubscription._id,
|
||||
recurlySubscriptionId,
|
||||
'recurly'
|
||||
)
|
||||
)
|
||||
} catch (err) {
|
||||
throw new ReportError(
|
||||
'rolled-back-analytics-mapping-failed',
|
||||
`Restored Mongo, Recurly, Stripe but failed to register analytics mapping: ${err.message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs() {
|
||||
const args = minimist(process.argv.slice(2), {
|
||||
string: ['output'],
|
||||
number: ['throttle'],
|
||||
boolean: ['commit', 'help'],
|
||||
default: {
|
||||
commit: false,
|
||||
throttle: DEFAULT_THROTTLE,
|
||||
},
|
||||
})
|
||||
|
||||
if (args.help) {
|
||||
usage()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const inputFile = args._[0]
|
||||
const paramsSchema = z.object({
|
||||
output: z.string().optional(),
|
||||
commit: z.boolean(),
|
||||
throttle: z.number().int().positive(),
|
||||
inputFile: z.string().optional(),
|
||||
})
|
||||
|
||||
try {
|
||||
return paramsSchema.parse({
|
||||
output: args.output,
|
||||
commit: args.commit,
|
||||
throttle: args.throttle,
|
||||
inputFile,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Invalid arguments:', err.message)
|
||||
usage()
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await scriptRunner(main)
|
||||
process.exit(0)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
}
|
||||
Reference in New Issue
Block a user