Merge pull request #30958 from overleaf/slg-recurly-stripe-migration-30684

add script for migrating customer metadata from recurly to stripe

GitOrigin-RevId: 019413eda20cef2e09c9cc278a8806fa244fe019
This commit is contained in:
Simon Gardner
2026-01-26 10:56:44 +00:00
committed by Copybot
parent 0ee8b25298
commit b517df6672
4 changed files with 2267 additions and 0 deletions
@@ -0,0 +1,364 @@
/* eslint-disable @overleaf/require-script-runner */
import lodash from 'lodash'
/*
* This helper can be used by migrate_recurly_customers_to_stripe.mjs
*
* This file can be deleted once the Recurly to Stripe migration is complete.
*/
const { isEqual } = lodash
export function coalesceOrEqualOrThrow(a, b, fieldName) {
const isSetA = !!a
const isSetB = !!b
if (isSetA && isSetB && a !== b) {
throw new Error(
`Field ${fieldName}: Primary and fallback values are both set but differ (${a} != ${b})`
)
}
return isSetA ? a : b
}
function normalizeName(firstName, lastName) {
const first = (firstName || '').trim()
const last = (lastName || '').trim()
const full = `${first} ${last}`.trim()
return full || null
}
/**
* Extract and coalesce customer name from Recurly data.
*
* Atomic behavior: first+last name are taken from the same source.
*
* Coalesce/equality behavior:
* - Prefer billingInfo name when both first+last are present.
* - Fall back to account name otherwise.
* - If both billingInfo and account have a complete (first+last) name and they differ, throw.
*
* @param {object} account - Recurly account object
* @param {object|null} billingInfo - Recurly billing info object
* @returns {string|null}
*/
export function coalesceOrEqualOrThrowName(account, billingInfo) {
const billingHasFullName = !!(billingInfo?.firstName && billingInfo?.lastName)
const accountHasFullName = !!(account?.firstName && account?.lastName)
const billingName = billingHasFullName
? normalizeName(billingInfo.firstName, billingInfo.lastName)
: null
const accountName = accountHasFullName
? normalizeName(account?.firstName, account?.lastName)
: null
if (billingHasFullName && accountHasFullName && billingName !== accountName) {
throw new Error(
`Name differs between billingInfo and account (${billingName} != ${accountName})`
)
}
return billingName ?? accountName
}
/**
* Extract and coalesce VAT number from Recurly data.
*
* Coalesce/equality behavior:
* - Prefer billingInfo.vatNumber when set.
* - Fall back to account.vatNumber otherwise.
* - If both are set but differ, throw.
*
* @param {object} account - Recurly account object
* @param {object|null} billingInfo - Recurly billing info object
* @returns {string|null}
*/
export function coalesceOrThrowVATNumber(account, billingInfo) {
const billingVat = billingInfo?.vatNumber?.trim() || null
const accountVat = account?.vatNumber?.trim() || null
return coalesceOrEqualOrThrow(billingVat, accountVat, 'vatNumber')
}
/**
* Normalize a Recurly address into a Stripe AddressParam.
*
* Recurly address field names appear in multiple shapes depending on SDK/version
* and serialization (e.g. street1/street2/postalCode vs line1/line2/postal_code).
*
* @param {any} address
* @returns {import('stripe').Stripe.AddressParam|null}
*/
export function normalizeRecurlyAddressToStripe(address) {
if (!address) return null
const line1 = (address.street1 || '').trim()
// eslint-disable-next-line camelcase
const postal_code = (address.postalCode || '').trim()
const country = String(address.country || '')
.trim()
.toUpperCase()
// Only send an address if it has enough data to be plausibly accepted/usable by Stripe.
// eslint-disable-next-line camelcase
if (!line1 || !postal_code || !country) return null
if (!/^[A-Z]{2}$/.test(country)) return null
const line2 = (address.street2 || '').trim()
const city = (address.city || '').trim()
const state = (address.region || '').trim()
return {
line1,
...(line2 ? { line2 } : {}),
...(city ? { city } : {}),
...(state ? { state } : {}),
// eslint-disable-next-line camelcase
postal_code,
country,
}
}
/**
* Extract address from Recurly data.
*
* Prefers billingInfo address as this is what the customer entered during checkout.
* Falls back to account address for manually-created accounts or legacy data.
*
* @param {object} account - Recurly account object
* @param {object|null} billingInfo - Recurly billing info object
* @returns {import('stripe').Stripe.AddressParam|null}
*/
export function coalesceOrEqualOrThrowAddress(account, billingInfo) {
const billingAddress = normalizeRecurlyAddressToStripe(billingInfo?.address)
const accountAddress = normalizeRecurlyAddressToStripe(account?.address)
const isBillingAddressValid = !!billingAddress
const isAccountAddressValid = !!accountAddress
if (!isBillingAddressValid && !isAccountAddressValid) return null
if (isBillingAddressValid && !isAccountAddressValid) return billingAddress
if (!isBillingAddressValid && isAccountAddressValid) return accountAddress
if (!isEqual(billingAddress, accountAddress)) {
throw new Error('Billing address and account address differ')
}
return billingAddress
}
/**
* EU member state country codes for VAT purposes.
*/
export const EU_VAT_COUNTRIES = [
'AT', // Austria
'BE', // Belgium
'BG', // Bulgaria
'CY', // Cyprus
'CZ', // Czech Republic
'DE', // Germany
'DK', // Denmark
'EE', // Estonia
'ES', // Spain
'FI', // Finland
'FR', // France
'GR', // Greece
'HR', // Croatia
'HU', // Hungary
'IE', // Ireland
'IT', // Italy
'LT', // Lithuania
'LU', // Luxembourg
'LV', // Latvia
'MT', // Malta
'NL', // Netherlands
'PL', // Poland
'PT', // Portugal
'RO', // Romania
'SE', // Sweden
'SI', // Slovenia
'SK', // Slovakia
]
function caProvinceFromPostalCode(postalCode) {
if (!postalCode) return null
const m = String(postalCode)
.trim()
.toUpperCase()
.match(/^([A-Z])/)
if (!m) return null
const c = m[1]
if (c === 'A') return 'NL'
if (c === 'B') return 'NS'
if (c === 'C') return 'PE'
if (c === 'E') return 'NB'
if (c === 'G' || c === 'H' || c === 'J') return 'QC'
if (c === 'K' || c === 'L' || c === 'M' || c === 'N' || c === 'P') return 'ON'
if (c === 'R') return 'MB'
if (c === 'S') return 'SK'
if (c === 'T') return 'AB'
if (c === 'V') return 'BC'
if (c === 'Y') return 'YT'
if (c === 'X') return 'NT_NU' // ambiguous without more info
return null
}
/**
* Determine the Stripe tax ID type for Canada based on the tax ID value format.
*
* Source reference:
* - Stripe docs list supported types + example formats (Canada section)
* https://docs.stripe.com/billing/customer/tax-ids
*
*
* If the value doesn't clearly match one of Stripe's documented example formats,
* return null so the caller can handle it manually.
*
* @param {string|undefined|null} taxIdValue
* @returns {'ca_bn'|'ca_gst_hst'|'ca_pst_bc'|'ca_pst_mb'|'ca_pst_sk'|'ca_qst'|null}
*/
export function getCanadaTaxIdType(taxIdValue, postalCode) {
if (!taxIdValue) return null
// Normalize for CRA/Stripe examples which may contain spaces (e.g. "123456789 RT 0001")
const normalized = String(taxIdValue).trim().toUpperCase().replace(/\s+/g, '')
if (!normalized) return null
// GST/HST: CRA defines program account numbers as BN (9 digits) + program id (2 letters) + reference (4 digits).
// CRA explicitly shows GST/HST program account number as: 123456789 RT 0001 (or without spaces).
// Safe because "RT" in this exact position is the GST/HST program identifier.
// source: https://www.canada.ca/en/revenue-agency/services/tax/businesses/topics/business-registration/business-number-program-account/need-program-accounts.htm
if (/^\d{9}RT\d{4}$/.test(normalized)) return 'ca_gst_hst'
// Québec QST: Stripe support doc gives exact structure and example 1234567891TQ0001,
// and Revenu Québec states QST registration numbers include the letters "TQ".
// Safe because "TQ" in this exact position is distinctive.
// source: https://support.stripe.com/questions/quebec-sales-tax-information
if (/^\d{10}TQ\d{4}$/.test(normalized)) return 'ca_qst'
// British Columbia PST: BC Gov explicitly states PST number format is PST-1234-5678.
// Safe because it has the "PST-" prefix + hyphen groups.
// source: https://www2.gov.bc.ca/gov/content/taxes/sales-taxes/pst/register
if (/^PST-\d{4}-\d{4}$/.test(normalized)) return 'ca_pst_bc'
const prov = caProvinceFromPostalCode(postalCode)
// Ambiguous numeric-only cases: require province inferred from postal code
// Manitoba: allow dashed 123456-7 and undashed 1234567, but ONLY if prov==MB
if (
(/^\d{6}-\d$/.test(normalized) || /^\d{7}$/.test(normalized)) &&
prov === 'MB'
) {
return 'ca_pst_mb'
}
// Saskatchewan: 7 digits ONLY if prov==SK
if (/^\d{7}$/.test(normalized) && prov === 'SK') {
return 'ca_pst_sk'
}
// Canadian BN: CRA says the GST/HST program account number starts with a 9-digit BN, and some workflows ask for only those 9 digits.
// Stripe has ca_bn, but a bare 9-digit value is ambiguous (BN-only vs “first 9 digits of GST/HST account” vs other).
// -> Not safe to classify from format alone. Only classify as ca_bn if your *input field* is explicitly “BN”.
// if (/^\d{9}$/.test(normalized)) return 'ca_bn'
return null
}
/**
* Get the Stripe tax ID type for a given country + tax ID value.
*
* Note: for some countries (e.g. Canada) the type depends on the tax ID value format,
* not just the country.
*
* @param {string|undefined|null} country - ISO 3166-1 alpha-2
* @param {string|undefined|null} taxIdValue
* @param {string|undefined|null} postalCode - used for Canada province inference
* @returns {string|null} - Stripe tax ID type, or null if country/type unsupported
*/
// TODO: this function is naive - we need more than just country to determine tax ID type
// for example canada has multiple types (BN, QST, GST/HST) depending on province (inferred from postal code)
export function getTaxIdType(country, taxIdValue, postalCode) {
if (!country) return null
const upperCountry = String(country).toUpperCase()
// EU VAT
if (EU_VAT_COUNTRIES.includes(upperCountry)) {
return 'eu_vat'
}
// Canada
if (upperCountry === 'CA') {
return getCanadaTaxIdType(taxIdValue, postalCode)
}
// Country-specific tax IDs (all Stripe-supported types)
// See: https://docs.stripe.com/api/tax_ids/create#create_tax_id-type
const countryTaxIdTypes = {
// Europe (non-EU)
GB: 'gb_vat',
// CH: 'ch_vat',
// NO: 'no_vat',
// IS: 'is_vat',
// LI: 'li_uid',
// TR: 'tr_tin',
// // Americas
US: 'us_ein',
// CA: 'ca_bn', // this is more complex, see getCanadaTaxIdType()
// MX: 'mx_rfc',
// BR: 'br_cnpj',
// CL: 'cl_tin',
// CO: 'co_nit',
// AR: 'ar_cuit',
// BO: 'bo_tin',
// CR: 'cr_tin',
// DO: 'do_rcn',
// EC: 'ec_ruc',
// PE: 'pe_ruc',
// UY: 'uy_ruc',
// VE: 've_rif',
// SV: 'sv_nit',
// // Asia-Pacific
// AU: 'au_abn',
// NZ: 'nz_gst',
// JP: 'jp_cn',
// KR: 'kr_brn',
// CN: 'cn_tin',
// HK: 'hk_br',
// TW: 'tw_vat',
// SG: 'sg_gst',
// MY: 'my_sst',
// TH: 'th_vat',
// ID: 'id_npwp',
// PH: 'ph_tin',
// IN: 'in_gst',
// VN: 'vn_tin',
// // Middle East
// AE: 'ae_trn',
// SA: 'sa_vat',
// BH: 'bh_vat',
// OM: 'om_vat',
// IL: 'il_vat',
// // Africa
// ZA: 'za_vat',
// EG: 'eg_tin',
// KE: 'ke_pin',
// NG: 'ng_tin',
// // Other
// GE: 'ge_vat',
// UA: 'ua_vat',
// RS: 'rs_pib',
// MD: 'md_vat',
// AD: 'ad_nrt',
}
return countryTaxIdTypes[upperCountry] || null
}
@@ -0,0 +1,232 @@
/* eslint-disable @overleaf/require-script-runner */
import test from 'node:test'
import assert from 'node:assert/strict'
/*
* This test can be run from the services/web directory with:
*
* node --test scripts/helpers/migrate_recurly_customers_to_stripe.helpers.node.test.mjs
*
* It can be deleted once the Recurly to Stripe migration is complete.
*/
import {
coalesceOrEqualOrThrow,
coalesceOrEqualOrThrowAddress,
coalesceOrEqualOrThrowName,
coalesceOrThrowVATNumber,
getCanadaTaxIdType,
} from './migrate_recurly_customers_to_stripe.helpers.mjs'
test('coalesceOrEqualOrThrow returns primary when set', () => {
assert.equal(coalesceOrEqualOrThrow('a', undefined, 'field'), 'a')
})
test('coalesceOrEqualOrThrow returns fallback when primary is unset', () => {
assert.equal(coalesceOrEqualOrThrow(undefined, 'b', 'field'), 'b')
})
test('coalesceOrEqualOrThrow returns value when both are set and equal', () => {
assert.equal(coalesceOrEqualOrThrow('same', 'same', 'field'), 'same')
})
test('coalesceOrEqualOrThrow throws when both are set but differ', () => {
assert.throws(
() => coalesceOrEqualOrThrow('a', 'b', 'field'),
/Primary and fallback values are both set but differ/
)
})
test('coalesceOrEqualOrThrowAddress returns null when neither is valid', () => {
assert.equal(coalesceOrEqualOrThrowAddress({}, null), null)
assert.equal(
coalesceOrEqualOrThrowAddress(
{ address: { street1: '', postalCode: '', country: '' } },
{ address: { street1: '', postalCode: '', country: '' } }
),
null
)
assert.equal(
coalesceOrEqualOrThrowAddress(
{ address: { street1: ' ', postalCode: ' ', country: ' ' } },
null
),
null
)
})
test('coalesceOrEqualOrThrowAddress returns account when billing invalid', () => {
const account = {
address: { street1: '1 Road', postalCode: 'ABC', country: 'GB' },
}
const billingInfo = {
address: { street1: '', postalCode: 'ABC', country: 'GB' },
}
assert.deepEqual(coalesceOrEqualOrThrowAddress(account, billingInfo), {
line1: '1 Road',
postal_code: 'ABC',
country: 'GB',
})
})
test('coalesceOrEqualOrThrowAddress returns billing when account invalid', () => {
const account = {
address: { street1: '', postalCode: 'ABC', country: 'GB' },
}
const billingInfo = {
address: { street1: '1 Road', postalCode: 'ABC', country: 'GB' },
}
assert.deepEqual(coalesceOrEqualOrThrowAddress(account, billingInfo), {
line1: '1 Road',
postal_code: 'ABC',
country: 'GB',
})
})
test('coalesceOrEqualOrThrowAddress returns billing when both valid+equal', () => {
const addr = { street1: '1 Road', postalCode: 'ABC', country: 'GB' }
assert.deepEqual(
coalesceOrEqualOrThrowAddress({ address: { ...addr } }, { address: addr }),
{ line1: '1 Road', postal_code: 'ABC', country: 'GB' }
)
})
test('coalesceOrEqualOrThrowAddress normalizes Recurly-style address fields', () => {
const billingInfo = {
address: {
street1: 'as',
street2: '',
city: '',
region: '',
postalCode: '12312',
country: 'AI',
},
}
assert.deepEqual(coalesceOrEqualOrThrowAddress({}, billingInfo), {
line1: 'as',
postal_code: '12312',
country: 'AI',
})
})
test('coalesceOrEqualOrThrowAddress throws when both valid but differ', () => {
const account = {
address: { street1: '1 Road', postalCode: 'ABC', country: 'GB' },
}
const billingInfo = {
address: { street1: '2 Road', postalCode: 'ABC', country: 'GB' },
}
assert.throws(
() => coalesceOrEqualOrThrowAddress(account, billingInfo),
/Billing address and account address differ/
)
})
test('coalesceOrEqualOrThrowName returns billingInfo name when both sources match', () => {
const account = { firstName: 'Alice', lastName: 'Billing' }
const billingInfo = { firstName: 'Alice', lastName: 'Billing' }
assert.equal(
coalesceOrEqualOrThrowName(account, billingInfo),
'Alice Billing'
)
})
test('coalesceOrEqualOrThrowName prefers billingInfo when billingInfo is full but account is not', () => {
const account = { firstName: 'Alice', lastName: '' }
const billingInfo = { firstName: 'Alice', lastName: 'Billing' }
assert.equal(
coalesceOrEqualOrThrowName(account, billingInfo),
'Alice Billing'
)
})
test('coalesceOrEqualOrThrowName falls back to account when billingInfo missing last name', () => {
const account = { firstName: 'Alice', lastName: 'Account' }
const billingInfo = { firstName: 'Alice', lastName: '' }
assert.equal(
coalesceOrEqualOrThrowName(account, billingInfo),
'Alice Account'
)
})
test('coalesceOrEqualOrThrowName returns null when both sources are empty', () => {
assert.equal(coalesceOrEqualOrThrowName({}, null), null)
assert.equal(
coalesceOrEqualOrThrowName({ firstName: '', lastName: '' }, null),
null
)
})
test('coalesceOrEqualOrThrowName throws when both full names are present but differ', () => {
const account = { firstName: 'Alice', lastName: 'Account' }
const billingInfo = { firstName: 'Alice', lastName: 'Billing' }
assert.throws(
() => coalesceOrEqualOrThrowName(account, billingInfo),
/Name differs between billingInfo and account/
)
})
test('coalesceOrThrowVATNumber returns billingInfo VAT when set', () => {
const account = { vatNumber: '' }
const billingInfo = { vatNumber: 'BILL456' }
assert.equal(coalesceOrThrowVATNumber(account, billingInfo), 'BILL456')
})
test('coalesceOrThrowVATNumber returns account VAT when billingInfo VAT unset', () => {
const account = { vatNumber: 'ACCT123' }
const billingInfo = { vatNumber: '' }
assert.equal(coalesceOrThrowVATNumber(account, billingInfo), 'ACCT123')
})
test('coalesceOrThrowVATNumber returns null when neither is set', () => {
assert.equal(coalesceOrThrowVATNumber({}, null), null)
assert.equal(
coalesceOrThrowVATNumber({ vatNumber: '' }, { vatNumber: '' }),
null
)
})
test('coalesceOrThrowVATNumber treats trimmed values as equal', () => {
const account = { vatNumber: ' GB123 ' }
const billingInfo = { vatNumber: 'GB123' }
assert.equal(coalesceOrThrowVATNumber(account, billingInfo), 'GB123')
})
test('coalesceOrThrowVATNumber throws when both are set but differ', () => {
const account = { vatNumber: 'GB123' }
const billingInfo = { vatNumber: 'DE999' }
assert.throws(
() => coalesceOrThrowVATNumber(account, billingInfo),
/Field vatNumber: Primary and fallback values are both set but differ/
)
})
test('getCanadaTaxIdType returns ca_gst_hst for GST/HST format', () => {
assert.equal(getCanadaTaxIdType('123456789RT0002', null), 'ca_gst_hst')
assert.equal(getCanadaTaxIdType(' 123456789rt0002 ', null), 'ca_gst_hst')
})
test('getCanadaTaxIdType returns ca_qst for Quebec QST format', () => {
assert.equal(getCanadaTaxIdType('1234567890TQ1234', null), 'ca_qst')
assert.equal(getCanadaTaxIdType('1234567890tq1234', null), 'ca_qst')
})
test('getCanadaTaxIdType returns PST types for documented provincial formats', () => {
assert.equal(getCanadaTaxIdType('PST-1234-5678', null), 'ca_pst_bc')
assert.equal(getCanadaTaxIdType('123456-7', 'R3C 4T3'), 'ca_pst_mb')
assert.equal(getCanadaTaxIdType('1234567', 'S7K 3J8'), 'ca_pst_sk')
})
test('getCanadaTaxIdType returns ca_bn for 9-digit BN format', () => {
// assert.equal(getCanadaTaxIdType('123456789', null), 'ca_bn')
assert.equal(getCanadaTaxIdType('123456789', null), null) // TODO: improve function get definitive ca_bn vs ca_gst_hst
})
test('getCanadaTaxIdType returns null when format is unknown/ambiguous', () => {
assert.equal(getCanadaTaxIdType('', null), null)
assert.equal(getCanadaTaxIdType(null, null), null)
assert.equal(getCanadaTaxIdType('RT0002', null), null)
assert.equal(getCanadaTaxIdType('PST12345678', null), null)
})
@@ -0,0 +1,293 @@
/**
* List Recurly accounts and output as CSV for use with migrate_recurly_customers_to_stripe.mjs
*
* Useful for generating list of customers for testing purposes.
*
* This script can be deleted once the Recurly to Stripe migration is complete.
*
* Usage:
* node scripts/recurly/list_recurly_accounts.mjs --limit 100 --output test_customers.csv
*
* Options:
* --limit N Number of accounts to fetch (default: 100)
* --output FILE Output CSV file (required)
* --stripe-account Account ID to use for target_stripe_account column
* --verbose Enable debug logging
*/
import Settings from '@overleaf/settings'
import recurly from 'recurly'
import minimist from 'minimist'
import fs from 'node:fs'
import { scriptRunner } from '../lib/ScriptRunner.mjs'
import { normalizeRecurlyAddressToStripe } from '../helpers/migrate_recurly_customers_to_stripe.helpers.mjs'
const recurlyApiKey =
process.env.RECURLY_API_KEY || Settings.apis?.recurly?.apiKey
if (!recurlyApiKey) {
throw new Error(
'Recurly API key is not set. Set RECURLY_API_KEY env var or configure Settings.apis.recurly.apiKey'
)
}
const client = new recurly.Client(recurlyApiKey)
function usage() {
console.error(
'List Recurly accounts and output as CSV for use with migrate_recurly_customers_to_stripe.mjs'
)
console.error('')
console.error('Usage:')
console.error(
' node scripts/recurly/list_recurly_accounts.mjs --output <file> [options]'
)
console.error('')
console.error('Options:')
console.error(
' --limit, -l N Number of accounts to fetch (default: 100)'
)
console.error(' --output, -o FILE Output CSV file (required)')
console.error(
' --stripe-account, -s ID Target Stripe account ID for all rows'
)
console.error(' --verbose, -v Enable debug logging')
console.error(' --help, -h Show this help message')
}
function parseArgs() {
return minimist(process.argv.slice(2), {
alias: {
o: 'output',
l: 'limit',
s: 'stripe-account',
v: 'verbose',
h: 'help',
},
default: { limit: 100 },
string: ['output', 'stripe-account'],
boolean: ['verbose'],
})
}
async function main(trackProgress) {
const args = parseArgs()
const DEBUG = !!args.verbose
function debug(message, context = {}) {
if (!DEBUG) return
const contextStr =
Object.keys(context).length > 0 ? ` ${JSON.stringify(context)}` : ''
console.log(`[DEBUG] ${message}${contextStr}`)
}
if (args.help || args.h) {
usage()
process.exit(0)
}
if (!args.output) {
usage()
console.error('')
console.error('Error: --output is required')
process.exit(1)
}
const limit = parseInt(args.limit, 10)
const targetStripeAccount =
args['stripe-account'] || 'REPLACE_WITH_STRIPE_ACCOUNT_ID'
if (!Number.isFinite(limit) || limit <= 0) {
throw new Error(`Invalid --limit: ${args.limit}`)
}
if (limit < 2) {
throw new Error(
'Invalid --limit: must be >= 2 to satisfy VAT constraints (>=1 VAT account but <=50% VAT overall)'
)
}
await trackProgress(`Fetching up to ${limit} accounts from Recurly...`)
await trackProgress(`Target Stripe account: ${targetStripeAccount}`)
if (DEBUG) {
await trackProgress('Debug logging enabled')
}
const vatCandidates = []
const nonVatCandidates = []
let scanned = 0
let acceptedWithAddress = 0
let rejectedNoAddress = 0
let rejectedVatOverCap = 0
let billingInfoFetched = 0
let billingInfoNotFound = 0
let billingInfoOtherError = 0
let usedBillingAddress = 0
let usedBillingVatNumber = 0
const vatCap = Math.floor(limit / 2)
// List accounts with pagination - Recurly returns a Pager, need to call .each()
// Options must be wrapped in { params: { ... } }
const accountsPager = client.listAccounts({
params: { limit: Math.min(limit, 200) },
})
for await (const account of accountsPager.each()) {
scanned++
const recurlyAccountCode = account.code
const row = {
recurly_account_code: recurlyAccountCode,
target_stripe_account: targetStripeAccount,
stripe_customer_id: '', // Empty - no existing Stripe customer
email: account.email,
state: account.state,
}
let address = account.address
let vatNumber =
typeof account.vatNumber === 'string' ? account.vatNumber : null
// Fetch billing info only if needed for address/vat detection
if (!normalizeRecurlyAddressToStripe(address) || !vatNumber) {
try {
billingInfoFetched++
const billingInfo = await client.getBillingInfo(
`code-${recurlyAccountCode}`
)
if (!address && billingInfo?.address) {
address = billingInfo.address
usedBillingAddress++
}
if (!vatNumber && billingInfo?.vatNumber) {
vatNumber = billingInfo.vatNumber
usedBillingVatNumber++
}
} catch (error) {
if (!(error instanceof recurly.errors.NotFoundError)) {
billingInfoOtherError++
throw error
}
billingInfoNotFound++
}
}
if (!normalizeRecurlyAddressToStripe(address)) {
rejectedNoAddress++
debug('Rejected account: no valid address', {
scanned,
recurlyAccountCode,
rejectedNoAddress,
})
continue
}
acceptedWithAddress++
const hasVat = !!(typeof vatNumber === 'string' && vatNumber.trim())
if (hasVat) {
// Enforce VAT upper bound while scanning.
if (vatCandidates.length >= vatCap) {
rejectedVatOverCap++
debug('Rejected account: VAT over cap', {
scanned,
recurlyAccountCode,
vatCandidates: vatCandidates.length,
vatCap,
rejectedVatOverCap,
})
continue
}
vatCandidates.push(row)
} else {
nonVatCandidates.push(row)
}
// Stop once we can satisfy constraints.
const vatToTake = Math.min(vatCap, vatCandidates.length)
const needsAtLeastOneVat = vatCandidates.length >= 1
const nonVatNeeded = limit - Math.max(1, vatToTake)
if (needsAtLeastOneVat && nonVatCandidates.length >= nonVatNeeded) {
debug('Stopping early: constraints satisfied', {
scanned,
vatCandidates: vatCandidates.length,
nonVatCandidates: nonVatCandidates.length,
vatCap,
nonVatNeeded,
})
break
}
if (scanned % 25 === 0) {
await trackProgress(
`Scanned ${scanned} accounts (acceptedWithAddress=${acceptedWithAddress}, vat=${vatCandidates.length}, nonVat=${nonVatCandidates.length})`
)
debug('Progress', {
scanned,
acceptedWithAddress,
rejectedNoAddress,
rejectedVatOverCap,
vatCandidates: vatCandidates.length,
nonVatCandidates: nonVatCandidates.length,
billingInfoFetched,
billingInfoNotFound,
billingInfoOtherError,
usedBillingAddress,
usedBillingVatNumber,
})
}
}
if (vatCandidates.length < 1) {
throw new Error(
`Unable to find any accounts with VAT numbers (scanned=${scanned}, acceptedWithAddress=${acceptedWithAddress}, rejectedNoAddress=${rejectedNoAddress})`
)
}
const vatToTake = Math.max(1, Math.min(vatCap, vatCandidates.length))
const nonVatToTake = limit - vatToTake
if (nonVatCandidates.length < nonVatToTake) {
throw new Error(
`Unable to satisfy VAT ratio constraint: need ${nonVatToTake} non-VAT + ${vatToTake} VAT, but have nonVat=${nonVatCandidates.length}, vat=${vatCandidates.length} (scanned=${scanned}, rejectedNoAddress=${rejectedNoAddress}, rejectedVatOverCap=${rejectedVatOverCap})`
)
}
const accounts = [
...vatCandidates.slice(0, vatToTake),
...nonVatCandidates.slice(0, nonVatToTake),
]
await trackProgress(
`Selected ${accounts.length} accounts (vat=${vatToTake}, nonVat=${nonVatToTake}, scanned=${scanned}, rejectedNoAddress=${rejectedNoAddress})`
)
// Output CSV
const csvHeader =
'recurly_account_code,target_stripe_account,stripe_customer_id'
const csvRows = accounts.map(
a =>
`${a.recurly_account_code},${a.target_stripe_account},${a.stripe_customer_id}`
)
const csvContent = [csvHeader, ...csvRows].join('\n') + '\n'
fs.writeFileSync(args.output, csvContent)
await trackProgress(`Wrote ${accounts.length} accounts to ${args.output}`)
// Output a summary
const states = {}
accounts.forEach(a => {
states[a.state] = (states[a.state] || 0) + 1
})
await trackProgress('Account states:')
for (const [state, stateCount] of Object.entries(states)) {
await trackProgress(` ${state}: ${stateCount}`)
}
}
try {
await scriptRunner(main)
process.exit(0)
} catch (err) {
console.error('Error:', err.message)
process.exit(1)
}
File diff suppressed because it is too large Load Diff