mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 17:19:37 +02:00
[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:
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user