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

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

GitOrigin-RevId: a8fc7de4a4bf3971c5221a0dec3fc279d8d2f67d
This commit is contained in:
Kristina
2026-02-20 09:35:36 +01:00
committed by Copybot
parent 00fe0473c5
commit 820adf568e
4 changed files with 327 additions and 151 deletions

View File

@@ -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

View File

@@ -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]

View File

@@ -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)`
)

View File

@@ -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]
*