Further improvements to Recurly -> Stripe Customer Upsert script

* handle billingInfo / account discrepancies

* fix rate limiting behaviour of fetchRecurlyActiveSubscription

* make force-invalid-tax aware of stripe tax validation errors

* clear taxInfoPending if vatNumber is now valid or not present on second runs

* re-instate pre-validation of tax ids in dry-run mode

* handle tax exemption

* fix tax pre-validation tests

* fix comment typos

* remove redundant hadConflict

* re-use normalizeName function

* throw if more than 1 active recurly subscription when resolving conflict

---------

Co-authored-by: Simon Gardner <simon.gardner@overleaf.com>
GitOrigin-RevId: a88cad87887bd9e043fb457091e999c1d555f546
This commit is contained in:
Simon Gardner
2026-03-06 09:10:28 +00:00
committed by Copybot
co-authored by Simon Gardner
parent 029c73adce
commit 89e8044a25
3 changed files with 449 additions and 84 deletions
@@ -21,7 +21,7 @@ export function coalesceOrEqualOrThrow(a, b, fieldName) {
return isSetA ? a : b
}
function normalizeName(firstName, lastName) {
export function normalizeName(firstName, lastName) {
const first = (firstName || '').trim()
const last = (lastName || '').trim()
const full = `${first} ${last}`.trim()
@@ -66,6 +66,34 @@ export function coalesceOrEqualOrThrowName(account) {
return billingName ?? accountName
}
/**
* Extract customer name from billing info only (no fallback to account).
*
* @param {object} account - Recurly account object
* @returns {string|null}
*/
export function extractNameFromBillingInfo(account) {
const billingHasFullName = !!(
account.billingInfo?.firstName && account.billingInfo?.lastName
)
return billingHasFullName
? normalizeName(account.billingInfo.firstName, account.billingInfo.lastName)
: null
}
/**
* Extract customer name from account level only (no fallback to billing info).
*
* @param {object} account - Recurly account object
* @returns {string|null}
*/
export function extractNameFromAccount(account) {
const accountHasFullName = !!(account.firstName && account.lastName)
return accountHasFullName
? normalizeName(account.firstName, account.lastName)
: null
}
/**
* Extract and coalesce VAT number from Recurly data.
*
@@ -197,6 +225,11 @@ function normalizeTaxIdCompact(value) {
return normalizeTaxId(value).replace(/\s+/g, '')
}
function normalizeTaxIdAlnum(value) {
// Keep alphanumerics and '&' (used by some MX RFC values), strip separators.
return normalizeTaxId(value).replace(/[^A-Z0-9&]+/g, '')
}
function digitsOnly(value) {
return normalizeTaxId(value).replace(/\D+/g, '')
}
@@ -878,11 +911,17 @@ const COUNTRY_TAX_ID_FORMATS = {
* @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
* @param {boolean} [preValidateFormat=false] - Optional behavior flag to pre-validate format.
* @returns {{type: string|null, reason: string|null}} - Stripe tax ID type + failure reason (when type is null)
*/
// 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) {
export function getTaxIdType(
country,
taxIdValue,
postalCode,
preValidateFormat = false
) {
if (!country) return { type: null, reason: 'missing country' }
const upperCountry = String(country).toUpperCase()
@@ -923,6 +962,12 @@ export function getTaxIdType(country, taxIdValue, postalCode) {
'SK',
])
if (euVatOnlyCountries.has(upperCountry)) {
if (preValidateFormat && !hasEuVatPrefix(upperCountry, taxIdValue)) {
return {
type: null,
reason: `invalid tax ID format for country ${upperCountry}`,
}
}
return { type: 'eu_vat', reason: null }
}
}
@@ -1051,6 +1096,15 @@ export function getTaxIdType(country, taxIdValue, postalCode) {
const countryFormat = COUNTRY_TAX_ID_FORMATS[upperCountry]
if (countryFormat) {
if (preValidateFormat) {
const normalized = normalizeTaxIdAlnum(taxIdValue)
if (!countryFormat.regex.test(normalized)) {
return {
type: null,
reason: `invalid tax ID format for country ${upperCountry}`,
}
}
}
return { type: countryFormat.taxIdType, reason: null }
}
@@ -380,6 +380,33 @@ test('getTaxIdType includes failure reason', () => {
assert.equal(invalidEuOss.reason, 'invalid EU OSS VAT number')
})
test('getTaxIdType pre-validation is opt-in', () => {
// Default behavior: no country-format pre-validation
const noPreValidation = getTaxIdType('US', 'NOT_A_VALID_EIN', null)
assert.equal(noPreValidation.type, 'us_ein')
assert.equal(noPreValidation.reason, null)
// Optional behavior: enable country-format pre-validation
const withPreValidation = getTaxIdType('US', 'NOT_A_VALID_EIN', null, true)
assert.equal(withPreValidation.type, null)
assert.equal(withPreValidation.reason, 'invalid tax ID format for country US')
})
test('getTaxIdType pre-validates EU VAT-only countries when enabled', () => {
const invalidWithPreValidation = getTaxIdType('AT', '12345678', null, true)
assert.equal(invalidWithPreValidation.type, null)
assert.equal(
invalidWithPreValidation.reason,
'invalid tax ID format for country AT'
)
const validWithPreValidation = getTaxIdType('AT', 'ATU12345678', null, true)
assert.equal(validWithPreValidation.type, 'eu_vat')
assert.equal(validWithPreValidation.reason, null)
})
test('coalesceOrThrowPaymentMethod throws when payment methods array is empty', () => {
assert.throws(
() => coalesceOrThrowPaymentMethod([], 'cus_123', {}),