mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-13 21:53:43 +01:00
[migration scripts] detect account drift between Recurly and Stripe (#32315)
* add a script to detect drift between recurly and stripe * only flag when Recurly updated since subscription created in Stripe * add --comparison-date option instead of stripe subscription created time * reduce API calls by checking Recurly update time earlier GitOrigin-RevId: f0ecb5644f7445603da020a46e32622fcd2eebf4
This commit is contained in:
@@ -1116,8 +1116,9 @@ export function areStripeAndRecurlyCardDetailsEqual(
|
||||
recurlyPaymentMethod
|
||||
) {
|
||||
if (
|
||||
!stripePaymentMethod ||
|
||||
stripePaymentMethod.type !== 'card' ||
|
||||
!stripePaymentMethod?.card ||
|
||||
!stripePaymentMethod.card ||
|
||||
!recurlyPaymentMethod?.lastFour
|
||||
) {
|
||||
return false
|
||||
@@ -1129,3 +1130,461 @@ export function areStripeAndRecurlyCardDetailsEqual(
|
||||
stripePaymentMethod.card.exp_year === recurlyPaymentMethod.expYear
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ADDRESS / STRING COMPARISON HELPERS
|
||||
// =============================================================================
|
||||
|
||||
export function normalizeComparableString(value) {
|
||||
if (value == null) return ''
|
||||
return String(value).trim()
|
||||
}
|
||||
|
||||
export function hasAnyAddressValue(address) {
|
||||
if (!address || typeof address !== 'object') return false
|
||||
return Object.values(address).some(v => normalizeComparableString(v) !== '')
|
||||
}
|
||||
|
||||
export function ccEmailsToArray(ccEmails) {
|
||||
if (ccEmails == null || ccEmails === undefined) return []
|
||||
const normalisedEmails = String(ccEmails)
|
||||
.split(/[\s,;]+/)
|
||||
.filter(Boolean)
|
||||
return [...new Set(normalisedEmails)]
|
||||
}
|
||||
|
||||
export function normalizeTaxIdValue(taxId) {
|
||||
const value = normalizeComparableString(taxId)
|
||||
return value.replace(/[.\-_\s:/]/g, '').toLowerCase()
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CUSTOM FIELDS
|
||||
// =============================================================================
|
||||
|
||||
export const RECURLY_CUSTOM_FIELD_NAMES = [
|
||||
'channel',
|
||||
'Industry',
|
||||
'ol_sales_person',
|
||||
'MigratedfromFreeAgent',
|
||||
]
|
||||
|
||||
/**
|
||||
* Extract Recurly custom fields as Stripe metadata.
|
||||
*
|
||||
* Expects an account object with a `customFields` array (Recurly npm SDK format)
|
||||
* where each element has `name` and `value` properties.
|
||||
*
|
||||
* @param {object} account - Recurly account object (npm SDK format)
|
||||
* @returns {{ metadata: Record<string, string>, counts: Record<string, number> }}
|
||||
*/
|
||||
export function extractRecurlyCustomFieldMetadata(account) {
|
||||
const customFields = account?.customFields
|
||||
if (!Array.isArray(customFields)) {
|
||||
throw new Error(
|
||||
'Unexpected Recurly response: account.customFields is missing or not an array'
|
||||
)
|
||||
}
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
const metadata = {}
|
||||
|
||||
const counts = {
|
||||
channel: 0,
|
||||
Industry: 0,
|
||||
ol_sales_person: 0,
|
||||
MigratedfromFreeAgent: 0,
|
||||
noCustomFields: 0,
|
||||
}
|
||||
|
||||
if (customFields.length === 0) {
|
||||
counts.noCustomFields = 1
|
||||
return { metadata, counts }
|
||||
}
|
||||
|
||||
for (const field of customFields) {
|
||||
const name = field?.name?.trim()
|
||||
if (!RECURLY_CUSTOM_FIELD_NAMES.includes(name)) continue
|
||||
const rawValue = field?.value
|
||||
if (rawValue == null) continue
|
||||
const value = String(rawValue).trim()
|
||||
if (!value) continue
|
||||
metadata[name] = value
|
||||
counts[name] = 1
|
||||
}
|
||||
return { metadata, counts }
|
||||
}
|
||||
|
||||
function normalizeComparableAddress(address) {
|
||||
if (!address || typeof address !== 'object') return null
|
||||
const norm = {
|
||||
line1: normalizeComparableString(address.line1),
|
||||
line2: normalizeComparableString(address.line2),
|
||||
city: normalizeComparableString(address.city),
|
||||
state: normalizeComparableString(address.state),
|
||||
postal_code: normalizeComparableString(address.postal_code),
|
||||
country: normalizeComparableString(address.country).toUpperCase(),
|
||||
}
|
||||
return hasAnyAddressValue(norm) ? norm : null
|
||||
}
|
||||
|
||||
export function addressesEqual(a, b) {
|
||||
const na = normalizeComparableAddress(a)
|
||||
const nb = normalizeComparableAddress(b)
|
||||
if (!na && !nb) return true
|
||||
if (!na || !nb) return false
|
||||
return (
|
||||
na.line1 === nb.line1 &&
|
||||
na.line2 === nb.line2 &&
|
||||
na.city === nb.city &&
|
||||
na.state === nb.state &&
|
||||
na.postal_code === nb.postal_code &&
|
||||
na.country === nb.country
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CUSTOMER IDENTITY RESOLUTION
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Resolve customer name, address, company, and VAT number from Recurly account data.
|
||||
*
|
||||
* For most customers, billing info and account info agree (or only one source is set),
|
||||
* and the standard coalesce logic applies (billing info preferred, account as fallback).
|
||||
*
|
||||
* When any field has conflicting values across both sources, `fetchCollectionMethod`
|
||||
* is called to determine which source wins:
|
||||
*
|
||||
* - automatic (web sales): billing info is used for the Stripe customer record.
|
||||
* - manual (manual billing): account info is used for the Stripe customer record,
|
||||
* and billing info is returned separately to be copied to the payment method's
|
||||
* billing_details.
|
||||
*
|
||||
* @param {object} account - Recurly account object
|
||||
* @param {() => Promise<string|null>} fetchCollectionMethod - Async callback that returns the
|
||||
* subscription's collection method ('automatic' or 'manual') when a conflict is detected.
|
||||
* @returns {Promise<{
|
||||
* name: string|null,
|
||||
* address: import('stripe').Stripe.AddressParam|null,
|
||||
* companyName: string|null,
|
||||
* vatNumber: string|null,
|
||||
* collectionMethod: string|null,
|
||||
* billingInfoForPaymentMethod: object|null
|
||||
* }>}
|
||||
*/
|
||||
export async function resolveCustomerIdentity(account, fetchCollectionMethod) {
|
||||
const billingName = extractNameFromBillingInfo(account)
|
||||
const accountName = extractNameFromAccount(account)
|
||||
const nameConflict =
|
||||
billingName !== null && accountName !== null && billingName !== accountName
|
||||
|
||||
const billingAddress = normalizeRecurlyAddressToStripe(
|
||||
account.billingInfo?.address
|
||||
)
|
||||
const accountAddress = normalizeRecurlyAddressToStripe(account?.address)
|
||||
const addressConflict =
|
||||
billingAddress !== null &&
|
||||
accountAddress !== null &&
|
||||
!addressesEqual(billingAddress, accountAddress)
|
||||
|
||||
const billingCompany = account.billingInfo?.company?.trim() || null
|
||||
const accountCompany = account.company?.trim() || null
|
||||
const companyConflict =
|
||||
billingCompany !== null &&
|
||||
accountCompany !== null &&
|
||||
billingCompany !== accountCompany
|
||||
|
||||
const billingVat = account.billingInfo?.vatNumber?.trim() || null
|
||||
const accountVat = account?.vatNumber?.trim() || null
|
||||
const vatConflict =
|
||||
billingVat !== null && accountVat !== null && billingVat !== accountVat
|
||||
|
||||
const hasConflict =
|
||||
nameConflict || addressConflict || companyConflict || vatConflict
|
||||
|
||||
let name,
|
||||
address,
|
||||
companyName,
|
||||
vatNumber,
|
||||
collectionMethod,
|
||||
billingInfoForPaymentMethod
|
||||
|
||||
if (!hasConflict) {
|
||||
name = billingName ?? accountName
|
||||
address = billingAddress ?? accountAddress
|
||||
companyName = billingCompany ?? accountCompany
|
||||
vatNumber = billingVat ?? accountVat
|
||||
collectionMethod = null
|
||||
billingInfoForPaymentMethod = null
|
||||
} else {
|
||||
collectionMethod = await fetchCollectionMethod()
|
||||
|
||||
if (!collectionMethod) {
|
||||
throw new Error(
|
||||
'Conflict between billing info and account fields, but no subscription found to determine collection method'
|
||||
)
|
||||
}
|
||||
|
||||
if (collectionMethod === 'automatic') {
|
||||
name = billingName ?? accountName
|
||||
address = billingAddress ?? accountAddress
|
||||
companyName = billingCompany ?? accountCompany
|
||||
vatNumber = billingVat ?? accountVat
|
||||
billingInfoForPaymentMethod = null
|
||||
} else if (collectionMethod === 'manual') {
|
||||
name = accountName ?? billingName
|
||||
address = accountAddress ?? billingAddress
|
||||
companyName = accountCompany ?? billingCompany
|
||||
vatNumber = accountVat ?? billingVat
|
||||
billingInfoForPaymentMethod = account.billingInfo
|
||||
} else {
|
||||
throw new Error(`Unexpected collectionMethod: ${collectionMethod}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (vatNumber && address?.country === 'GB') {
|
||||
vatNumber = normalisedGBVATNumber(vatNumber)
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
address,
|
||||
companyName,
|
||||
vatNumber,
|
||||
collectionMethod,
|
||||
billingInfoForPaymentMethod,
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ACCOUNT COMPARISON
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Compare account-level fields between a Recurly account and a Stripe customer.
|
||||
*
|
||||
* This encapsulates the drift-detection logic shared by both the finalize script
|
||||
* and the compare script.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {object} options.account - Recurly account (npm SDK camelCase format)
|
||||
* @param {object} options.stripeCustomer - Stripe customer (expanded with tax_ids, default_payment_method)
|
||||
* @param {string} options.overleafUserId - Overleaf user ID (for metadata.userId comparison)
|
||||
* @param {() => Promise<string|null>} options.fetchCollectionMethod - Callback to get subscription collection method
|
||||
* @param {Array} options.stripePaymentMethods - Pre-fetched Stripe payment methods
|
||||
* @param {string} options.stripeServiceName - 'stripe-us' or 'stripe-uk'
|
||||
* @returns {Promise<Record<string, { recurly: any, stripe: any }>>} - Per-field diffs (empty = no drift)
|
||||
*/
|
||||
export async function compareAccountFields({
|
||||
account,
|
||||
stripeCustomer,
|
||||
overleafUserId,
|
||||
fetchCollectionMethod,
|
||||
stripePaymentMethods,
|
||||
stripeServiceName,
|
||||
}) {
|
||||
const { name, address, companyName, vatNumber } =
|
||||
await resolveCustomerIdentity(account, fetchCollectionMethod)
|
||||
|
||||
const diffs = {}
|
||||
|
||||
// Email
|
||||
if (
|
||||
normalizeComparableString(account.email) !==
|
||||
normalizeComparableString(stripeCustomer.email)
|
||||
) {
|
||||
diffs.email = {
|
||||
recurly: account.email || null,
|
||||
stripe: stripeCustomer.email || null,
|
||||
}
|
||||
}
|
||||
|
||||
// Name
|
||||
if (
|
||||
normalizeComparableString(name) !==
|
||||
normalizeComparableString(stripeCustomer.name)
|
||||
) {
|
||||
diffs.name = {
|
||||
recurly: name || null,
|
||||
stripe: stripeCustomer.name || null,
|
||||
}
|
||||
}
|
||||
|
||||
// Address
|
||||
if (address) {
|
||||
if (!addressesEqual(address, stripeCustomer.address)) {
|
||||
diffs.address = {
|
||||
recurly: address,
|
||||
stripe: stripeCustomer.address || null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Business name (company)
|
||||
if (companyName) {
|
||||
if (
|
||||
normalizeComparableString(companyName) !==
|
||||
normalizeComparableString(stripeCustomer.business_name)
|
||||
) {
|
||||
diffs.business_name = {
|
||||
recurly: companyName,
|
||||
stripe: stripeCustomer.business_name || null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Metadata
|
||||
const expectedMetadata = {}
|
||||
if (account.createdAt) {
|
||||
expectedMetadata.recurlyCreatedAt = account.createdAt.toISOString()
|
||||
}
|
||||
expectedMetadata.recurlyAccountCode = ''
|
||||
expectedMetadata.userId = overleafUserId
|
||||
|
||||
const { metadata: customFieldMetadata } =
|
||||
extractRecurlyCustomFieldMetadata(account)
|
||||
Object.assign(expectedMetadata, customFieldMetadata)
|
||||
|
||||
// Tax ID
|
||||
let expectedTaxIdType = null
|
||||
let expectedTaxIdValue = null
|
||||
if (vatNumber) {
|
||||
const taxIdTypeResult = getTaxIdType(
|
||||
address?.country,
|
||||
vatNumber,
|
||||
address?.postal_code,
|
||||
false
|
||||
)
|
||||
if (taxIdTypeResult.type && address?.country) {
|
||||
expectedTaxIdType = taxIdTypeResult.type
|
||||
expectedTaxIdValue = vatNumber
|
||||
} else {
|
||||
expectedMetadata.taxInfoPending = vatNumber
|
||||
}
|
||||
}
|
||||
if (!expectedMetadata.taxInfoPending) {
|
||||
expectedMetadata.taxInfoPending = ''
|
||||
}
|
||||
|
||||
for (const [key, expectedValue] of Object.entries(expectedMetadata)) {
|
||||
const stripeValue = stripeCustomer.metadata?.[key] ?? ''
|
||||
if (
|
||||
normalizeComparableString(expectedValue) !==
|
||||
normalizeComparableString(stripeValue)
|
||||
) {
|
||||
diffs[`metadata.${key}`] = {
|
||||
recurly: expectedValue,
|
||||
stripe: stripeValue || null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tax exempt
|
||||
const expectedTaxExempt = account.taxExempt ? 'exempt' : 'none'
|
||||
if (expectedTaxExempt !== (stripeCustomer.tax_exempt || 'none')) {
|
||||
diffs.tax_exempt = {
|
||||
recurly: expectedTaxExempt,
|
||||
stripe: stripeCustomer.tax_exempt || 'none',
|
||||
}
|
||||
}
|
||||
|
||||
// CC emails
|
||||
const expectedCcEmails = ccEmailsToArray(account.ccEmails)
|
||||
const stripeCcEmails = stripeCustomer.additional_emails?.cc || []
|
||||
const recurlyCcSorted = [...expectedCcEmails].sort()
|
||||
const stripeCcSorted = [...stripeCcEmails].sort()
|
||||
if (JSON.stringify(recurlyCcSorted) !== JSON.stringify(stripeCcSorted)) {
|
||||
diffs.cc_emails = {
|
||||
recurly: expectedCcEmails,
|
||||
stripe: stripeCcEmails,
|
||||
}
|
||||
}
|
||||
|
||||
// Tax ID
|
||||
if (expectedTaxIdType && expectedTaxIdValue) {
|
||||
const normalizedExpectedTaxIdValue = normalizeTaxIdValue(expectedTaxIdValue)
|
||||
const stripeTaxIds = stripeCustomer.tax_ids?.data || []
|
||||
const matchingTaxId = stripeTaxIds.find(
|
||||
tid =>
|
||||
tid.type === expectedTaxIdType &&
|
||||
normalizedExpectedTaxIdValue &&
|
||||
normalizeTaxIdValue(tid.value) === normalizedExpectedTaxIdValue
|
||||
)
|
||||
if (!matchingTaxId) {
|
||||
diffs.tax_id = {
|
||||
recurly: { type: expectedTaxIdType, value: expectedTaxIdValue },
|
||||
stripe:
|
||||
stripeTaxIds.length > 0
|
||||
? stripeTaxIds.map(t => ({ type: t.type, value: t.value }))
|
||||
: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default payment method
|
||||
const stripeDefaultPaymentMethod =
|
||||
stripeCustomer.invoice_settings?.default_payment_method
|
||||
const isPaypalBillingAgreement =
|
||||
account.billingInfo?.paymentMethod?.object === 'paypal_billing_agreement'
|
||||
|
||||
let expectedPaymentMethod = null
|
||||
if (
|
||||
isPaypalBillingAgreement &&
|
||||
!['US', 'CA'].includes(address?.country) &&
|
||||
stripeServiceName !== 'stripe-us'
|
||||
) {
|
||||
expectedPaymentMethod = { type: 'paypal' }
|
||||
}
|
||||
|
||||
if (
|
||||
!isPaypalBillingAgreement &&
|
||||
stripePaymentMethods.length > 0 &&
|
||||
account.billingInfo &&
|
||||
account.billingInfo.paymentMethod
|
||||
) {
|
||||
expectedPaymentMethod = account.billingInfo.paymentMethod
|
||||
}
|
||||
|
||||
const stripePaymentMethodCreatedAt = stripeDefaultPaymentMethod?.created
|
||||
const recurlyPaymentMethodUpdatedAt =
|
||||
(account.billingInfo?.updatedAt?.getTime() || 0) / 1000
|
||||
const recurlyPaymentMethodIsNewer =
|
||||
stripePaymentMethodCreatedAt < recurlyPaymentMethodUpdatedAt
|
||||
const paymentMethodIsMissing =
|
||||
(!stripeDefaultPaymentMethod && expectedPaymentMethod) ||
|
||||
(stripeDefaultPaymentMethod && !expectedPaymentMethod)
|
||||
if (
|
||||
paymentMethodIsMissing ||
|
||||
(!areStripeAndRecurlyCardDetailsEqual(
|
||||
stripeDefaultPaymentMethod,
|
||||
expectedPaymentMethod
|
||||
) &&
|
||||
recurlyPaymentMethodIsNewer)
|
||||
) {
|
||||
diffs.default_payment_method = {
|
||||
recurly: {
|
||||
type: expectedPaymentMethod?.type || 'card',
|
||||
last4: expectedPaymentMethod?.lastFour || null,
|
||||
expiry: expectedPaymentMethod?.lastFour
|
||||
? `${expectedPaymentMethod.expMonth}/${expectedPaymentMethod.expYear}`
|
||||
: null,
|
||||
updatedAt: account.billingInfo?.updatedAt?.toISOString() || null,
|
||||
},
|
||||
stripe: {
|
||||
type: stripeDefaultPaymentMethod?.type || null,
|
||||
last4: stripeDefaultPaymentMethod?.card
|
||||
? stripeDefaultPaymentMethod.card.last4
|
||||
: null,
|
||||
expiry: stripeDefaultPaymentMethod?.card
|
||||
? `${stripeDefaultPaymentMethod.card.exp_month}/${stripeDefaultPaymentMethod.card.exp_year}`
|
||||
: null,
|
||||
created: stripeDefaultPaymentMethod?.created
|
||||
? new Date(stripeDefaultPaymentMethod.created * 1000).toISOString()
|
||||
: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return diffs
|
||||
}
|
||||
|
||||
+574
@@ -37,6 +37,15 @@ import {
|
||||
getTaxIdType,
|
||||
coalesceOrThrowPaymentMethod,
|
||||
normalisedGBVATNumber,
|
||||
normalizeComparableString,
|
||||
hasAnyAddressValue,
|
||||
ccEmailsToArray,
|
||||
normalizeTaxIdValue,
|
||||
extractRecurlyCustomFieldMetadata,
|
||||
addressesEqual,
|
||||
resolveCustomerIdentity,
|
||||
compareAccountFields,
|
||||
areStripeAndRecurlyCardDetailsEqual,
|
||||
} from './migrate_recurly_customers_to_stripe.helpers.mjs'
|
||||
|
||||
test('extractNameFromAccount returns normalized full name when first and last are present', () => {
|
||||
@@ -479,3 +488,568 @@ test('normalisedGBVATNumber preserves Northern Ireland XI numbers', () => {
|
||||
// XI numbers should not be modified to GB
|
||||
assert.equal(normalisedGBVATNumber('XI123456789'), 'XI123456789')
|
||||
})
|
||||
|
||||
test('normalizeComparableString returns empty string for null/undefined', () => {
|
||||
assert.equal(normalizeComparableString(null), '')
|
||||
assert.equal(normalizeComparableString(undefined), '')
|
||||
})
|
||||
|
||||
test('normalizeComparableString trims whitespace', () => {
|
||||
assert.equal(normalizeComparableString(' hello '), 'hello')
|
||||
assert.equal(normalizeComparableString(' '), '')
|
||||
})
|
||||
|
||||
test('normalizeComparableString converts non-strings', () => {
|
||||
assert.equal(normalizeComparableString(42), '42')
|
||||
assert.equal(normalizeComparableString(0), '0')
|
||||
})
|
||||
|
||||
test('hasAnyAddressValue returns false for null/undefined/non-object', () => {
|
||||
assert.equal(hasAnyAddressValue(null), false)
|
||||
assert.equal(hasAnyAddressValue(undefined), false)
|
||||
assert.equal(hasAnyAddressValue('string'), false)
|
||||
})
|
||||
|
||||
test('hasAnyAddressValue returns false for all-empty address', () => {
|
||||
assert.equal(hasAnyAddressValue({ line1: '', line2: '', city: '' }), false)
|
||||
assert.equal(hasAnyAddressValue({ line1: ' ', city: null }), false)
|
||||
})
|
||||
|
||||
test('hasAnyAddressValue returns true when any field is non-empty', () => {
|
||||
assert.equal(hasAnyAddressValue({ line1: '123 Main St' }), true)
|
||||
assert.equal(hasAnyAddressValue({ country: 'US' }), true)
|
||||
})
|
||||
|
||||
test('ccEmailsToArray returns empty array for null/undefined', () => {
|
||||
assert.deepEqual(ccEmailsToArray(null), [])
|
||||
assert.deepEqual(ccEmailsToArray(undefined), [])
|
||||
})
|
||||
|
||||
test('ccEmailsToArray splits on commas, semicolons, and whitespace', () => {
|
||||
assert.deepEqual(ccEmailsToArray('a@b.com,c@d.com'), ['a@b.com', 'c@d.com'])
|
||||
assert.deepEqual(ccEmailsToArray('a@b.com;c@d.com'), ['a@b.com', 'c@d.com'])
|
||||
assert.deepEqual(ccEmailsToArray('a@b.com c@d.com'), ['a@b.com', 'c@d.com'])
|
||||
})
|
||||
|
||||
test('ccEmailsToArray deduplicates', () => {
|
||||
assert.deepEqual(ccEmailsToArray('a@b.com,a@b.com,c@d.com'), [
|
||||
'a@b.com',
|
||||
'c@d.com',
|
||||
])
|
||||
})
|
||||
|
||||
test('ccEmailsToArray handles empty string', () => {
|
||||
assert.deepEqual(ccEmailsToArray(''), [])
|
||||
})
|
||||
|
||||
test('normalizeTaxIdValue strips punctuation and lowercases', () => {
|
||||
assert.equal(normalizeTaxIdValue('GB.123-456_789'), 'gb123456789')
|
||||
assert.equal(normalizeTaxIdValue(' DE:123/456 '), 'de123456')
|
||||
})
|
||||
|
||||
test('normalizeTaxIdValue handles null/undefined', () => {
|
||||
assert.equal(normalizeTaxIdValue(null), '')
|
||||
assert.equal(normalizeTaxIdValue(undefined), '')
|
||||
})
|
||||
|
||||
test('extractRecurlyCustomFieldMetadata extracts known fields', () => {
|
||||
const account = {
|
||||
customFields: [
|
||||
{ name: 'channel', value: 'web' },
|
||||
{ name: 'Industry', value: 'Education' },
|
||||
{ name: 'unknown_field', value: 'ignored' },
|
||||
],
|
||||
}
|
||||
const { metadata, counts } = extractRecurlyCustomFieldMetadata(account)
|
||||
assert.deepEqual(metadata, { channel: 'web', Industry: 'Education' })
|
||||
assert.equal(counts.channel, 1)
|
||||
assert.equal(counts.Industry, 1)
|
||||
assert.equal(counts.ol_sales_person, 0)
|
||||
})
|
||||
|
||||
test('extractRecurlyCustomFieldMetadata returns empty for no custom fields', () => {
|
||||
const { metadata, counts } = extractRecurlyCustomFieldMetadata({
|
||||
customFields: [],
|
||||
})
|
||||
assert.deepEqual(metadata, {})
|
||||
assert.equal(counts.noCustomFields, 1)
|
||||
})
|
||||
|
||||
test('extractRecurlyCustomFieldMetadata throws for missing customFields', () => {
|
||||
assert.throws(
|
||||
() => extractRecurlyCustomFieldMetadata({}),
|
||||
/account.customFields is missing or not an array/
|
||||
)
|
||||
})
|
||||
|
||||
test('extractRecurlyCustomFieldMetadata skips null/empty values', () => {
|
||||
const account = {
|
||||
customFields: [
|
||||
{ name: 'channel', value: null },
|
||||
{ name: 'Industry', value: '' },
|
||||
{ name: 'ol_sales_person', value: ' ' },
|
||||
],
|
||||
}
|
||||
const { metadata } = extractRecurlyCustomFieldMetadata(account)
|
||||
assert.deepEqual(metadata, {})
|
||||
})
|
||||
|
||||
test('addressesEqual returns true for two null addresses', () => {
|
||||
assert.equal(addressesEqual(null, null), true)
|
||||
})
|
||||
|
||||
test('addressesEqual returns false when one is null', () => {
|
||||
assert.equal(
|
||||
addressesEqual({ line1: '123 Main St', country: 'US' }, null),
|
||||
false
|
||||
)
|
||||
assert.equal(
|
||||
addressesEqual(null, { line1: '123 Main St', country: 'US' }),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('addressesEqual returns true for matching addresses', () => {
|
||||
const a = {
|
||||
line1: '123 Main St',
|
||||
line2: '',
|
||||
city: 'London',
|
||||
state: '',
|
||||
postal_code: 'SW1A 1AA',
|
||||
country: 'GB',
|
||||
}
|
||||
const b = {
|
||||
line1: '123 Main St',
|
||||
line2: '',
|
||||
city: 'London',
|
||||
state: '',
|
||||
postal_code: 'SW1A 1AA',
|
||||
country: 'gb',
|
||||
}
|
||||
assert.equal(addressesEqual(a, b), true)
|
||||
})
|
||||
|
||||
test('addressesEqual returns false for differing addresses', () => {
|
||||
const a = {
|
||||
line1: '123 Main St',
|
||||
city: 'London',
|
||||
country: 'GB',
|
||||
}
|
||||
const b = {
|
||||
line1: '456 High St',
|
||||
city: 'London',
|
||||
country: 'GB',
|
||||
}
|
||||
assert.equal(addressesEqual(a, b), false)
|
||||
})
|
||||
|
||||
test('addressesEqual normalizes whitespace', () => {
|
||||
const a = { line1: ' 123 Main St ', country: ' GB ' }
|
||||
const b = { line1: '123 Main St', country: 'GB' }
|
||||
assert.equal(addressesEqual(a, b), true)
|
||||
})
|
||||
|
||||
test('areStripeAndRecurlyCardDetailsEqual returns false for null inputs', () => {
|
||||
assert.equal(areStripeAndRecurlyCardDetailsEqual(null, null), false)
|
||||
assert.equal(
|
||||
areStripeAndRecurlyCardDetailsEqual(null, { lastFour: '1234' }),
|
||||
false
|
||||
)
|
||||
assert.equal(
|
||||
areStripeAndRecurlyCardDetailsEqual(
|
||||
{ type: 'card', card: { last4: '1234', exp_month: 12, exp_year: 2030 } },
|
||||
null
|
||||
),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('areStripeAndRecurlyCardDetailsEqual returns false for non-card type', () => {
|
||||
assert.equal(
|
||||
areStripeAndRecurlyCardDetailsEqual(
|
||||
{ type: 'paypal' },
|
||||
{ lastFour: '1234' }
|
||||
),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('areStripeAndRecurlyCardDetailsEqual returns true for matching cards', () => {
|
||||
assert.equal(
|
||||
areStripeAndRecurlyCardDetailsEqual(
|
||||
{ type: 'card', card: { last4: '4242', exp_month: 6, exp_year: 2025 } },
|
||||
{ lastFour: '4242', expMonth: 6, expYear: 2025 }
|
||||
),
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('areStripeAndRecurlyCardDetailsEqual returns false for mismatched last4', () => {
|
||||
assert.equal(
|
||||
areStripeAndRecurlyCardDetailsEqual(
|
||||
{ type: 'card', card: { last4: '4242', exp_month: 6, exp_year: 2025 } },
|
||||
{ lastFour: '1111', expMonth: 6, expYear: 2025 }
|
||||
),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveCustomerIdentity uses billing info when no conflict', async () => {
|
||||
const account = {
|
||||
billingInfo: {
|
||||
firstName: 'Bill',
|
||||
lastName: 'User',
|
||||
address: { street1: '123 Bill St', country: 'US' },
|
||||
company: 'Bill Corp',
|
||||
vatNumber: 'US123',
|
||||
},
|
||||
firstName: 'Account',
|
||||
lastName: 'User',
|
||||
address: { street1: '456 Acct St', country: 'US' },
|
||||
company: 'Acct Corp',
|
||||
vatNumber: 'US123',
|
||||
}
|
||||
// No conflict on vatNumber (same), but name differs -> conflict
|
||||
const result = await resolveCustomerIdentity(account, async () => 'automatic')
|
||||
assert.equal(result.name, 'Bill User')
|
||||
})
|
||||
|
||||
test('resolveCustomerIdentity uses account info for manual collection', async () => {
|
||||
const account = {
|
||||
billingInfo: {
|
||||
firstName: 'Bill',
|
||||
lastName: 'User',
|
||||
address: { street1: '123 Bill St', country: 'US' },
|
||||
},
|
||||
firstName: 'Account',
|
||||
lastName: 'User',
|
||||
address: { street1: '456 Acct St', country: 'US' },
|
||||
}
|
||||
const result = await resolveCustomerIdentity(account, async () => 'manual')
|
||||
assert.equal(result.name, 'Account User')
|
||||
assert.notEqual(result.billingInfoForPaymentMethod, null)
|
||||
})
|
||||
|
||||
test('resolveCustomerIdentity returns billing info preferred when no conflict', async () => {
|
||||
const account = {
|
||||
billingInfo: {
|
||||
firstName: 'Same',
|
||||
lastName: 'Name',
|
||||
vatNumber: 'GB123456789',
|
||||
},
|
||||
firstName: 'Same',
|
||||
lastName: 'Name',
|
||||
vatNumber: 'GB123456789',
|
||||
}
|
||||
const result = await resolveCustomerIdentity(account, async () => {
|
||||
throw new Error('should not be called')
|
||||
})
|
||||
assert.equal(result.name, 'Same Name')
|
||||
assert.equal(result.collectionMethod, null)
|
||||
})
|
||||
|
||||
test('resolveCustomerIdentity does not call fetchCollectionMethod when no conflict', async () => {
|
||||
let called = false
|
||||
const account = {
|
||||
billingInfo: { firstName: 'Only', lastName: 'Billing' },
|
||||
}
|
||||
await resolveCustomerIdentity(account, async () => {
|
||||
called = true
|
||||
return 'automatic'
|
||||
})
|
||||
assert.equal(called, false)
|
||||
})
|
||||
|
||||
test('resolveCustomerIdentity throws when conflict but no collection method', async () => {
|
||||
const account = {
|
||||
billingInfo: {
|
||||
firstName: 'Bill',
|
||||
lastName: 'Name',
|
||||
},
|
||||
firstName: 'Acct',
|
||||
lastName: 'Name',
|
||||
}
|
||||
await assert.rejects(
|
||||
() => resolveCustomerIdentity(account, async () => null),
|
||||
/no subscription found to determine collection method/
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveCustomerIdentity normalises GB VAT', async () => {
|
||||
const account = {
|
||||
billingInfo: {
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
address: { country: 'GB' },
|
||||
vatNumber: '123456789',
|
||||
},
|
||||
}
|
||||
const result = await resolveCustomerIdentity(account, async () => 'automatic')
|
||||
assert.equal(result.vatNumber, 'GB123456789')
|
||||
})
|
||||
|
||||
test('compareAccountFields returns empty diffs when everything matches', async () => {
|
||||
const account = {
|
||||
email: 'user@example.com',
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
customFields: [],
|
||||
billingInfo: {
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
},
|
||||
}
|
||||
const stripeCustomer = {
|
||||
email: 'user@example.com',
|
||||
name: 'Test User',
|
||||
metadata: {
|
||||
recurlyAccountCode: '',
|
||||
userId: 'user123',
|
||||
taxInfoPending: '',
|
||||
},
|
||||
tax_exempt: 'none',
|
||||
}
|
||||
|
||||
const diffs = await compareAccountFields({
|
||||
account,
|
||||
stripeCustomer,
|
||||
overleafUserId: 'user123',
|
||||
fetchCollectionMethod: async () => null,
|
||||
stripePaymentMethods: [],
|
||||
stripeServiceName: 'stripe-uk',
|
||||
})
|
||||
|
||||
assert.deepEqual(diffs, {})
|
||||
})
|
||||
|
||||
test('compareAccountFields detects email drift', async () => {
|
||||
const account = {
|
||||
email: 'new@example.com',
|
||||
customFields: [],
|
||||
billingInfo: {},
|
||||
}
|
||||
const stripeCustomer = {
|
||||
email: 'old@example.com',
|
||||
metadata: {
|
||||
recurlyAccountCode: '',
|
||||
userId: 'user123',
|
||||
taxInfoPending: '',
|
||||
},
|
||||
tax_exempt: 'none',
|
||||
}
|
||||
|
||||
const diffs = await compareAccountFields({
|
||||
account,
|
||||
stripeCustomer,
|
||||
overleafUserId: 'user123',
|
||||
fetchCollectionMethod: async () => null,
|
||||
stripePaymentMethods: [],
|
||||
stripeServiceName: 'stripe-uk',
|
||||
})
|
||||
|
||||
assert.ok(diffs.email)
|
||||
assert.equal(diffs.email.recurly, 'new@example.com')
|
||||
assert.equal(diffs.email.stripe, 'old@example.com')
|
||||
})
|
||||
|
||||
test('compareAccountFields detects name drift', async () => {
|
||||
const account = {
|
||||
email: 'user@example.com',
|
||||
customFields: [],
|
||||
billingInfo: {
|
||||
firstName: 'New',
|
||||
lastName: 'Name',
|
||||
},
|
||||
}
|
||||
const stripeCustomer = {
|
||||
email: 'user@example.com',
|
||||
name: 'Old Name',
|
||||
metadata: {
|
||||
recurlyAccountCode: '',
|
||||
userId: 'user123',
|
||||
taxInfoPending: '',
|
||||
},
|
||||
tax_exempt: 'none',
|
||||
}
|
||||
|
||||
const diffs = await compareAccountFields({
|
||||
account,
|
||||
stripeCustomer,
|
||||
overleafUserId: 'user123',
|
||||
fetchCollectionMethod: async () => null,
|
||||
stripePaymentMethods: [],
|
||||
stripeServiceName: 'stripe-uk',
|
||||
})
|
||||
|
||||
assert.ok(diffs.name)
|
||||
assert.equal(diffs.name.recurly, 'New Name')
|
||||
assert.equal(diffs.name.stripe, 'Old Name')
|
||||
})
|
||||
|
||||
test('compareAccountFields detects tax ID drift', async () => {
|
||||
const account = {
|
||||
email: 'user@example.com',
|
||||
customFields: [],
|
||||
billingInfo: {
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
address: { country: 'DE' },
|
||||
vatNumber: 'DE123456789',
|
||||
},
|
||||
}
|
||||
const stripeCustomer = {
|
||||
email: 'user@example.com',
|
||||
name: 'Test User',
|
||||
address: { country: 'DE' },
|
||||
metadata: {
|
||||
recurlyAccountCode: '',
|
||||
userId: 'user123',
|
||||
taxInfoPending: '',
|
||||
},
|
||||
tax_exempt: 'none',
|
||||
tax_ids: { data: [] },
|
||||
}
|
||||
|
||||
const diffs = await compareAccountFields({
|
||||
account,
|
||||
stripeCustomer,
|
||||
overleafUserId: 'user123',
|
||||
fetchCollectionMethod: async () => null,
|
||||
stripePaymentMethods: [],
|
||||
stripeServiceName: 'stripe-uk',
|
||||
})
|
||||
|
||||
assert.ok(diffs.tax_id)
|
||||
assert.equal(diffs.tax_id.recurly.type, 'eu_vat')
|
||||
assert.equal(diffs.tax_id.recurly.value, 'DE123456789')
|
||||
})
|
||||
|
||||
test('compareAccountFields does not report tax ID diff when values match after normalization', async () => {
|
||||
const account = {
|
||||
email: 'user@example.com',
|
||||
customFields: [],
|
||||
billingInfo: {
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
address: { country: 'DE' },
|
||||
vatNumber: 'DE-123.456.789',
|
||||
},
|
||||
}
|
||||
const stripeCustomer = {
|
||||
email: 'user@example.com',
|
||||
name: 'Test User',
|
||||
address: { country: 'DE' },
|
||||
metadata: {
|
||||
recurlyAccountCode: '',
|
||||
userId: 'user123',
|
||||
taxInfoPending: '',
|
||||
},
|
||||
tax_exempt: 'none',
|
||||
tax_ids: {
|
||||
data: [{ type: 'eu_vat', value: 'DE123456789' }],
|
||||
},
|
||||
}
|
||||
|
||||
const diffs = await compareAccountFields({
|
||||
account,
|
||||
stripeCustomer,
|
||||
overleafUserId: 'user123',
|
||||
fetchCollectionMethod: async () => null,
|
||||
stripePaymentMethods: [],
|
||||
stripeServiceName: 'stripe-uk',
|
||||
})
|
||||
|
||||
assert.equal(diffs.tax_id, undefined)
|
||||
})
|
||||
|
||||
test('compareAccountFields handles no payment methods on either side without crashing', async () => {
|
||||
const account = {
|
||||
email: 'user@example.com',
|
||||
customFields: [],
|
||||
billingInfo: {},
|
||||
}
|
||||
const stripeCustomer = {
|
||||
email: 'user@example.com',
|
||||
metadata: {
|
||||
recurlyAccountCode: '',
|
||||
userId: 'user123',
|
||||
taxInfoPending: '',
|
||||
},
|
||||
tax_exempt: 'none',
|
||||
invoice_settings: { default_payment_method: null },
|
||||
}
|
||||
|
||||
// This should not throw (regression test for null payment method crash)
|
||||
const diffs = await compareAccountFields({
|
||||
account,
|
||||
stripeCustomer,
|
||||
overleafUserId: 'user123',
|
||||
fetchCollectionMethod: async () => null,
|
||||
stripePaymentMethods: [],
|
||||
stripeServiceName: 'stripe-uk',
|
||||
})
|
||||
|
||||
assert.equal(diffs.default_payment_method, undefined)
|
||||
})
|
||||
|
||||
test('compareAccountFields detects metadata drift', async () => {
|
||||
const account = {
|
||||
email: 'user@example.com',
|
||||
customFields: [{ name: 'channel', value: 'web' }],
|
||||
billingInfo: {},
|
||||
}
|
||||
const stripeCustomer = {
|
||||
email: 'user@example.com',
|
||||
metadata: {
|
||||
recurlyAccountCode: '',
|
||||
userId: 'user123',
|
||||
taxInfoPending: '',
|
||||
channel: 'api',
|
||||
},
|
||||
tax_exempt: 'none',
|
||||
}
|
||||
|
||||
const diffs = await compareAccountFields({
|
||||
account,
|
||||
stripeCustomer,
|
||||
overleafUserId: 'user123',
|
||||
fetchCollectionMethod: async () => null,
|
||||
stripePaymentMethods: [],
|
||||
stripeServiceName: 'stripe-uk',
|
||||
})
|
||||
|
||||
assert.ok(diffs['metadata.channel'])
|
||||
assert.equal(diffs['metadata.channel'].recurly, 'web')
|
||||
assert.equal(diffs['metadata.channel'].stripe, 'api')
|
||||
})
|
||||
|
||||
test('compareAccountFields detects tax_exempt drift', async () => {
|
||||
const account = {
|
||||
email: 'user@example.com',
|
||||
customFields: [],
|
||||
billingInfo: {},
|
||||
taxExempt: true,
|
||||
}
|
||||
const stripeCustomer = {
|
||||
email: 'user@example.com',
|
||||
metadata: {
|
||||
recurlyAccountCode: '',
|
||||
userId: 'user123',
|
||||
taxInfoPending: '',
|
||||
},
|
||||
tax_exempt: 'none',
|
||||
}
|
||||
|
||||
const diffs = await compareAccountFields({
|
||||
account,
|
||||
stripeCustomer,
|
||||
overleafUserId: 'user123',
|
||||
fetchCollectionMethod: async () => null,
|
||||
stripePaymentMethods: [],
|
||||
stripeServiceName: 'stripe-uk',
|
||||
})
|
||||
|
||||
assert.ok(diffs.tax_exempt)
|
||||
assert.equal(diffs.tax_exempt.recurly, 'exempt')
|
||||
assert.equal(diffs.tax_exempt.stripe, 'none')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user