mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-06-08 00:29:04 +02:00
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:
@@ -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
|
||||
}
|
||||
+232
@@ -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
Reference in New Issue
Block a user