mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 17:19:37 +02:00
[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:
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,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]
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user