Browse Source

[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
Kristina 6 months ago
parent
commit
4decfd343d

+ 157 - 77
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 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() {

+ 18 - 0
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() {