diff --git a/services/web/scripts/stripe/finalize-stripe-subscription-migration.mjs b/services/web/scripts/stripe/finalize-stripe-subscription-migration.mjs index e715d88670..980c7e257b 100755 --- a/services/web/scripts/stripe/finalize-stripe-subscription-migration.mjs +++ b/services/web/scripts/stripe/finalize-stripe-subscription-migration.mjs @@ -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() { diff --git a/services/web/scripts/stripe/rollback-finalized-stripe-migration.mjs b/services/web/scripts/stripe/rollback-finalized-stripe-migration.mjs index 182b16e938..4c390141b7 100755 --- a/services/web/scripts/stripe/rollback-finalized-stripe-migration.mjs +++ b/services/web/scripts/stripe/rollback-finalized-stripe-migration.mjs @@ -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() {