[web] update finalization scripts for phase 2 (#31293)

* handle group quantities & add-ons when detecting changes
* include analyticsId in output
* flag users in customer-io
* rely on customer metadata for tax info needing updating
* rollback customer.io data in rollback script
* refactor how we handle errors when migrating to avoid losing data for output

GitOrigin-RevId: f77430b0b366217ac85b72dde92e9364dc879023
This commit is contained in:
Kristina
2026-02-05 11:59:25 +01:00
committed by Copybot
parent 35906b4018
commit 4decfd343d
2 changed files with 175 additions and 77 deletions

View File

@@ -24,7 +24,7 @@
* 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
* recurly_account_code,target_stripe_account,stripe_customer_id,previous_recurly_status,previous_recurly_subscription_id,email,analyticsId,status,note
*
* Note: recurly_account_code is the Overleaf user ID (admin_id)
*/
@@ -44,7 +44,11 @@ import RecurlyWrapper from '../../app/src/Features/Subscription/RecurlyWrapper.m
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 PlansLocator from '../../app/src/Features/Subscription/PlansLocator.mjs'
import UserAnalyticsIdCache from '../../app/src/Features/Analytics/UserAnalyticsIdCache.mjs'
import CustomerIoHandler from '../../modules/customer-io/app/src/CustomerIoHandler.mjs'
import { ReportError } from './helpers.mjs'
import isEqual from 'lodash/isEqual.js'
const DEFAULT_THROTTLE = 40
@@ -76,10 +80,10 @@ async function main(trackProgress) {
const csvReader = getCsvReader(inputStream)
const csvWriter = getCsvWriter(outputFile)
await trackProgress('Populating product metadata cache...')
await trackProgress('Populating product metadata...')
await preloadProductMetadata('uk')
await preloadProductMetadata('us')
await trackProgress('Product metadata cache populated')
await trackProgress('Product metadata populated')
await trackProgress(`Output: ${outputFile}`)
@@ -108,11 +112,15 @@ async function main(trackProgress) {
previous_recurly_subscription_id:
result.previousRecurlySubscriptionId || '',
email: result.email || '',
analyticsId: result.analyticsId || '',
status: result.status,
note: result.note,
})
if (result.status === 'migrated' || result.status === 'validated') {
if (
result.status.startsWith('migrated') ||
result.status === 'validated'
) {
successCount++
} else {
errorCount++
@@ -133,6 +141,7 @@ async function main(trackProgress) {
previous_recurly_status: '',
previous_recurly_subscription_id: '',
email: '',
analyticsId: '',
status: err.status,
note: err.message,
})
@@ -144,6 +153,7 @@ async function main(trackProgress) {
previous_recurly_status: '',
previous_recurly_subscription_id: '',
email: '',
analyticsId: '',
status: 'error',
note: err.message,
})
@@ -162,6 +172,7 @@ async function main(trackProgress) {
await trackProgress('🎉 Script completed!')
csvWriter.end()
await CustomerIoHandler.closeCustomerIo()
}
function getCsvReader(inputStream) {
@@ -182,6 +193,7 @@ function getCsvWriter(outputFile) {
'previous_recurly_status',
'previous_recurly_subscription_id',
'email',
'analyticsId',
'status',
'note',
],
@@ -206,12 +218,12 @@ async function preloadProductMetadata(region) {
limit: 100,
})
const cache = new Map()
const results = new Map()
for (const product of products.data) {
cache.set(product.id, product.metadata)
results.set(product.id, product.metadata)
}
preloadedProductMetadata.set(region, cache)
preloadedProductMetadata.set(region, results)
}
async function processMigration(input, commit) {
@@ -299,92 +311,125 @@ async function processMigration(input, commit) {
// 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,
}
throw new ReportError(
'changes-detected',
`Changes detected between Recurly and Stripe: ${changes.join('; ')}`
)
}
// 7. If commit mode, perform migration
const adminUserId = mongoSubscription.admin_id.toString()
const analyticsId = await UserAnalyticsIdCache.get(adminUserId)
const result = {
status: 'not-migrated',
note: 'Not yet migrated',
previousRecurlyStatus,
previousRecurlySubscriptionId,
email: stripeCustomer.email,
analyticsId,
}
if (commit) {
await performCutover(
mongoSubscription,
stripeSubscription,
recurlySubscription,
stripeClient,
stripeCustomer
)
return {
status: 'migrated',
note: 'Successfully migrated to Stripe',
previousRecurlyStatus,
previousRecurlySubscriptionId,
email: stripeCustomer.email,
try {
await performCutover(
mongoSubscription,
stripeSubscription,
recurlySubscription,
stripeClient,
stripeCustomer,
analyticsId
)
} catch (err) {
if (err instanceof ReportError && err.status?.startsWith('migrated-')) {
result.status = err.status
result.note = err.message
return result
}
throw err
}
result.status = 'migrated'
result.note = 'Successfully migrated to Stripe'
return result
} else {
return {
status: 'validated',
note: 'DRY RUN: Ready to migrate',
previousRecurlyStatus,
previousRecurlySubscriptionId,
email: stripeCustomer.email,
}
result.status = 'validated'
result.note = 'DRY RUN: Ready to migrate'
return result
}
}
// 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',
/**
* Format subscription items for display in error messages
*/
function formatItems(items) {
return items
.map(item => `${item.code}(qty:${item.quantity},amt:${item.amount})`)
.join(', ')
}
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 details from Recurly subscription
const recurlyPlanItem =
PlansLocator.convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded(
recurlySubscription.plan.plan_code
)
const simplifiedPlanCode = recurlyPlanItem.planCode.replace(
/_free_trial.*$/,
''
)
const additionalLicenseQuantity =
(recurlySubscription.subscription_add_ons || []).find(
addOn => addOn.add_on_code === 'additional-license'
)?.quantity || 0
const recurlyItems = [
{
code: simplifiedPlanCode,
quantity: recurlyPlanItem.quantity + additionalLicenseQuantity,
amount:
recurlySubscription.unit_amount_in_cents / recurlyPlanItem.quantity,
},
...(recurlySubscription.subscription_add_ons || [])
.filter(addOn => addOn.add_on_code !== 'additional-license')
.map(addOn => ({
code: addOn.add_on_code,
quantity: addOn.quantity,
amount: addOn.unit_amount_in_cents,
})),
].sort((a, b) => a.code.localeCompare(b.code))
// 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()
)
// Extract item details from Stripe subscription
const products = preloadedProductMetadata.get(region)
const hasAddOns = stripeSubscription.items.data.length > 1
const stripeItems = stripeSubscription.items.data
.map(item => {
const productMetadata = products.get(item.price.product)
if (!productMetadata) {
throw new ReportError(
'unknown-stripe-product',
`Unknown Stripe product: ${item.price.product}`
)
}
// Compare item codes
if (recurlyItemCodes !== stripeItemCodes) {
return {
code:
productMetadata?.planCode?.includes('assistant') && hasAddOns
? productMetadata?.addOnCode
: productMetadata?.planCode,
quantity: item.quantity,
amount: item.price.unit_amount,
}
})
.sort((a, b) => a.code.localeCompare(b.code))
// Compare items
if (!isEqual(recurlyItems, stripeItems)) {
changes.push(
`Items: Recurly=[${recurlyItemCodes}], Stripe=[${stripeItemCodes}]`
`Items: Recurly=[${formatItems(recurlyItems)}], Stripe=[${formatItems(stripeItems)}]`
)
}
// TODO: compare quantities for each item, taking additional-license add-ons into account
// Compare states
const recurlyState = recurlySubscription.state
const stripeState = convertStripeStatusToSubscriptionState(stripeSubscription)
@@ -405,7 +450,8 @@ async function performCutover(
stripeSubscription,
recurlySubscription,
stripeClient,
stripeCustomer
stripeCustomer,
analyticsId
) {
const adminUserId = mongoSubscription.admin_id.toString()
@@ -419,7 +465,14 @@ async function performCutover(
mongoSubscription.recurlySubscription_id = undefined
mongoSubscription.recurlyStatus = undefined
await mongoSubscription.save()
try {
await mongoSubscription.save()
} catch (err) {
throw new ReportError(
'not-migrated-mongo-update-failed',
`Failed to update Mongo subscription: ${err.message}`
)
}
// Step 2: Emit migration analytics event
AnalyticsManager.recordEventForUserInBackground(
@@ -446,7 +499,10 @@ async function performCutover(
method: 'PUT',
})
} catch (err) {
throw new Error(`Failed to postpone Recurly billing: ${err.message}`)
throw new ReportError(
'migrated-recurly-postpone-failed',
`Failed to postpone Recurly billing: ${err.message}`
)
}
}
@@ -473,7 +529,7 @@ async function performCutover(
)
} catch (err) {
throw new ReportError(
'analytics-mapping-failed',
'migrated-analytics-mapping-failed',
`Successfully migrated to Stripe but failed to register analytics mapping: ${err.message}`
)
}
@@ -491,11 +547,35 @@ async function performCutover(
})
} catch (err) {
throw new ReportError(
'customer-metadata-removal-failed',
'migrated-customer-metadata-removal-failed',
`Successfully migrated to Stripe and registered analytics mapping but failed to remove customer metadata: ${err.message}`
)
}
}
// Step 7. Send data to customer.io
if (analyticsId) {
try {
const migrationDate = new Date().toISOString().slice(0, 10)
const needsToUpdateTaxInfo =
(stripeCustomer.metadata?.taxInfoPending || '').length > 0
// TODO: request Recurly account and billingInfo to verify if tax info in Stripe is up to date
CustomerIoHandler.updateUserAttributes(analyticsId, {
email: stripeCustomer.email,
stripe_migration: {
migration_date: migrationDate,
needs_to_update_tax_id: needsToUpdateTaxInfo,
},
})
} catch (err) {
throw new ReportError(
'migrated-customerio-upload-failed',
`Successfully migrated to Stripe but failed to upload user to customer.io: ${err.message}`
)
}
}
}
function parseArgs() {

View File

@@ -38,6 +38,8 @@ import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClien
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 UserAnalyticsIdCache from '../../app/src/Features/Analytics/UserAnalyticsIdCache.mjs'
import CustomerIoHandler from '../../modules/customer-io/app/src/CustomerIoHandler.mjs'
import { ReportError } from './helpers.mjs'
import AccountMappingHelper from '../../app/src/Features/Analytics/AccountMappingHelper.mjs'
@@ -149,6 +151,7 @@ async function main(trackProgress) {
await trackProgress('🎉 Script completed!')
csvWriter.end()
await CustomerIoHandler.closeCustomerIo()
}
function getCsvReader(inputStream) {
@@ -359,6 +362,21 @@ async function performRollback(
`Restored Mongo, Recurly, Stripe but failed to register analytics mapping: ${err.message}`
)
}
// Step 5: Remove migration date from customer.io
const analyticsId = await UserAnalyticsIdCache.get(adminUserId)
if (analyticsId) {
try {
CustomerIoHandler.updateUserAttributes(analyticsId, {
stripe_migration: {},
})
} catch (err) {
throw new ReportError(
'rolled-back-customerio-update-failed',
`Restored Mongo, Recurly, Stripe but failed to update user in customer.io: ${err.message}`
)
}
}
}
function parseArgs() {