ソースを参照

[web] add support for migrating paypal subscriptions (#31607)

* support Paypal for migration
* remove sensitive payment info in logging
* add `resolveStripeCustomer`

GitOrigin-RevId: a8fc7de4a4bf3971c5221a0dec3fc279d8d2f67d
Kristina 7 ヶ月 前
親
コミット
820adf568e

+ 30 - 0
services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.mjs

@@ -898,6 +898,36 @@ export function getTaxIdType(country, taxIdValue, postalCode) {
   return countryTaxIdTypes[upperCountry] || null
 }
 
+/**
+ * Remove paypal billing agreement id from account for logging purposes
+ *
+ * @param {object} account
+ * @returns {object} sanitized account object
+ */
+export function sanitizeAccount(account) {
+  return {
+    ...account,
+    billingInfo: sanitizeBillingInfo(account.billingInfo),
+  }
+}
+
+/**
+ * Remove paypal billing agreement id from billing info for logging purposes
+ *
+ * @param {object} billingInfo
+ * @returns {object} sanitized billing info object
+ */
+export function sanitizeBillingInfo(billingInfo) {
+  const sanitizedBillingInfo = structuredClone(billingInfo)
+  if (
+    sanitizedBillingInfo?.paymentMethod?.object === 'paypal_billing_agreement'
+  ) {
+    sanitizedBillingInfo.paymentMethod.billingAgreementId =
+      'REDACTED_FOR_LOGGING'
+  }
+  return sanitizedBillingInfo
+}
+
 /**
  *
  * @param {Stripe.PaymentMethod[]} paymentMethods

+ 7 - 7
services/web/scripts/recurly/cleanup-recurly-subscriptions-post-migration.mjs

@@ -3,13 +3,13 @@
 /**
  * This script CLEANS UP Recurly subscriptions after migration to Stripe is finalized.
  *
- * IMPORTANT: Only run this AFTER the cutover is complete, verified, and
- * we've confirmed that Stripe is working correctly.
- *
- * WARNING: After running this script, rollback is NO LONGER POSSIBLE.
- *
- * NOTE: This script will trigger lifecycle emails to be sent. Please turn off:
- * - "Subscription Expired Template" (https://sharelatex.recurly.com/emails/subscription_expired/template/edit)
+ * ⚠️ IMPORTANT NOTES:
+ * - Only run this AFTER the cutover from Recurly to Stripe is complete and verified
+ * - After running this script, rollback is NO LONGER POSSIBLE
+ * - NEVER extend this script to close Recurly accounts or remove billing info for Paypal
+ *   (could trigger PayPal billing agreement cancellation)
+ * - This script will trigger lifecycle emails to be sent. Please turn off:
+ *     "Subscription Expired Template" (https://sharelatex.recurly.com/emails/subscription_expired/template/edit)
  *
  * Usage:
  *   node scripts/recurly/cleanup-recurly-subscriptions-post-migration.mjs [OPTS] [INPUT-FILE]

+ 284 - 144
services/web/scripts/recurly/migrate_recurly_customers_to_stripe.mjs

@@ -29,9 +29,6 @@
  *   --output (success file): Records that were successfully updated
  *     Format: recurly_account_code,target_stripe_account,stripe_customer_id
  *
- *   <output>_skipped_no_stripe_id.csv: Records skipped because stripe_customer_id was missing
- *     Format: recurly_account_code,target_stripe_account,stripe_customer_id
- *
  *   <output>_errors.csv: Records that failed (overwritten each run)
  *     Format: recurly_account_code,target_stripe_account,stripe_customer_id,error
  *
@@ -99,6 +96,8 @@ import {
   coalesceOrThrowPaymentMethod,
   coalesceOrThrowVATNumber,
   getTaxIdType,
+  sanitizeAccount,
+  sanitizeBillingInfo,
 } from '../helpers/migrate_recurly_customers_to_stripe.helpers.mjs'
 import {
   createRateLimitedApiWrappers,
@@ -370,22 +369,19 @@ function formatCsvRow(columns, row) {
 }
 
 /**
- * Create output writers for success, error, and skipped files.
+ * Create output writers for success and error files.
  *
  * Success file: Append-only, contains all successfully updated records
  * Errors file: Overwritten each run, contains only failures from this run
- * Skipped file: Overwritten each run, contains records skipped because they have no stripe_customer_id
  *
  * @param {string} successPath - Path to the success output CSV file
  * @param {string} errorsPath - Path to the errors output CSV file
- * @param {string} skippedPath - Path to the skipped_no_stripe_id output CSV file
  * @param {boolean} restart - If true, truncate existing files
- * @returns {{ writeSuccess: (row: object) => void, writeError: (row: object) => void, writeSkipped: (row: object) => void, close: () => Promise<void> }}
+ * @returns {{ writeSuccess: (row: object) => void, writeError: (row: object) => void, close: () => Promise<void> }}
  */
 function createOutputWriters(
   successPath,
   errorsPath,
-  skippedPath,
   restart = false,
   { enableSuccessFile = true } = {}
 ) {
@@ -404,13 +400,6 @@ function createOutputWriters(
     'error',
   ]
 
-  // Skipped file columns (same as success, stripe_customer_id is the existing one)
-  const skippedColumns = [
-    'recurly_account_code',
-    'target_stripe_account',
-    'stripe_customer_id',
-  ]
-
   // Success file: append mode (unless restart)
   // NOTE: In dry-run mode, we intentionally do NOT create or write to the success file,
   // because commit mode uses it for resume/skip behavior.
@@ -432,10 +421,6 @@ function createOutputWriters(
   const errorsStream = fs.createWriteStream(errorsPath, { flags: 'w' })
   errorsStream.write(errorsColumns.join(',') + '\n')
 
-  // Skipped file: always overwrite (contains only this run's skipped)
-  const skippedStream = fs.createWriteStream(skippedPath, { flags: 'w' })
-  skippedStream.write(skippedColumns.join(',') + '\n')
-
   function writeSuccess(row) {
     if (!successStream) return
     successStream.write(formatCsvRow(successColumns, row))
@@ -445,24 +430,15 @@ function createOutputWriters(
     errorsStream.write(formatCsvRow(errorsColumns, row))
   }
 
-  function writeSkipped(row) {
-    skippedStream.write(formatCsvRow(skippedColumns, row))
-  }
-
   async function close() {
     if (successStream) successStream.end()
     errorsStream.end()
-    skippedStream.end()
 
     const closers = [
       new Promise((resolve, reject) => {
         errorsStream.on('finish', resolve)
         errorsStream.on('error', reject)
       }),
-      new Promise((resolve, reject) => {
-        skippedStream.on('finish', resolve)
-        skippedStream.on('error', reject)
-      }),
     ]
 
     if (successStream) {
@@ -477,7 +453,7 @@ function createOutputWriters(
     await Promise.all(closers)
   }
 
-  return { writeSuccess, writeError, writeSkipped, close }
+  return { writeSuccess, writeError, close }
 }
 
 /**
@@ -487,13 +463,6 @@ function getErrorsPath(successPath) {
   return successPath.replace(/\.csv$/, '_errors.csv')
 }
 
-/**
- * Get the skipped_no_stripe_id file path from the success file path
- */
-function getSkippedPath(successPath) {
-  return successPath.replace(/\.csv$/, '_skipped_no_stripe_id.csv')
-}
-
 /**
  * Get the stripe.json file path from the success file path (for dry-run mode)
  */
@@ -628,13 +597,13 @@ async function fetchOtherStripeCustomerByUserId(
       stripeClient.customers.search({
         query: `metadata['userId']:"${userId}"`,
         limit: 100,
+        expand: ['data.subscriptions'],
       }),
     { ...context, stripeApi: 'customers.search' }
   )
 
   return (
-    results.data?.find(customer => customer.id !== context.stripeCustomerId) ||
-    null
+    results.data?.find(customer => customer.id !== stripeCustomerId) || null
   )
 }
 
@@ -658,6 +627,148 @@ async function fetchTargetStripeCustomerPaymentMethods(
   return paymentMethods.data
 }
 
+/**
+ * Creates a Stripe Setup Intent to import a PayPal billing agreement.
+ *
+ * @param {Stripe} stripeClient - The Stripe client for the target account
+ * @param {string} stripeCustomerId - The Stripe customer ID
+ * @param {string} billingAgreementId - The PayPal billing agreement ID
+ * @param {object} context - Logging context
+ * @returns {Promise<Stripe.PaymentMethod>}
+ * @throws {Error} If the setup intent fails or does not produce a payment method
+ */
+async function createPayPalPaymentMethod(
+  stripeClient,
+  stripeCustomerId,
+  billingAgreementId,
+  context
+) {
+  logDebug(
+    'Creating PayPal setup intent',
+    {
+      ...context,
+      step: 'create_paypal_setup_intent',
+    },
+    { verboseOnly: true }
+  )
+
+  const setupIntent = await rateLimiters.requestWithRetries(
+    stripeClient.serviceName,
+    () =>
+      stripeClient.setupIntents.create({
+        customer: stripeCustomerId,
+        payment_method_types: ['paypal'],
+        payment_method_data: {
+          type: 'paypal',
+        },
+        payment_method_options: {
+          paypal: {
+            billing_agreement_id: billingAgreementId,
+          },
+        },
+        confirm: true,
+        usage: 'off_session',
+        mandate_data: {
+          customer_acceptance: {
+            type: 'offline',
+          },
+        },
+        return_url: `${Settings.siteUrl}/user/subscription`, // required for PayPal setup intents, but not actually used since we're confirming immediately
+        expand: ['payment_method'],
+      }),
+    { ...context, stripeApi: 'setupIntents.create' }
+  )
+
+  if (setupIntent.status !== 'succeeded') {
+    throw new Error(
+      `PayPal setup intent ${setupIntent.id} has unexpected status: ${setupIntent.status}`
+    )
+  }
+
+  if (!setupIntent.payment_method) {
+    throw new Error(
+      `PayPal setup intent ${setupIntent.id} succeeded but has no payment_method`
+    )
+  }
+
+  logDebug(
+    'Successfully created PayPal setup intent',
+    {
+      ...context,
+      setupIntentId: setupIntent.id,
+      paymentMethodId: setupIntent.payment_method.id,
+    },
+    { verboseOnly: true }
+  )
+
+  // The setup intent returns the full payment method object, but we only need the ID
+  // to set it as the default on the customer.
+  return setupIntent.payment_method
+}
+
+/**
+ * Determines the payment method to set on the Stripe customer.
+ *
+ * This handles both migrating a PayPal billing agreement and matching an existing
+ * credit card payment method.
+ *
+ * @param {Stripe} stripeClient - The Stripe client for the target account
+ * @param {string} stripeCustomerId - The Stripe customer ID
+ * @param {object} billingInfo - Recurly billing info object
+ * @param {object} address - The customer's address (used for PayPal country check)
+ * @param {boolean} commit - Whether this is a dry-run or a commit
+ * @param {object} context - Logging context
+ * @returns {Promise<Stripe.PaymentMethod>}
+ * @throws {Error} If the payment method cannot be determined or created
+ */
+async function getPaymentMethod(
+  stripeClient,
+  stripeCustomerId,
+  billingInfo,
+  address,
+  commit,
+  context
+) {
+  if (billingInfo?.paymentMethod?.object === 'paypal_billing_agreement') {
+    const addressCountry = address?.country
+    if (
+      addressCountry === 'CA' ||
+      addressCountry === 'US' ||
+      stripeClient.serviceName === 'stripe-us'
+    ) {
+      throw new Error(
+        `PayPal billing agreement migration is not supported for ${addressCountry} customers`
+      )
+    }
+
+    if (commit) {
+      return await createPayPalPaymentMethod(
+        stripeClient,
+        stripeCustomerId,
+        billingInfo.paymentMethod.billingAgreementId,
+        context
+      )
+    } else {
+      logDebug('DRY RUN: Would create PayPal setup intent', context, {
+        verboseOnly: true,
+      })
+      // Return a placeholder for dry-run output
+      return { id: 'pm_placeholder_paypal_dry_run', type: 'paypal' }
+    }
+  }
+
+  const paymentMethods = await fetchTargetStripeCustomerPaymentMethods(
+    stripeClient,
+    stripeCustomerId,
+    context
+  )
+  return coalesceOrThrowPaymentMethod(
+    paymentMethods,
+    stripeCustomerId,
+    billingInfo
+  )
+}
+
 /**
  * Replace a customer's tax IDs (delete any existing, then create the desired one).
  *
@@ -789,6 +900,104 @@ function addressesEqual(a, b) {
 // MAIN PROCESSING
 // =============================================================================
 
+/**
+ * Resolve the Stripe customer for a given Recurly account.
+ *
+ * Handles three cases:
+ * 1. Another customer with matching userId metadata exists when no stripeCustomerId is provided → reuse it
+ * 2. No stripeCustomerId provided → create a new customer (or placeholder in dry-run)
+ * 3. stripeCustomerId provided → fetch the existing customer
+ *
+ * @param {object} params
+ * @param {Stripe} params.stripeClient - Stripe SDK client for the target account
+ * @param {string|null} params.stripeCustomerId - Stripe customer ID from the input CSV (may be empty)
+ * @param {string} params.recurlyAccountCode - Recurly account code / Overleaf user ID
+ * @param {object} params.account - Recurly account object (used for email on create)
+ * @param {boolean} params.commit - Whether to actually create/fetch in Stripe
+ * @param {object} params.context - Logging context
+ * @param {object} params.stripeContext - Stripe-specific logging context
+ * @returns {Promise<Stripe.Customer|object>} - The resolved Stripe customer object (or placeholder in dry-run)
+ * @throws {Error} if there are multiple matching customers found
+ */
+async function resolveStripeCustomer({
+  stripeClient,
+  stripeCustomerId,
+  recurlyAccountCode,
+  account,
+  commit,
+  context,
+  stripeContext,
+}) {
+  const otherMatchingCustomer = await fetchOtherStripeCustomerByUserId(
+    stripeClient,
+    recurlyAccountCode,
+    stripeCustomerId,
+    stripeContext
+  )
+
+  if (otherMatchingCustomer) {
+    if (stripeCustomerId) {
+      throw new Error(
+        `Found another Stripe customer with matching userId metadata: ${otherMatchingCustomer.id}`
+      )
+    }
+
+    logDebug(
+      'Found Stripe customer with matching userId metadata, reusing',
+      { ...context, otherStripeCustomerId: otherMatchingCustomer.id },
+      { verboseOnly: true }
+    )
+    return otherMatchingCustomer
+  }
+
+  if (!stripeCustomerId) {
+    if (commit) {
+      const newCustomer = await rateLimiters.requestWithRetries(
+        stripeClient.serviceName,
+        () =>
+          stripeClient.customers.create({
+            email: account.email,
+            metadata: { userId: recurlyAccountCode },
+          }),
+        { ...stripeContext, stripeApi: 'customers.create' }
+      )
+      logDebug(
+        'Created new Stripe customer',
+        { ...context, newStripeCustomerId: newCustomer.id },
+        { verboseOnly: true }
+      )
+      return newCustomer
+    }
+
+    logDebug(
+      'DRY RUN: Would create new Stripe customer',
+      { ...context, step: 'create_stripe_customer' },
+      { verboseOnly: true }
+    )
+    return {
+      id: 'cus_dry_run_new_customer_placeholder',
+      metadata: { userId: recurlyAccountCode },
+    }
+  }
+
+  logDebug(
+    'Fetching existing Stripe customer',
+    { ...context, step: 'fetch_stripe_customer' },
+    { verboseOnly: true }
+  )
+  const customer = await fetchTargetStripeCustomer(
+    stripeClient,
+    stripeCustomerId,
+    stripeContext
+  )
+  logDebug(
+    'Resolved existing Stripe customer',
+    { ...context, stripeEmail: customer.email, stripeName: customer.name },
+    { verboseOnly: true }
+  )
+  return customer
+}
+
 /**
  * Process a single customer row from the input CSV.
  *
@@ -810,8 +1019,8 @@ async function processCustomer(
   const {
     recurly_account_code: recurlyAccountCode,
     target_stripe_account: targetStripeAccount,
-    stripe_customer_id: stripeCustomerId,
   } = row
+  let stripeCustomerId = row.stripe_customer_id
 
   const context = {
     rowNumber,
@@ -830,7 +1039,7 @@ async function processCustomer(
     recurly_account_code: recurlyAccountCode,
     target_stripe_account: targetStripeAccount,
     stripe_customer_id: stripeCustomerId || '',
-    outcome: '', // 'updated', 'dry_run', 'skipped_no_stripe_id', or 'error'
+    outcome: '', // 'updated', 'dry_run', or 'error'
     error: '',
     customerParams: null, // Stripe customer params (for dry-run output)
     taxInfoPending: null, // Recurly VAT number if tax ID type couldn't be determined
@@ -845,22 +1054,6 @@ async function processCustomer(
       throw new Error('Missing required field: target_stripe_account')
     }
 
-    // TODO: In a later phase, records without stripe_customer_id will be
-    // handled differently (e.g., PayPal customers who need re-authorization).
-    // For now, skip them since we're only processing card customers that
-    // were imported via PAN import and have a stripe_customer_id.
-    if (!stripeCustomerId) {
-      result.outcome = 'skipped_no_stripe_id'
-      logDebug(
-        'Skipping - no stripe_customer_id (not imported via PAN)',
-        {
-          ...context,
-        },
-        { verboseOnly: true }
-      )
-      return result
-    }
-
     // Get Stripe client for target account
     logDebug(
       'Getting Stripe client',
@@ -891,11 +1084,12 @@ async function processCustomer(
         ...context,
         email: account.email,
         hasBillingInfo: !!billingInfo,
-        paymentMethod: billingInfo?.paypalBillingAgreementId
-          ? 'paypal'
-          : billingInfo?.cardType || 'none',
-        account,
-        billingInfo,
+        paymentMethod:
+          billingInfo?.paymentMethod?.object === 'paypal_billing_agreement'
+            ? 'paypal'
+            : billingInfo?.cardType || 'none',
+        account: sanitizeAccount(account),
+        billingInfo: sanitizeBillingInfo(billingInfo),
       },
       { verboseOnly: true }
     )
@@ -932,50 +1126,26 @@ async function processCustomer(
       )
     }
 
-    // Fetch existing customer from target Stripe account
-    logDebug(
-      'Fetching existing Stripe customer',
-      {
-        ...context,
-        step: 'fetch_stripe_customer',
-      },
-      { verboseOnly: true }
-    )
-    const existingCustomer = await fetchTargetStripeCustomer(
+    const existingCustomer = await resolveStripeCustomer({
       stripeClient,
       stripeCustomerId,
-      stripeContext
-    )
+      recurlyAccountCode,
+      account,
+      commit,
+      context,
+      stripeContext,
+    })
+    stripeCustomerId = existingCustomer.id
+    result.stripe_customer_id = stripeCustomerId || ''
+    stripeContext.stripeCustomerId = stripeCustomerId
+    context.stripeCustomerId = stripeCustomerId
 
     if (existingCustomer.subscriptions?.data.length > 0) {
       throw new Error(
-        `Stripe customer ${stripeCustomerId} already has ${existingCustomer.subscriptions.data.length} active subscription(s).`
+        `Stripe customer ${stripeCustomerId} already has ${existingCustomer.subscriptions?.data?.length} active subscription(s).`
       )
     }
 
-    const otherMatchingCustomer = await fetchOtherStripeCustomerByUserId(
-      stripeClient,
-      recurlyAccountCode,
-      stripeCustomerId,
-      stripeContext
-    )
-
-    if (otherMatchingCustomer) {
-      throw new Error(
-        `Found another Stripe customer with matching userId metadata: ${otherMatchingCustomer.id}`
-      )
-    }
-
-    logDebug(
-      'Found existing Stripe customer',
-      {
-        ...context,
-        stripeEmail: existingCustomer.email,
-        stripeName: existingCustomer.name,
-      },
-      { verboseOnly: true }
-    )
-
     // Extract VAT number and country for tax ID creation
     const vatNumber = coalesceOrThrowVATNumber(account, billingInfo)
     let taxIdType = null
@@ -1076,6 +1246,15 @@ async function processCustomer(
     const address = coalesceOrEqualOrThrowAddress(account, billingInfo)
     const companyName = extractCompanyName(account, billingInfo)
 
+    const paymentMethod = await getPaymentMethod(
+      stripeClient,
+      stripeCustomerId,
+      billingInfo,
+      address,
+      commit,
+      stripeContext
+    )
+
     // TODO: Handle tax exempt status
     // Recurly has account.exemptionCertificate for tax exemption
     // Stripe has customer.tax_exempt: 'none' | 'exempt' | 'reverse'
@@ -1095,18 +1274,6 @@ async function processCustomer(
     //   customerParams.metadata.ccEmails = account.ccEmails
     // }
 
-    const paymentMethods = await fetchTargetStripeCustomerPaymentMethods(
-      stripeClient,
-      stripeCustomerId,
-      region,
-      stripeContext
-    )
-    const paymentMethod = coalesceOrThrowPaymentMethod(
-      paymentMethods,
-      stripeCustomerId,
-      billingInfo
-    )
-
     /** @type {Record<string, string>} */
     const metadata = {}
     if (account.createdAt) {
@@ -1269,6 +1436,8 @@ async function processCustomer(
               createdTaxId,
             }
           : null,
+        _isPaypal: paymentMethod?.type === 'paypal',
+        _targetStripeCustomerId: stripeCustomerId,
       }
       logDebug(
         'DRY RUN: Would update Stripe customer',
@@ -1364,13 +1533,6 @@ function usage() {
     '    Format: recurly_account_code,target_stripe_account,stripe_customer_id'
   )
   console.error('')
-  console.error(
-    '  SKIPPED file (<output>_skipped_no_stripe_id.csv): Records without stripe_customer_id'
-  )
-  console.error(
-    '    Format: recurly_account_code,target_stripe_account,stripe_customer_id'
-  )
-  console.error('')
   console.error(
     '  ERRORS file (<output>_errors.csv): Records that failed THIS run'
   )
@@ -1571,7 +1733,6 @@ async function main(trackProgress) {
   }
 
   const errorsOutputPath = getErrorsPath(successOutputPath)
-  const skippedOutputPath = getSkippedPath(successOutputPath)
   const stripeJsonPath = getStripeJsonPath(successOutputPath)
   const stripeExistingFieldsJsonPath =
     getStripeExistingFieldsJsonPath(successOutputPath)
@@ -1581,7 +1742,6 @@ async function main(trackProgress) {
     inputPath,
     successOutputPath,
     errorsOutputPath,
-    skippedOutputPath,
     ...(commit ? {} : { stripeJsonPath }),
     stripeExistingFieldsJsonPath,
     concurrency,
@@ -1627,23 +1787,14 @@ async function main(trackProgress) {
   const {
     writeSuccess,
     writeError,
-    writeSkipped,
     close: closeOutputs,
   } = commit
-    ? createOutputWriters(
-        successOutputPath,
-        errorsOutputPath,
-        skippedOutputPath,
-        restart,
-        { enableSuccessFile: true }
-      )
-    : createOutputWriters(
-        successOutputPath,
-        errorsOutputPath,
-        skippedOutputPath,
-        true,
-        { enableSuccessFile: false }
-      )
+    ? createOutputWriters(successOutputPath, errorsOutputPath, restart, {
+        enableSuccessFile: true,
+      })
+    : createOutputWriters(successOutputPath, errorsOutputPath, true, {
+        enableSuccessFile: false,
+      })
 
   // For dry-run mode, collect Stripe customer params to write to JSON
   const stripeCustomerParams = []
@@ -1660,7 +1811,6 @@ async function main(trackProgress) {
     let queuedThisRun = 0
     let skippedPreviouslyProcessed = 0
     let updatedCount = 0
-    let skippedNoStripeIdCount = 0
     let errorCount = 0
     let dryRunCount = 0
     let taxInfoPendingCount = 0
@@ -1775,9 +1925,6 @@ async function main(trackProgress) {
             writeError(result)
             errorCount++
             errorAccountCodes.push(accountCode)
-          } else if (result.outcome === 'skipped_no_stripe_id') {
-            writeSkipped(result)
-            skippedNoStripeIdCount++
           } else {
             writeSuccess(result)
             // Update statistics and collect dry-run data
@@ -1805,7 +1952,6 @@ async function main(trackProgress) {
               processedThisRun,
               updated: updatedCount,
               dryRun: dryRunCount,
-              skippedNoStripeId: skippedNoStripeIdCount,
               taxInfoPending: taxInfoPendingCount,
               errors: errorCount,
               skippedPrevious: skippedPreviouslyProcessed,
@@ -1888,9 +2034,6 @@ async function main(trackProgress) {
     await trackProgress(
       `  - ${commit ? 'Updated' : 'Would update'}: ${commit ? updatedCount : dryRunCount}`
     )
-    await trackProgress(
-      `  - Skipped (no stripe_customer_id): ${skippedNoStripeIdCount}`
-    )
     await trackProgress(`  - Tax info pending: ${taxInfoPendingCount}`)
     await trackProgress(`  - Errors: ${errorCount}`)
     await trackProgress('')
@@ -1913,9 +2056,6 @@ async function main(trackProgress) {
         `Success file: ${successOutputPath} (not modified in dry-run mode)`
       )
     }
-    await trackProgress(
-      `Skipped file: ${skippedOutputPath} (${skippedNoStripeIdCount} records)`
-    )
     await trackProgress(
       `Errors file: ${errorsOutputPath} (${errorCount} records)`
     )

+ 6 - 0
services/web/scripts/stripe/bulk-cancel-subscriptions.mjs

@@ -6,6 +6,12 @@
  * NOTE: this will email customers to inform them of the cancellation unless you turn off
  * the cancellation automation in Stripe beforehand: https://dashboard.stripe.com/<account>/revenue-recovery/automations
  *
+ * ⚠️ WARNING: For customers with PayPal billing agreements, do NOT extend this script to
+ * delete the Stripe customer (customers.del) or detach payment methods
+ * (paymentMethods.detach). Doing so will permanently destroy the PayPal billing
+ * agreement, which cannot be recovered without asking the customer to re-authorize.
+ * Cancelling a subscription is safe — it does not affect the payment method.
+ *
  * Usage:
  *   node scripts/stripe/bulk-cancel-subscriptions.mjs [OPTS] [INPUT-FILE]
  *