From 820adf568e6aff56f68a5bd27bcc58b7d53395fc Mon Sep 17 00:00:00 2001 From: Kristina <7614497+khjrtbrg@users.noreply.github.com> Date: Fri, 20 Feb 2026 09:35:36 +0100 Subject: [PATCH] [web] add support for migrating paypal subscriptions (#31607) * support Paypal for migration * remove sensitive payment info in logging * add `resolveStripeCustomer` GitOrigin-RevId: a8fc7de4a4bf3971c5221a0dec3fc279d8d2f67d --- ...te_recurly_customers_to_stripe.helpers.mjs | 30 ++ ...p-recurly-subscriptions-post-migration.mjs | 14 +- .../migrate_recurly_customers_to_stripe.mjs | 428 ++++++++++++------ .../stripe/bulk-cancel-subscriptions.mjs | 6 + 4 files changed, 327 insertions(+), 151 deletions(-) diff --git a/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.mjs b/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.mjs index 49146b9eef..0eb404967f 100644 --- a/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.mjs +++ b/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 diff --git a/services/web/scripts/recurly/cleanup-recurly-subscriptions-post-migration.mjs b/services/web/scripts/recurly/cleanup-recurly-subscriptions-post-migration.mjs index 1ccfaaefc2..c8cbfd651f 100755 --- a/services/web/scripts/recurly/cleanup-recurly-subscriptions-post-migration.mjs +++ b/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] diff --git a/services/web/scripts/recurly/migrate_recurly_customers_to_stripe.mjs b/services/web/scripts/recurly/migrate_recurly_customers_to_stripe.mjs index 8338bb48e1..07f51482ab 100644 --- a/services/web/scripts/recurly/migrate_recurly_customers_to_stripe.mjs +++ b/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 * - * _skipped_no_stripe_id.csv: Records skipped because stripe_customer_id was missing - * Format: recurly_account_code,target_stripe_account,stripe_customer_id - * * _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 }} + * @returns {{ writeSuccess: (row: object) => void, writeError: (row: object) => void, close: () => Promise }} */ 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} + * @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} + * @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} - 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} */ 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 (_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 (_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)` ) diff --git a/services/web/scripts/stripe/bulk-cancel-subscriptions.mjs b/services/web/scripts/stripe/bulk-cancel-subscriptions.mjs index 3558037f4a..2171914a72 100755 --- a/services/web/scripts/stripe/bulk-cancel-subscriptions.mjs +++ b/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//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] *