|
|
@@ -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 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}`
|
|
|
+ )
|
|
|
+ }
|
|
|
|
|
|
- // 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()
|
|
|
- )
|
|
|
+ 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 item codes
|
|
|
- if (recurlyItemCodes !== stripeItemCodes) {
|
|
|
+ // 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() {
|