diff --git a/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.mjs b/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.mjs index 7c77f412f6..b997cca46a 100644 --- a/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.mjs +++ b/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.mjs @@ -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, counts: Record }} + */ +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} */ + 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} 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} 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>} - 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 +} diff --git a/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.node.test.mjs b/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.node.test.mjs index 81f7e3d099..dbbcf43d66 100644 --- a/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.node.test.mjs +++ b/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.node.test.mjs @@ -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') +}) diff --git a/services/web/scripts/recurly/compare_recurly_stripe_customers.mjs b/services/web/scripts/recurly/compare_recurly_stripe_customers.mjs new file mode 100644 index 0000000000..878ce5462a --- /dev/null +++ b/services/web/scripts/recurly/compare_recurly_stripe_customers.mjs @@ -0,0 +1,804 @@ +#!/usr/bin/env node + +/** + * This script compares existing Stripe customer records with data from Recurly + * to detect any drift since the last migration run. + * + * It is a read-only companion to migrate_recurly_customers_to_stripe.mjs. + * It uses the same normalization logic but makes no changes to Stripe. + * + * Input CSV format: + * recurly_account_code,target_stripe_account,stripe_customer_id + * + * Output files: + * --output (comparison file): One row per customer with match/mismatch status + * Format: recurly_account_code,target_stripe_account,stripe_customer_id,status,diffs + * + * _details.json: Detailed diff for each customer with mismatches + * Format: Array of { recurly_account_code, stripe_customer_id, diffs: { field: { recurly, stripe } } } + * + * _errors.csv: Records that failed + * Format: recurly_account_code,target_stripe_account,stripe_customer_id,error + * + * Usage: + * node scripts/recurly/compare_recurly_stripe_customers.mjs --input customers.csv --output comparison.csv --comparison-date 2026-03-19 + * + * Options: + * --input, -i Path to input CSV file (required) + * --output, -o Path to output CSV file (required) + * --comparison-date Date in YYYY-MM-DD to compare against (required) + * --limit, -l Limit number of records processed (default: no limit) + * --concurrency, -c Number of customers to process concurrently (default: 10) + * --recurly-rate-limit Requests per second for Recurly (default: 10) + * --recurly-api-retries Number of retries on Recurly 429s (default: 5) + * --recurly-retry-delay-ms Delay between Recurly retries in ms (default: 1000) + * --stripe-rate-limit Requests per second for Stripe (default: 50) + * --stripe-api-retries Number of retries on Stripe 429s (default: 5) + * --stripe-retry-delay-ms Delay between Stripe retries in ms (default: 1000) + * --verbose, -v Enable debug logging + * + * Note, prior to running this script, environment variables must have been loaded from config/local.env + * + * ``` + * set -a + * source ../../config/local.env + * set +a + * ``` + */ + +import Settings from '@overleaf/settings' +import Stripe from 'stripe' +import recurly from 'recurly' +import minimist from 'minimist' +import PQueue from 'p-queue' +import fs from 'node:fs' +import * as csv from 'csv' +import { scriptRunner } from '../lib/ScriptRunner.mjs' + +import { compareAccountFields } from '../helpers/migrate_recurly_customers_to_stripe.helpers.mjs' +import { + createRateLimitedApiWrappers, + DEFAULT_RECURLY_RATE_LIMIT, + DEFAULT_STRIPE_RATE_LIMIT, + DEFAULT_RECURLY_API_RETRIES, + DEFAULT_RECURLY_RETRY_DELAY_MS, + DEFAULT_STRIPE_API_RETRIES, + DEFAULT_STRIPE_RETRY_DELAY_MS, +} from '../stripe/RateLimiter.mjs' + +// ============================================================================= +// STRIPE CLIENT SETUP +// ============================================================================= + +const stripeClients = {} + +function getRegionClient(region) { + const regionLower = String(region || '') + .trim() + .toLowerCase() + + if (regionLower !== 'us' && regionLower !== 'uk') { + throw new Error( + `Unknown Stripe region: ${region}. Expected stripe-us or stripe-uk.` + ) + } + + if (stripeClients[regionLower]) return stripeClients[regionLower] + + const secretKey = + regionLower === 'us' + ? Settings.apis?.stripeUS?.secretKey || + process.env.STRIPE_OL_SECRET_KEY || + process.env.STRIPE_OL_US_SECRET_KEY + : Settings.apis?.stripeUK?.secretKey || + process.env.STRIPE_OL_UK_SECRET_KEY + + if (!secretKey || !String(secretKey).trim()) { + throw new Error( + `No Stripe secret key configured for region ${regionLower}.` + ) + } + + const client = new Stripe(secretKey, { + httpClient: Stripe.createFetchHttpClient(), + telemetry: false, + }) + + client.serviceName = `stripe-${regionLower}` + stripeClients[regionLower] = client + return client +} + +// ============================================================================= +// RECURLY CLIENT SETUP +// ============================================================================= + +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 recurlyClient = new recurly.Client(recurlyApiKey) + +// ============================================================================= +// LOGGING UTILITIES +// ============================================================================= + +function timestamp() { + return new Date().toISOString() +} + +function logWarn(message, context = {}) { + const contextStr = + Object.keys(context).length > 0 ? ` ${JSON.stringify(context)}` : '' + console.warn(`[${timestamp()}] WARN: ${message}${contextStr}`) +} + +function logError(message, error = null, context = {}) { + const contextStr = + Object.keys(context).length > 0 ? ` ${JSON.stringify(context)}` : '' + console.error(`[${timestamp()}] ERROR: ${message}${contextStr}`) + if (error?.stack) { + console.error(`[${timestamp()}] STACK: ${error.stack}`) + } +} + +let DEBUG_MODE = false + +function logDebug(message, context = {}, { verboseOnly = false } = {}) { + if (verboseOnly && !DEBUG_MODE) return + const contextStr = + Object.keys(context).length > 0 ? ` ${JSON.stringify(context)}` : '' + const level = verboseOnly ? 'DEBUG' : 'INFO' + console.log(`[${timestamp()}] ${level}: ${message}${contextStr}`) +} + +// ============================================================================= +// DATA FETCHING +// ============================================================================= + +let rateLimiters + +async function fetchRecurlyData(accountCode, context) { + return await rateLimiters.requestWithRetries( + 'recurly', + () => recurlyClient.getAccount(`code-${accountCode}`), + context + ) +} + +async function fetchRecurlySubscription(accountCode, context) { + // Try live subscriptions first, then fall back to expired. + for (const state of ['live', 'expired']) { + const subscriptions = await rateLimiters.requestWithRetries( + 'recurly', + async () => { + const pager = recurlyClient.listAccountSubscriptions( + `code-${accountCode}`, + { params: { state, order: 'desc', sort: 'updated_at' } } + ) + const results = [] + for await (const subscription of pager.each()) { + results.push(subscription) + } + return results + }, + context + ) + if (subscriptions.length > 0) { + // Return the most recently updated subscription in this state + return subscriptions[0] + } + } + return null +} + +async function fetchTargetStripeCustomer( + stripeClient, + stripeCustomerId, + context +) { + const customer = await rateLimiters.requestWithRetries( + stripeClient.serviceName, + () => + stripeClient.customers.retrieve(stripeCustomerId, { + expand: ['tax_ids', 'invoice_settings.default_payment_method'], + }), + { ...context, stripeApi: 'customers.retrieve' } + ) + if (customer.deleted) { + throw new Error(`Stripe customer ${stripeCustomerId} has been deleted`) + } + return customer +} + +async function fetchTargetStripeCustomerPaymentMethods( + stripeClient, + stripeCustomerId, + context +) { + const paymentMethods = await rateLimiters.requestWithRetries( + stripeClient.serviceName, + () => stripeClient.customers.listPaymentMethods(stripeCustomerId), + { ...context, stripeApi: 'customers.listPaymentMethods' } + ) + return paymentMethods.data +} + +// ============================================================================= +// COMPARISON LOGIC +// ============================================================================= + +/** + * Compare a single customer's Recurly data against Stripe. + */ +async function compareCustomer(row, rowNumber, comparisonDate) { + const { + recurly_account_code: recurlyAccountCode, + target_stripe_account: targetStripeAccount, + stripe_customer_id: stripeCustomerId, + } = row + + const context = { + rowNumber, + recurlyAccountCode, + targetStripeAccount, + stripeCustomerId, + } + + const stripeContext = { + rowNumber, + stripeCustomerId, + stripeAccount: targetStripeAccount, + } + + const result = { + recurly_account_code: recurlyAccountCode, + target_stripe_account: targetStripeAccount, + stripe_customer_id: stripeCustomerId, + status: '', // 'match', 'mismatch', or 'error' + diffs: '', + error: '', + diffDetails: null, + } + + try { + if (!recurlyAccountCode) throw new Error('Missing recurly_account_code') + if (!targetStripeAccount) throw new Error('Missing target_stripe_account') + if (!stripeCustomerId) throw new Error('Missing stripe_customer_id') + + const region = String(targetStripeAccount || '') + .trim() + .toLowerCase() + .replace(/^stripe-/, '') + const stripeClient = getRegionClient(region) + + const account = await fetchRecurlyData(recurlyAccountCode, context) + const recurlyAccountUpdatedAt = account.updatedAt?.getTime() || 0 + + // If Recurly account was not updated after the comparison date, consider it a match + if (recurlyAccountUpdatedAt <= comparisonDate.getTime()) { + result.status = 'match' + result.diffs = '' + return result + } + + const stripeCustomer = await fetchTargetStripeCustomer( + stripeClient, + stripeCustomerId, + stripeContext + ) + + const stripePaymentMethods = await fetchTargetStripeCustomerPaymentMethods( + stripeClient, + stripeCustomerId, + stripeContext + ) + + const diffs = await compareAccountFields({ + account, + stripeCustomer, + overleafUserId: recurlyAccountCode, + fetchCollectionMethod: async () => { + const subscription = await fetchRecurlySubscription( + recurlyAccountCode, + context + ) + return subscription?.collectionMethod || null + }, + stripePaymentMethods, + stripeServiceName: stripeClient.serviceName, + }) + + // Determine result + const diffKeys = Object.keys(diffs) + if (diffKeys.length === 0) { + result.status = 'match' + result.diffs = '' + } else { + result.status = 'mismatch' + result.diffs = diffKeys.join('; ') + result.diffDetails = { + recurly_account_code: recurlyAccountCode, + stripe_customer_id: stripeCustomerId, + target_stripe_account: targetStripeAccount, + comparison_date: comparisonDate.toISOString(), + recurly_account_updated_at: account.updatedAt?.toISOString(), + diffs, + } + + logDebug('Customer has diffs', { + ...context, + diffFields: diffKeys, + }) + } + } catch (error) { + result.status = 'error' + const errorDetails = [error.message] + if (error.code) errorDetails.push(`code=${error.code}`) + if (error.type) errorDetails.push(`type=${error.type}`) + if (error.statusCode) errorDetails.push(`statusCode=${error.statusCode}`) + result.error = errorDetails.join('; ') + logError('Failed to compare customer', error, context) + } + + return result +} + +// ============================================================================= +// CSV HELPERS +// ============================================================================= + +function formatCsvRow(columns, row) { + const values = columns.map(col => { + const raw = row[col] + const val = raw == null ? '' : String(raw) + if (val.includes(',') || val.includes('"') || val.includes('\n')) { + return `"${val.replace(/"/g, '""')}"` + } + return val + }) + return values.join(',') + '\n' +} + +function createJsonArrayWriter(jsonPath) { + const stream = fs.createWriteStream(jsonPath, { flags: 'w' }) + stream.write('[\n') + let wroteAny = false + + function write(value) { + const serialized = JSON.stringify(value, null, 2) + if (wroteAny) stream.write(',\n') + stream.write(serialized) + wroteAny = true + } + + async function close() { + stream.write('\n]\n') + stream.end() + await new Promise((resolve, reject) => { + stream.on('finish', resolve) + stream.on('error', reject) + }) + } + + return { write, close } +} + +// ============================================================================= +// CLI +// ============================================================================= + +function usage() { + console.error( + 'Compare Recurly customer data against migrated Stripe customers' + ) + console.error('') + console.error('Usage:') + console.error( + ' node scripts/recurly/compare_recurly_stripe_customers.mjs [options]' + ) + console.error('') + console.error('Options:') + console.error(' --input, -i Path to input CSV file (required)') + console.error(' --output, -o Path to output CSV file (required)') + console.error( + ' --comparison-date Date in YYYY-MM-DD to compare against (required)' + ) + console.error( + ' --limit, -l Limit number of records processed (default: no limit)' + ) + console.error( + ' --concurrency, -c Number of customers to process concurrently (default: 10)' + ) + console.error( + ' --recurly-rate-limit Requests per second for Recurly (default: 10)' + ) + console.error( + ' --recurly-api-retries Number of retries on Recurly 429s (default: 5)' + ) + console.error( + ' --recurly-retry-delay-ms Delay between Recurly retries in ms (default: 1000)' + ) + console.error( + ' --stripe-rate-limit Requests per second for Stripe (default: 50)' + ) + console.error( + ' --stripe-api-retries Number of retries on Stripe 429s (default: 5)' + ) + console.error( + ' --stripe-retry-delay-ms Delay between Stripe retries in ms (default: 1000)' + ) + console.error(' --verbose, -v Enable debug logging') +} + +function parseConcurrency(value, { defaultValue = 10 } = {}) { + if (value === undefined || value === null || value === '') return defaultValue + const parsed = Number(value) + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1) { + throw new Error( + `Invalid --concurrency value: ${value}. Expected a positive integer.` + ) + } + return parsed +} + +function parseRateLimit(value, { defaultValue, name }) { + if (value === undefined || value === null || value === '') return defaultValue + const parsed = Number(value) + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error( + `Invalid --${name} value: ${value}. Expected a positive number.` + ) + } + return parsed +} + +function parseNonNegativeInt(value, { defaultValue, name }) { + if (value === undefined || value === null || value === '') return defaultValue + const parsed = Number(value) + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 0) { + throw new Error( + `Invalid --${name} value: ${value}. Expected a non-negative integer.` + ) + } + return parsed +} + +function parseArgs() { + return minimist(process.argv.slice(2), { + alias: { + i: 'input', + o: 'output', + h: 'help', + v: 'verbose', + c: 'concurrency', + l: 'limit', + }, + string: [ + 'input', + 'output', + 'comparison-date', + 'limit', + 'recurly-rate-limit', + 'recurly-api-retries', + 'recurly-retry-delay-ms', + 'stripe-rate-limit', + 'stripe-api-retries', + 'stripe-retry-delay-ms', + ], + boolean: ['verbose', 'help'], + default: { + verbose: false, + concurrency: 10, + 'recurly-rate-limit': DEFAULT_RECURLY_RATE_LIMIT, + 'recurly-api-retries': DEFAULT_RECURLY_API_RETRIES, + 'recurly-retry-delay-ms': DEFAULT_RECURLY_RETRY_DELAY_MS, + 'stripe-rate-limit': DEFAULT_STRIPE_RATE_LIMIT, + 'stripe-api-retries': DEFAULT_STRIPE_API_RETRIES, + 'stripe-retry-delay-ms': DEFAULT_STRIPE_RETRY_DELAY_MS, + }, + }) +} + +// ============================================================================= +// MAIN +// ============================================================================= + +async function main(trackProgress) { + const startTime = new Date() + const args = parseArgs() + const { + input: inputPath, + output: outputPath, + 'comparison-date': comparisonDateRaw, + verbose, + help, + concurrency: concurrencyRaw, + limit: limitRaw, + 'recurly-rate-limit': recurlyRateLimitRaw, + 'recurly-api-retries': recurlyApiRetriesRaw, + 'recurly-retry-delay-ms': recurlyRetryDelayMsRaw, + 'stripe-rate-limit': stripeRateLimitRaw, + 'stripe-api-retries': stripeApiRetriesRaw, + 'stripe-retry-delay-ms': stripeRetryDelayMsRaw, + } = args + + let concurrency, + recurlyRateLimit, + recurlyApiRetriesValue, + recurlyRetryDelayMsValue, + stripeRateLimitPerSecond, + stripeApiRetriesValue, + stripeRetryDelayMsValue, + limit, + comparisonDate + try { + concurrency = parseConcurrency(concurrencyRaw, { defaultValue: 10 }) + limit = parseNonNegativeInt(limitRaw, { + defaultValue: null, + name: 'limit', + }) + recurlyRateLimit = parseRateLimit(recurlyRateLimitRaw, { + defaultValue: DEFAULT_RECURLY_RATE_LIMIT, + name: 'recurly-rate-limit', + }) + recurlyApiRetriesValue = parseNonNegativeInt(recurlyApiRetriesRaw, { + defaultValue: DEFAULT_RECURLY_API_RETRIES, + name: 'recurly-api-retries', + }) + recurlyRetryDelayMsValue = parseNonNegativeInt(recurlyRetryDelayMsRaw, { + defaultValue: DEFAULT_RECURLY_RETRY_DELAY_MS, + name: 'recurly-retry-delay-ms', + }) + stripeRateLimitPerSecond = parseRateLimit(stripeRateLimitRaw, { + defaultValue: DEFAULT_STRIPE_RATE_LIMIT, + name: 'stripe-rate-limit', + }) + stripeApiRetriesValue = parseNonNegativeInt(stripeApiRetriesRaw, { + defaultValue: DEFAULT_STRIPE_API_RETRIES, + name: 'stripe-api-retries', + }) + stripeRetryDelayMsValue = parseNonNegativeInt(stripeRetryDelayMsRaw, { + defaultValue: DEFAULT_STRIPE_RETRY_DELAY_MS, + name: 'stripe-retry-delay-ms', + }) + if (!comparisonDateRaw) { + throw new Error('--comparison-date is required') + } + const dateMatch = comparisonDateRaw.match(/^(\d{4})-(\d{2})-(\d{2})$/) + if (!dateMatch) { + throw new Error('--comparison-date must be in YYYY-MM-DD format') + } + const [, year, month, day] = dateMatch + comparisonDate = new Date(`${year}-${month}-${day}T00:00:00.000Z`) + if (isNaN(comparisonDate.getTime())) { + throw new Error('Invalid date provided for --comparison-date') + } + } catch (error) { + logError(error.message) + usage() + process.exit(1) + } + + rateLimiters = createRateLimitedApiWrappers({ + recurlyRateLimit, + recurlyApiRetries: recurlyApiRetriesValue, + recurlyRetryDelayMs: recurlyRetryDelayMsValue, + stripeRateLimit: stripeRateLimitPerSecond, + stripeApiRetries: stripeApiRetriesValue, + stripeRetryDelayMs: stripeRetryDelayMsValue, + logDebug, + logWarn, + }) + + DEBUG_MODE = !!verbose + + if (help || !inputPath || !outputPath) { + usage() + process.exit(help ? 0 : 1) + } + + const errorsPath = outputPath.replace(/\.csv$/, '_errors.csv') + const detailsJsonPath = outputPath.replace(/\.csv$/, '_details.json') + + logDebug('Starting comparison', { + inputPath, + outputPath, + errorsPath, + detailsJsonPath, + comparisonDate: comparisonDate.toISOString(), + concurrency, + ...(limit != null ? { limit } : {}), + }) + await trackProgress('Starting comparison') + + // Output CSV columns + const outputColumns = [ + 'recurly_account_code', + 'target_stripe_account', + 'stripe_customer_id', + 'status', + 'diffs', + ] + const errorColumns = [ + 'recurly_account_code', + 'target_stripe_account', + 'stripe_customer_id', + 'error', + ] + + const outputStream = fs.createWriteStream(outputPath, { flags: 'w' }) + outputStream.write(outputColumns.join(',') + '\n') + + const errorsStream = fs.createWriteStream(errorsPath, { flags: 'w' }) + errorsStream.write(errorColumns.join(',') + '\n') + + const detailsWriter = createJsonArrayWriter(detailsJsonPath) + + try { + let totalInInput = 0 + let processedCount = 0 + let matchCount = 0 + let mismatchCount = 0 + let errorCount = 0 + let queuedCount = 0 + + const inputStream = fs.createReadStream(inputPath) + const parser = csv.parse({ + columns: true, + trim: true, + bom: true, + skip_empty_lines: true, + relax_column_count: true, + relax_column_count_less: true, + }) + inputStream.pipe(parser) + + const queue = new PQueue({ concurrency }) + const maxQueueSize = concurrency + + let rowNumber = 0 + let limitReached = false + + try { + for await (const row of parser) { + rowNumber++ + totalInInput++ + + const thisRowNumber = rowNumber + + if (limit != null && queuedCount >= limit) { + limitReached = true + logDebug('Record limit reached', { limit, queuedCount }) + break + } + + if (queue.size >= maxQueueSize) { + await queue.onSizeLessThan(maxQueueSize) + } + + queuedCount++ + queue.add(async () => { + let result + try { + result = await compareCustomer(row, thisRowNumber, comparisonDate) + } catch (error) { + result = { + ...row, + status: 'error', + diffs: '', + error: error?.message || String(error), + diffDetails: null, + } + logError('Unhandled error', error, { + rowNumber: thisRowNumber, + accountCode: row.recurly_account_code, + }) + } + + processedCount++ + + if (result.status === 'match') { + matchCount++ + outputStream.write(formatCsvRow(outputColumns, result)) + } else if (result.status === 'mismatch') { + mismatchCount++ + outputStream.write(formatCsvRow(outputColumns, result)) + if (result.diffDetails) { + detailsWriter.write(result.diffDetails) + } + } else { + errorCount++ + errorsStream.write(formatCsvRow(errorColumns, result)) + } + + const progressInterval = DEBUG_MODE ? 100 : 1000 + if (processedCount % progressInterval === 0) { + logDebug('Progress', { + processedCount, + matchCount, + mismatchCount, + errorCount, + }) + await trackProgress( + `Progress: ${processedCount} processed, ${matchCount} match, ${mismatchCount} mismatch, ${errorCount} errors` + ) + } + }) + } + } finally { + await queue.onIdle() + } + + if (limitReached) { + await trackProgress(`Limit reached (${limit}).`) + } + + // Final summary + const endTime = new Date() + const durationMs = endTime.getTime() - startTime.getTime() + const durationSeconds = Math.floor(durationMs / 1000) + + const finalStats = rateLimiters.getRateLimiterStats() + + await trackProgress('=== COMPARISON SUMMARY ===') + await trackProgress(`Total in input: ${totalInInput}`) + await trackProgress(`Processed: ${processedCount}`) + await trackProgress(` Match: ${matchCount}`) + await trackProgress(` Mismatch: ${mismatchCount}`) + await trackProgress(` Error: ${errorCount}`) + await trackProgress(`Duration: ${durationSeconds}s`) + await trackProgress(`Output: ${outputPath}`) + await trackProgress(`Errors: ${errorsPath} (${errorCount} records)`) + await trackProgress( + `Details: ${detailsJsonPath} (${mismatchCount} records)` + ) + await trackProgress( + `API calls - Recurly: ${finalStats.recurly.totalRequests}, Stripe: ${finalStats.stripe.totalRequests}` + ) + + logDebug('Comparison complete', { + totalInInput, + processedCount, + matchCount, + mismatchCount, + errorCount, + }) + + return errorCount === 0 && mismatchCount === 0 ? 0 : 1 + } finally { + outputStream.end() + errorsStream.end() + + const results = await Promise.allSettled([ + new Promise((resolve, reject) => { + outputStream.on('finish', resolve) + outputStream.on('error', reject) + }), + new Promise((resolve, reject) => { + errorsStream.on('finish', resolve) + errorsStream.on('error', reject) + }), + detailsWriter.close(), + ]) + + for (const result of results) { + if (result.status === 'rejected') { + logWarn('Failed to close output stream', { + error: result.reason?.message || String(result.reason), + }) + } + } + } +} + +try { + const exitCode = await scriptRunner(main) + process.exit(exitCode ?? 0) +} catch (error) { + logError('Script failed with unhandled error', error) + process.exit(1) +} diff --git a/services/web/scripts/recurly/migrate_recurly_customers_to_stripe.mjs b/services/web/scripts/recurly/migrate_recurly_customers_to_stripe.mjs index 80985ee167..775cb2198b 100644 --- a/services/web/scripts/recurly/migrate_recurly_customers_to_stripe.mjs +++ b/services/web/scripts/recurly/migrate_recurly_customers_to_stripe.mjs @@ -93,13 +93,17 @@ import { scriptRunner } from '../lib/ScriptRunner.mjs' import { areStripeAndRecurlyCardDetailsEqual, coalesceOrThrowPaymentMethod, - extractNameFromAccount, - extractNameFromBillingInfo, getTaxIdType, - normalisedGBVATNumber, normalizeRecurlyAddressToStripe, normalizeName, + addressesEqual, + resolveCustomerIdentity, sanitizeAccount, + normalizeComparableString, + hasAnyAddressValue, + ccEmailsToArray, + RECURLY_CUSTOM_FIELD_NAMES, + extractRecurlyCustomFieldMetadata, } from '../helpers/migrate_recurly_customers_to_stripe.helpers.mjs' import { createRateLimitedApiWrappers, @@ -182,66 +186,6 @@ if (!recurlyApiKey) { } const recurlyClient = new recurly.Client(recurlyApiKey) -// ============================================================================= -// CUSTOM FIELDS -// ============================================================================= - -const RECURLY_CUSTOM_FIELD_NAMES = [ - 'channel', - 'Industry', - 'ol_sales_person', - 'MigratedfromFreeAgent', -] - -function getRecurlyCustomFields(account) { - const customFields = account?.customFields - if (customFields == null) { - throw new Error( - 'Recurly account is missing customFields (empty array is acceptable)' - ) - } - if (!Array.isArray(customFields)) { - throw new Error('Recurly account customFields is not an array') - } - return customFields -} - -function extractRecurlyCustomFieldMetadata(account) { - const customFields = getRecurlyCustomFields(account) - - /** @type {Record} */ - 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 } -} - // ============================================================================= // LOGGING UTILITIES // ============================================================================= @@ -926,65 +870,8 @@ function isStripeTaxIdInvalidError(error) { return error.code === 'tax_id_invalid' } -function normalizeComparableString(value) { - if (value == null) return '' - return String(value).trim() -} - const STRIPE_METADATA_MAX_ALT_EMAILS = 5 -function ccEmailsToArray(ccEmails) { - if (ccEmails == null || ccEmails === undefined) { - return [] - } - - // regex splits on commas, semicolons or whitespace and trims each email - // empty values are filtered out - const normalisedEmails = String(ccEmails) - .split(/[\s,;]+/) - .filter(Boolean) - - const deDupedEmails = [...new Set(normalisedEmails)] - - return deDupedEmails -} - -function hasAnyAddressValue(address) { - if (!address || typeof address !== 'object') return false - return Object.values(address).some(v => normalizeComparableString(v) !== '') -} - -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 -} - -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 - ) -} - // ============================================================================= // MAIN PROCESSING // ============================================================================= @@ -1216,153 +1103,6 @@ async function updatePaymentMethodBillingDetails( ) } -/** - * 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, the subscription's - * collection_method is fetched 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 {string} recurlyAccountCode - Account code (used for subscription lookup on conflict) - * @param {object} context - Logging context - * @returns {Promise<{ - * name: string|null, - * address: import('stripe').Stripe.AddressParam|null, - * companyName: string|null, - * vatNumber: string|null, - * collectionMethod: string|null, - * billingInfoForPaymentMethod: object|null - * }>} - */ -async function resolveCustomerIdentity(account, recurlyAccountCode, context) { - // Detect conflicts between billing info and account fields - 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) { - // No conflict: use the standard coalesce logic (billing info preferred) - name = billingName ?? accountName - address = billingAddress ?? accountAddress - companyName = billingCompany ?? accountCompany - vatNumber = billingVat ?? accountVat - collectionMethod = null - billingInfoForPaymentMethod = null - } else { - // Conflict detected: fetch the subscription to determine which source wins - logWarn( - 'Conflict between billing info and account fields; fetching subscription collection method to resolve', - { - ...context, - nameConflict, - addressConflict, - companyConflict, - vatConflict, - } - ) - - const subscription = await fetchRecurlyActiveSubscription( - recurlyAccountCode, - context - ) - collectionMethod = subscription?.collectionMethod || null - - if (!collectionMethod) { - throw new Error( - 'Conflict between billing info and account fields, but no live subscription found to determine collection method' - ) - } - - logDebug( - 'Resolving billing info / account conflict using subscription collection method', - { - ...context, - collectionMethod, - nameConflict, - addressConflict, - companyConflict, - vatConflict, - }, - { verboseOnly: true } - ) - - if (collectionMethod === 'automatic') { - // Web sales: use billing info for the Stripe customer record - name = billingName ?? accountName - address = billingAddress ?? accountAddress - companyName = billingCompany ?? accountCompany - vatNumber = billingVat ?? accountVat - billingInfoForPaymentMethod = null - } else if (collectionMethod === 'manual') { - // Manual billing: use account info for the Stripe customer record, - // and return billing info to be copied to the payment method's billing_details - name = accountName ?? billingName - address = accountAddress ?? billingAddress - companyName = accountCompany ?? billingCompany - vatNumber = accountVat ?? billingVat - billingInfoForPaymentMethod = account.billingInfo - } else { - throw new Error( - `Unknown collection method "${collectionMethod}" encountered while resolving billing info / account conflict` - ) - } - } - - // Normalise GB VAT numbers using the resolved address country - if (vatNumber && address?.country === 'GB') { - vatNumber = normalisedGBVATNumber(vatNumber) - } - - return { - name, - address, - companyName, - vatNumber, - collectionMethod, - billingInfoForPaymentMethod, - } -} - /** * Process a single customer row from the input CSV. * @@ -1485,7 +1225,23 @@ async function processCustomer( companyName, vatNumber, billingInfoForPaymentMethod, - } = await resolveCustomerIdentity(account, recurlyAccountCode, context) + } = await resolveCustomerIdentity(account, async () => { + logWarn( + 'Conflict between billing info and account fields; fetching subscription collection method to resolve', + { ...context } + ) + const subscription = await fetchRecurlyActiveSubscription( + recurlyAccountCode, + context + ) + const cm = subscription?.collectionMethod || null + logDebug( + 'Resolved collection method for conflict', + { ...context, collectionMethod: cm }, + { verboseOnly: true } + ) + return cm + }) if (name === null && companyName === null) { // This should not happen since we're handling all the known cases in resolveCustomerIdentity but just in case diff --git a/services/web/scripts/stripe/finalize-stripe-subscription-migration.mjs b/services/web/scripts/stripe/finalize-stripe-subscription-migration.mjs index fa53e8eaa8..b2bb9c6a3b 100755 --- a/services/web/scripts/stripe/finalize-stripe-subscription-migration.mjs +++ b/services/web/scripts/stripe/finalize-stripe-subscription-migration.mjs @@ -39,6 +39,8 @@ import fs from 'node:fs' import path from 'node:path' import * as csv from 'csv' import minimist from 'minimist' +import Settings from '@overleaf/settings' +import recurly from 'recurly' import PQueue from 'p-queue' import { z } from '../../app/src/infrastructure/Validation.mjs' import { scriptRunner } from '../lib/ScriptRunner.mjs' @@ -56,6 +58,7 @@ import UserAnalyticsIdCache from '../../app/src/Features/Analytics/UserAnalytics import CustomerIoHandler from '../../modules/customer-io/app/src/CustomerIoHandler.mjs' import { ReportError } from './helpers.mjs' import isEqual from 'lodash/isEqual.js' +import { compareAccountFields } from '../helpers/migrate_recurly_customers_to_stripe.helpers.mjs' import { createRateLimitedApiWrappers, DEFAULT_RECURLY_RATE_LIMIT, @@ -71,6 +74,16 @@ const preloadedProductMetadata = new Map() // rate limiters - initialized in main() let rateLimiters +// Recurly SDK client - initialized at module level +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 recurlyClient = new recurly.Client(recurlyApiKey) + function usage() { console.error(`Usage: node scripts/stripe/finalize-stripe-subscription-migration.mjs [OPTS] [INPUT-FILE] @@ -366,16 +379,13 @@ async function processMigration(input, commit) { ) } - // 5. Fetch Recurly subscription + // 5. Fetch Recurly subscription and account let recurlySubscription try { recurlySubscription = await rateLimiters.requestWithRetries( 'recurly', () => - RecurlyWrapper.promises.getSubscription( - previousRecurlySubscriptionId, - {} - ), + recurlyClient.getSubscription(`uuid-${previousRecurlySubscriptionId}`), { operation: 'getSubscription', recurlySubscriptionId: previousRecurlySubscriptionId, @@ -388,12 +398,41 @@ async function processMigration(input, commit) { ) } + let recurlyAccount + try { + recurlyAccount = await rateLimiters.requestWithRetries( + 'recurly', + () => recurlyClient.getAccount(`code-${overleafUserId}`), + { + operation: 'getAccount', + overleafUserId, + } + ) + } catch (err) { + throw new ReportError( + 'no-recurly-account', + `Recurly account not found: ${err.message}` + ) + } + // 6. Detect changes between Recurly and Stripe - const changes = detectChanges(recurlySubscription, stripeSubscription, region) - if (changes.length > 0) { + const subscriptionChanges = detectSubscriptionChanges( + recurlySubscription, + stripeSubscription, + region + ) + const accountChanges = await detectAccountChanges( + overleafUserId, + stripeCustomerId, + stripeClient, + recurlyAccount, + recurlySubscription.collectionMethod || null + ) + const allChanges = [...subscriptionChanges, ...accountChanges] + if (allChanges.length > 0) { throw new ReportError( 'changes-detected', - `Changes detected between Recurly and Stripe: ${changes.join('; ')}` + `Changes detected between Recurly and Stripe: ${allChanges.join('; ')}` ) } @@ -450,38 +489,42 @@ function formatItems(items) { .join(', ') } -function detectChanges(recurlySubscription, stripeSubscription, region) { +function detectSubscriptionChanges( + recurlySubscription, + stripeSubscription, + region +) { const changes = [] // Extract item details from Recurly subscription const targetRecurlySubscription = - recurlySubscription.pending_subscription || recurlySubscription + recurlySubscription.pendingChange || recurlySubscription const recurlyPlanItem = PlansLocator.convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded( - targetRecurlySubscription.plan.plan_code + targetRecurlySubscription.plan.code ) const simplifiedPlanCode = recurlyPlanItem.planCode.replace( /_free_trial.*$/, '' ) const additionalLicenseQuantity = - (targetRecurlySubscription.subscription_add_ons || []).find( - addOn => addOn.add_on_code === 'additional-license' + (targetRecurlySubscription.addOns || []).find( + addOn => addOn.addOn.code === 'additional-license' )?.quantity || 0 const recurlyItems = [ { code: simplifiedPlanCode, quantity: recurlyPlanItem.quantity + additionalLicenseQuantity, amount: - targetRecurlySubscription.unit_amount_in_cents / + Math.round(targetRecurlySubscription.unitAmount * 100) / recurlyPlanItem.quantity, }, - ...(targetRecurlySubscription.subscription_add_ons || []) - .filter(addOn => addOn.add_on_code !== 'additional-license') + ...(targetRecurlySubscription.addOns || []) + .filter(addOn => addOn.addOn.code !== 'additional-license') .map(addOn => ({ - code: addOn.add_on_code, + code: addOn.addOn.code, quantity: addOn.quantity, - amount: addOn.unit_amount_in_cents, + amount: Math.round(addOn.unitAmount * 100), })), ].sort((a, b) => a.code.localeCompare(b.code)) @@ -526,6 +569,109 @@ function detectChanges(recurlySubscription, stripeSubscription, region) { return changes } +/** + * Detect account-level drift between the Recurly account and the migrated Stripe customer. + * + * Uses the Recurly SDK account (which includes billing info), and re-retrieves + * the Stripe customer with expanded tax_ids and default_payment_method so the + * comparison can cover all the fields that the customer-migration script set. + * + * @param {string} overleafUserId - Recurly account code / Overleaf user ID + * @param {string} stripeCustomerId - Stripe customer ID + * @param {object} stripeClient - Stripe client (from getRegionClient) + * @param {object} account - Recurly SDK account object (from recurlyClient.getAccount) + * @param {string|null} collectionMethod - Recurly subscription collection method + * @returns {Promise} - Array of change descriptions (empty = no drift) + */ +async function detectAccountChanges( + overleafUserId, + stripeCustomerId, + stripeClient, + account, + collectionMethod +) { + const context = { overleafUserId, stripeCustomerId } + + // Fetch the Stripe customer with tax_ids and payment method expanded + const stripeCustomer = await rateLimiters.requestWithRetries( + stripeClient.serviceName, + () => + stripeClient.stripe.customers.retrieve(stripeCustomerId, { + expand: ['tax_ids', 'invoice_settings.default_payment_method'], + }), + { ...context, operation: 'customers.retrieve' } + ) + + if (stripeCustomer.deleted) { + return [`Stripe customer ${stripeCustomerId} has been deleted`] + } + + // Pre-fetch payment methods if needed for comparison + let stripePaymentMethods = [] + const isPaypalBillingAgreement = + account.billingInfo?.paymentMethod?.object === 'paypal_billing_agreement' + if (!isPaypalBillingAgreement && account.billingInfo?.paymentMethod) { + const result = await rateLimiters.requestWithRetries( + stripeClient.serviceName, + () => stripeClient.stripe.customers.listPaymentMethods(stripeCustomerId), + { ...context, operation: 'customers.listPaymentMethods' } + ) + stripePaymentMethods = result.data + } + + const diffs = await compareAccountFields({ + account, + stripeCustomer, + overleafUserId, + fetchCollectionMethod: async () => collectionMethod, + stripePaymentMethods, + stripeServiceName: stripeClient.serviceName, + }) + + return formatDiffsAsChanges(diffs) +} + +/** + * Convert structured diffs from compareAccountFields into human-readable change descriptions. + */ +function formatDiffsAsChanges(diffs) { + const changes = [] + for (const [field, diff] of Object.entries(diffs)) { + if (field === 'address') { + changes.push( + `Address: Recurly=${JSON.stringify(diff.recurly)}, Stripe=${JSON.stringify(diff.stripe)}` + ) + } else if (field === 'cc_emails') { + changes.push( + `CC emails: Recurly=[${[...diff.recurly].sort().join(',')}], Stripe=[${[...(diff.stripe || [])].sort().join(',')}]` + ) + } else if (field === 'tax_id') { + const stripeStr = diff.stripe + ? diff.stripe.map(t => `{type:${t.type}, value:${t.value}}`).join(', ') + : '(none)' + changes.push( + `Tax ID: Recurly={type:${diff.recurly.type}, value:${diff.recurly.value}}, Stripe=${stripeStr}` + ) + } else if (field === 'default_payment_method') { + changes.push( + `Payment method: Recurly=${diff.recurly.type || diff.recurly.last4 || '(none)'}, Stripe=${diff.stripe.type || '(none)'}` + ) + } else if (field.startsWith('metadata.')) { + const key = field.slice('metadata.'.length) + changes.push( + `Metadata ${key}: Recurly=${diff.recurly || '(empty)'}, Stripe=${diff.stripe || '(empty)'}` + ) + } else { + const label = + field.charAt(0).toUpperCase() + field.slice(1).replace(/_/g, ' ') + changes.push( + `${label}: Recurly=${diff.recurly || '(empty)'}, Stripe=${diff.stripe || '(empty)'}` + ) + } + } + return changes +} + async function performCutover( mongoSubscription, stripeSubscription, @@ -568,9 +714,7 @@ async function performCutover( // Step 3: Postpone Recurly billing by +10 years if Recurly subscription is active if (recurlySubscription.state !== 'canceled') { - const currentBillingDate = new Date( - recurlySubscription.current_period_ends_at - ) + const currentBillingDate = new Date(recurlySubscription.currentPeriodEndsAt) const postponedDate = new Date(currentBillingDate) postponedDate.setFullYear(currentBillingDate.getFullYear() + 10)