From f2832c560b48471a1c380c42fcd2a66fe5828e60 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Mon, 15 Jun 2026 14:21:22 -0400 Subject: [PATCH] fix(ebs-sync): resolve address validation like Magento Resolve ShipTo validation from the stored shipping flag and BillTo validation from Chase AVS rules while preserving card last-four data for EBS using PCI-safe fields. --- .eslintrc.json | 6 +- src/actions/ebs-sync/ebs.js | 68 ++++++++++++--- src/actions/ebs-sync/types.d.ts | 19 ++++- src/actions/submit/index.js | 2 +- test/ebs-sync/ebs-e2e.test.js | 100 ++++++++++++++++++++++- test/ebs-sync/journal.test.js | 36 +++++++- test/fixtures/expected-cc-approved.xml | 2 +- test/fixtures/journal-cc-approved.ndjson | 2 +- 8 files changed, 209 insertions(+), 26 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index b8472af..499b2b7 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,14 +1,14 @@ { "env": { "es6": true, - "node": true, - "sourceType": "module" + "node": true }, "extends": [ "eslint:recommended", "plugin:jest/recommended" ], "parserOptions": { - "ecmaVersion": "latest" + "ecmaVersion": "latest", + "sourceType": "module" } } \ No newline at end of file diff --git a/src/actions/ebs-sync/ebs.js b/src/actions/ebs-sync/ebs.js index d5a5414..29b2905 100644 --- a/src/actions/ebs-sync/ebs.js +++ b/src/actions/ebs-sync/ebs.js @@ -14,7 +14,7 @@ * * ── Payment data sources ───────────────────────────────────────────────────── * Chase (provider='chase') payment_completed: - * transactionId, approvalCode, cardType (brand), cardNumber (masked mPAN), + * transactionId, approvalCode, cardType (brand), cardLast4, * cardExpiry (MMYY), avsMatch, cvvMatch, amount, currency * providerData.safetechResponse (pipe-delimited, optional) * fraud_evaluated: decision ('approve'|'decline'|'not_reviewed'|'pending') @@ -53,6 +53,10 @@ import { proxyFetch } from '../../proxy.js'; const TRANSIENT_ERROR_PATTERN = /timeout|timed out|aborted|fetch failed|network|ECONNRESET|ENOTFOUND|ETIMEDOUT|EAI_AGAIN/i; +const CHASE_AVS_UNVALIDATED_CODES = new Set([ + '1', '2', '3', '4', '5', '6', '8', 'G', 'J', 'M1', 'M8', 'N4', 'N6', 'R', 'UK', +]); + /** * Classify whether an error from syncOrderToEbs is worth retrying. * @@ -149,6 +153,8 @@ function extractEbsErrorMessage(xml) { * - The fraud_evaluated journal entry (fraud decision for Chase order state) * - order.estimates (fallback for taxAmount/shippingCost/subtotal when * the payment_completed entry was logged without locked-in estimates) + * - payment_completed.cardLast4 or order.payment.cardLastFour for EBS + * CreditCard/@CardLast4Digits * * Returns null when no payment_completed entry is present in the journal. * @@ -220,8 +226,8 @@ export function buildPaymentSnapshot(order, orderJournal) { approvalCode: completed.approvalCode || '', // cardType from Chase queryTransaction is the card brand (e.g. "Visa") cardBrand: completed.cardType || '', - // cardNumber is the masked mPAN (e.g. "401288XXXXXX1881") — last 4 for EBS - last4: (completed.cardNumber || '').slice(-4), + // Magento sends getCcLast4(); Edge stores only the final four digits. + last4: resolveCardLast4(completed, order), // cardExpiry from Chase in MMYY format (e.g. "0127" = January 2027) expiration: cardExpiryToDate(completed.cardExpiry || ''), approvalDate: completed.timestamp, @@ -237,6 +243,7 @@ export function buildPaymentSnapshot(order, orderJournal) { fraudAutoDecisionResponse: safetech.AutoDecisionResponse || '', // Forter / fraud provider decision from fraud_evaluated entry fraudDecision, + avsMatch: completed.avsMatch || '', // MISSING: storedCredentials (payment plan indicator not in journal) — 'N' storedCredentials: 'N', // MISSING: mitMsgType (not in journal) — 'CGEN' (standard one-time payment) @@ -283,9 +290,8 @@ export function buildPaymentSnapshot(order, orderJournal) { ...base, transactionId: completed.transactionId || '', approvalCode: completed.approvalCode || '', - // cardBrand and last4 not yet available in chase-wallet journal entries - cardBrand: completed.cardType || '', - last4: (completed.cardNumber || '').slice(-4), + cardBrand: completed.cardBrand || completed.cardType || '', + last4: (completed.cardLast4 || '').slice(-4), nameOnCard: completed.nameOnCard || '', approvalDate: completed.timestamp, fraudDecision, @@ -362,7 +368,7 @@ function buildCreateOrderXml(order, paymentSnapshot) { ReferrerCode="${escapeXml(referrerCode)}" Key="${escapeXml(orderKey)}" Created="${created}"> - ${buildCustomerXml(order)} + ${buildCustomerXml(order, paymentSnapshot)} ${buildPaymentXml(paymentSnapshot, order)} ${giftMessage} @@ -376,7 +382,7 @@ function buildCreateOrderXml(order, paymentSnapshot) { `; } -function buildCustomerXml(order) { +function buildCustomerXml(order, paymentSnapshot) { const email = escapeXml(order.customer?.email || ''); const firstName = escapeXml(sanitizeName(order.customer?.firstName || '')); const lastName = escapeXml(sanitizeName(order.customer?.lastName || '')); @@ -385,8 +391,14 @@ function buildCustomerXml(order) { ); // billing falls back to shipping when absent - const billing = order.billing || order.shipping || {}; - const shipping = order.shipping || {}; + const billing = { + ...(order.billing || order.shipping || {}), + isValidated: resolveBillingIsValidated(paymentSnapshot), + }; + const shipping = { + ...(order.shipping || {}), + isValidated: resolveShippingIsValidated(order), + }; return ``; } +/** + * Resolve ShipTo validation from the stored shipping address flag. + * Missing flags default to true for older and express-checkout orders. + * + * @param {object} order + * @returns {boolean} + */ +function resolveShippingIsValidated(order) { + return order.shipping?.isValidated !== false; +} + +/** + * Resolve BillTo validation using Magento's Chase AVS rules. + * Non-Chase methods and zero-total Chase orders are always considered validated. + * + * @param {object} paymentSnapshot + * @returns {boolean} + */ +function resolveBillingIsValidated(paymentSnapshot) { + if (paymentSnapshot?.method !== 'chasehpp') return true; + if (Number(paymentSnapshot.amount || 0) === 0) return true; + return !CHASE_AVS_UNVALIDATED_CODES.has(String(paymentSnapshot.avsMatch || '').trim()); +} + /** * Build a BillTo or ShipTo address block. * Uses OrderAddress field names: address1, address2, state, zip. @@ -858,6 +894,18 @@ function parseSafetech(response) { return result; } +/** + * Resolve the final four credit card digits for EBS. + * Magento sends getCcLast4(); Edge stores only last-four values for PCI scope. + * + * @param {object} completed - payment_completed journal entry + * @param {object} order - stored order document + * @returns {string} + */ +function resolveCardLast4(completed, order) { + return String(completed.cardLast4 || order.payment?.cardLastFour || '').slice(-4); +} + /** * Convert a Chase card expiry in MMYY format to EBS date format. * EBS expects YYYY-MM-DD-hh:mm (12-hour, no AM/PM) — we use the 28th at midnight. diff --git a/src/actions/ebs-sync/types.d.ts b/src/actions/ebs-sync/types.d.ts index f98ffcb..1d12386 100644 --- a/src/actions/ebs-sync/types.d.ts +++ b/src/actions/ebs-sync/types.d.ts @@ -22,7 +22,7 @@ export interface JournalPaymentData { /** EBS-facing method identifier */ method: 'chasehpp' | 'paypal' | 'affirm' | 'applepay'; /** Raw provider name from the payment_completed journal entry */ - provider: 'chase' | 'paypal' | 'affirm'; + provider: 'chase' | 'chase-wallet' | 'paypal' | 'affirm'; /** Charged total as a decimal string (e.g. "913.05") */ amount: string; /** Tax amount as a decimal string — from journal or order.estimates fallback */ @@ -41,7 +41,7 @@ export interface JournalPaymentData { approvalCode?: string; /** Card brand from Chase cardType field (e.g. "Visa", "Mastercard") */ cardBrand?: string; - /** Last 4 digits sliced from the masked mPAN (e.g. "1881" from "401288XXXXXX1881") */ + /** Last 4 card digits sent to EBS */ last4?: string; /** Card expiry converted from Chase MMYY format to EBS date (e.g. "2027-01-28T00:00") */ expiration?: string; @@ -57,6 +57,8 @@ export interface JournalPaymentData { fraudAutoDecisionResponse?: string; /** Decision from fraud_evaluated journal entry (e.g. 'approve', 'decline', 'not_reviewed') */ fraudDecision?: string | null; + /** Chase AVSMatch from queryTransaction; drives EBS BillTo IsValidated for chasehpp only */ + avsMatch?: string; /** MISSING: StoredCredentials flag — not logged; always 'N' */ storedCredentials?: string; /** MISSING: MIT message type — not logged; always 'CGEN' */ @@ -121,7 +123,7 @@ export interface StoredOrderAddress { company?: string; phone?: string; isDefault?: boolean; - /** When false, EBS IsValidated attribute is set to 'false' */ + /** Stored address metadata; ShipTo uses shipping.isValidated, while BillTo is resolved from payment AVS rules */ isValidated?: boolean; } @@ -132,6 +134,15 @@ export interface StoredOrderCustomer { phone?: string; } +export interface StoredOrderPayment { + method?: string; + transactionId?: string; + /** Final four card digits stored by Edge Commerce; fallback source for EBS CreditCard/@CardLast4Digits */ + cardLastFour?: string; + amount?: string; + currency?: string; +} + export interface StoredOrderItem { sku: string; quantity: number; @@ -179,6 +190,8 @@ export interface JournalOrderData { /** Billing address — falls back to shipping when absent */ billing?: StoredOrderAddress; shipping: StoredOrderAddress; + /** Stored order payment metadata; payment_completed journal remains the primary EBS payment source */ + payment?: StoredOrderPayment; items: StoredOrderItem[]; /** Locked-in estimate snapshot — present when estimateToken was provided at order creation */ estimates?: StoredOrderEstimates; diff --git a/src/actions/submit/index.js b/src/actions/submit/index.js index 9c0c252..1068c7a 100644 --- a/src/actions/submit/index.js +++ b/src/actions/submit/index.js @@ -10,7 +10,7 @@ const MAX_PAYLOAD_SIZE = 16_000; // 16KB * alphanumeric, underscores, hyphens, slashes allowed * but no trailing/leading slash, hyphen or underscore */ -const FORM_ID_PATTERN = /^[a-zA-Z0-9]+[\/a-zA-Z0-9_-]*[a-zA-Z0-9]+$/; +const FORM_ID_PATTERN = /^[a-zA-Z0-9]+[/a-zA-Z0-9_-]*[a-zA-Z0-9]+$/; /** * Origin of the production site, as seen in referer header diff --git a/test/ebs-sync/ebs-e2e.test.js b/test/ebs-sync/ebs-e2e.test.js index da7bdb4..cdbec8f 100644 --- a/test/ebs-sync/ebs-e2e.test.js +++ b/test/ebs-sync/ebs-e2e.test.js @@ -82,6 +82,13 @@ const CC_APPROVED_ORDER = { }, tax: { country: 'CA', state: 'ON', rate: 13, id: 'CA-ON-*-Rate1' }, }, + payment: { + method: 'card', + transactionId: '69F8F8131B061CE600000FFA0000796C41565367', + cardLastFour: '1881', + amount: '540.15', + currency: 'CAD', + }, }; /** Order matching journal-cc-decline.ndjson (Chase, Forter declined → cancelled). */ @@ -397,13 +404,13 @@ const MOCK_CTX = { describe('ebs-sync e2e', () => { let capturedXml; - const origFetch = globalThis.fetch; + const origFetch = global.fetch; beforeAll(() => { // proxyFetch wraps the destination request inside a JSON envelope: // POST body: { url, method, headers, body: } // Tests assert against the inner SOAP XML. - globalThis.fetch = async (_url, opts) => { + global.fetch = async (_url, opts) => { const wrapped = JSON.parse(opts.body); capturedXml = wrapped.body; return { ok: true, status: 200, text: async () => '' }; @@ -411,7 +418,7 @@ describe('ebs-sync e2e', () => { }); afterAll(() => { - globalThis.fetch = origFetch; + global.fetch = origFetch; }); beforeEach(() => { @@ -431,6 +438,20 @@ describe('ebs-sync e2e', () => { expect(xml).toBe(loadFixture(fixtureName)); } + const clone = (value) => JSON.parse(JSON.stringify(value)); + + const withCompletedPayment = (journal, fields) => journal.map((entry) => ( + entry.event === 'payment_completed' ? { ...entry, ...fields } : entry + )); + + const addressValidation = (xml, tag) => xml.match(new RegExp(`<${tag} IsValidated="([^"]+)"`))?.[1]; + + async function buildXml(order, journal) { + await syncOrderToEbs(MOCK_CTX, MOCK_PARAMS, order, journal); + expect(capturedXml).toBeTruthy(); + return capturedXml; + } + // ── Chase credit card — approved by Forter ────────────────────────────── describe('CC approved (journal-cc-approved)', () => { @@ -451,6 +472,7 @@ describe('ebs-sync e2e', () => { expect(snap.last4).toBe('1881'); expect(snap.expiration).toBe('2028-01-28-12:00'); expect(snap.fraudDecision).toBe('approve'); + expect(snap.avsMatch).toBe('2 '); // No SafeTech data in this journal (Forter-only environment) expect(snap.fraudScore).toBe(''); expect(snap.fraudStatusCode).toBe(''); @@ -461,6 +483,14 @@ describe('ebs-sync e2e', () => { expect(capturedXml).toBeTruthy(); assertXmlFixture('expected-cc-approved.xml', capturedXml); }); + + test('CreditCard CardLast4Digits falls back to order payment cardLastFour', async () => { + const journalWithoutCardLast4 = withCompletedPayment(journal, { cardLast4: undefined }); + + const xml = await buildXml(CC_APPROVED_ORDER, journalWithoutCardLast4); + + expect(xml).toContain('CardLast4Digits="1881"'); + }); }); // ── Chase credit card — declined by Forter ────────────────────────────── @@ -564,6 +594,70 @@ describe('ebs-sync e2e', () => { }); }); + // ── Address validation state ─────────────────────────────────────────── + + describe('address validation state', () => { + test('ShipTo IsValidated follows shipping.isValidated false', async () => { + const order = clone(PP_APPROVED_ORDER); + order.shipping.isValidated = false; + order.billing.isValidated = true; + + const xml = await buildXml(order, loadJournal('journal-pp-approved.ndjson')); + + expect(addressValidation(xml, 'ShipTo')).toBe('false'); + expect(addressValidation(xml, 'BillTo')).toBe('true'); + }); + + test.each([undefined, true])('ShipTo IsValidated defaults to true for %s', async (isValidated) => { + const order = clone(PP_APPROVED_ORDER); + if (isValidated !== undefined) order.shipping.isValidated = isValidated; + + const xml = await buildXml(order, loadJournal('journal-pp-approved.ndjson')); + + expect(addressValidation(xml, 'ShipTo')).toBe('true'); + }); + + test.each([ + ['paypal', PP_APPROVED_ORDER, 'journal-pp-approved.ndjson'], + ['affirm', AFFIRM_APPROVED_ORDER, 'journal-affirm-approved.ndjson'], + ['applepay', AP_APPROVED_ORDER, 'journal-ap-approved.ndjson'], + ])('BillTo IsValidated stays true for non-Chase method %s', async (_method, baseOrder, fixture) => { + const order = clone(baseOrder); + order.billing.isValidated = false; + + const xml = await buildXml(order, loadJournal(fixture)); + + expect(addressValidation(xml, 'BillTo')).toBe('true'); + }); + + test.each(['1', '2 ', 'G', 'M1', 'UK'])('BillTo IsValidated is false for Chase AVS %s', async (avsMatch) => { + const xml = await buildXml( + clone(CC_APPROVED_ORDER), + withCompletedPayment(loadJournal('journal-cc-approved.ndjson'), { avsMatch }), + ); + + expect(addressValidation(xml, 'BillTo')).toBe('false'); + }); + + test.each(['', 'Y', ' ', undefined])('BillTo IsValidated is true for Chase AVS %s', async (avsMatch) => { + const xml = await buildXml( + clone(CC_APPROVED_ORDER), + withCompletedPayment(loadJournal('journal-cc-approved.ndjson'), { avsMatch }), + ); + + expect(addressValidation(xml, 'BillTo')).toBe('true'); + }); + + test('BillTo IsValidated is true for zero-total Chase orders regardless of AVS', async () => { + const xml = await buildXml( + clone(CC_APPROVED_ORDER), + withCompletedPayment(loadJournal('journal-cc-approved.ndjson'), { amount: '0.00', avsMatch: '2' }), + ); + + expect(addressValidation(xml, 'BillTo')).toBe('true'); + }); + }); + // ── PayPal — bundle + extended warranty + free shipping ───────────────── describe('PP bundle + warranty (journal-pp-bundle-warranty)', () => { diff --git a/test/ebs-sync/journal.test.js b/test/ebs-sync/journal.test.js index b8b50b7..874053f 100644 --- a/test/ebs-sync/journal.test.js +++ b/test/ebs-sync/journal.test.js @@ -91,8 +91,7 @@ const CHASE_FORTER_ENTRIES = [ transactionId: '69D6844E106ACBB3000004420000559C4156542B', approvalCode: 'tst387', cardType: 'Visa', - cardNumber: '401288XXXXXX1881', - cardBin: '401288', + cardLast4: '1881', cardExpiry: '0127', avsMatch: '2 ', cvvMatch: 'M', @@ -370,12 +369,25 @@ describe('buildPaymentSnapshot', () => { expect(cardBrand).toBe('Visa'); }); - test('derives last4 from masked mPAN', () => { - // cardNumber "401288XXXXXX1881" → last 4 → "1881" + test('derives last4 from journal cardLast4', () => { const { last4 } = buildPaymentSnapshot(mockOrder(), CHASE_FORTER_ENTRIES); expect(last4).toBe('1881'); }); + test('falls back to order payment cardLastFour when journal cardLast4 is absent', () => { + const entries = CHASE_FORTER_ENTRIES.map((entry) => { + if (entry.event !== 'payment_completed') return entry; + const rest = { ...entry }; + delete rest.cardLast4; + return rest; + }); + const { last4 } = buildPaymentSnapshot( + mockOrder({ payment: { method: 'card', cardLastFour: '1881' } }), + entries, + ); + expect(last4).toBe('1881'); + }); + test('converts cardExpiry from MMYY to EBS date format', () => { // cardExpiry "0127" (January 2027) → "2027-01-28-12:00" (midnight in 12-hour form) const { expiration } = buildPaymentSnapshot(mockOrder(), CHASE_FORTER_ENTRIES); @@ -387,6 +399,22 @@ describe('buildPaymentSnapshot', () => { expect(fraudDecision).toBe('approve'); }); + test('captures AVS match from payment_completed', () => { + const { avsMatch } = buildPaymentSnapshot(mockOrder(), CHASE_FORTER_ENTRIES); + expect(avsMatch).toBe('2 '); + }); + + test('defaults missing AVS match to empty string', () => { + const entries = CHASE_FORTER_ENTRIES.map((entry) => { + if (entry.event !== 'payment_completed') return entry; + const rest = { ...entry }; + delete rest.avsMatch; + return rest; + }); + const { avsMatch } = buildPaymentSnapshot(mockOrder(), entries); + expect(avsMatch).toBe(''); + }); + test('defaults SafeTech fields to empty string when providerData absent', () => { // This order has no providerData.safetechResponse const snapshot = buildPaymentSnapshot(mockOrder(), CHASE_FORTER_ENTRIES); diff --git a/test/fixtures/expected-cc-approved.xml b/test/fixtures/expected-cc-approved.xml index 28b0d8b..b987d0e 100644 --- a/test/fixtures/expected-cc-approved.xml +++ b/test/fixtures/expected-cc-approved.xml @@ -29,7 +29,7 @@ xsi:type="Person" Key="approve@forter.com" Action="U"> - + 123 Main St Toronto diff --git a/test/fixtures/journal-cc-approved.ndjson b/test/fixtures/journal-cc-approved.ndjson index 02e170d..c94003d 100644 --- a/test/fixtures/journal-cc-approved.ndjson +++ b/test/fixtures/journal-cc-approved.ndjson @@ -4,5 +4,5 @@ {"id":"d3a1655c-0743-4f4a-a7ae-f24badd05025","timestamp":"2026-05-04T19:48:36.329Z","org":"aemsites","site":"vitamix","journal":"orders","event":"http_request","service":"chase-query","method":"POST","url":"https://testvitamix.chasepaymentechhostedpay-var.com/direct/services/request/query/","orderId":"2026-05-04T19-47-57.113Z-M4Z6Y6PC","attemptId":"066f0a52-7ed2-43cb-b8fd-a1c2be73b2c6","ok":true,"statusCode":200,"duration":283} {"id":"67d1ccee-bc70-4027-8ad4-f2bbf9da066a","timestamp":"2026-05-04T19:48:36.904Z","org":"aemsites","site":"vitamix","journal":"orders","event":"fraud_evaluated","orderId":"2026-05-04T19-47-57.113Z-M4Z6Y6PC","attemptId":"066f0a52-7ed2-43cb-b8fd-a1c2be73b2c6","provider":"forter","decision":"approve","reasonCodes":["Test"]} {"id":"f098ed10-5a9f-444b-a08b-68549844cbed","timestamp":"2026-05-04T19:48:36.904Z","org":"aemsites","site":"vitamix","journal":"orders","event":"http_request","service":"forter","method":"POST","url":"https://f2bc58b1eab5.api.forter-secure.com/v2/orders/2026-05-04T19-47-57.113Z-M4Z6Y6PC","orderId":"2026-05-04T19-47-57.113Z-M4Z6Y6PC","ok":true,"statusCode":200,"duration":351} -{"id":"e4d5d03f-cd24-485a-9366-fab042e14778","timestamp":"2026-05-04T19:48:37.350Z","org":"aemsites","site":"vitamix","journal":"orders","event":"payment_completed","orderId":"2026-05-04T19-47-57.113Z-M4Z6Y6PC","attemptId":"066f0a52-7ed2-43cb-b8fd-a1c2be73b2c6","provider":"chase","transactionId":"69F8F8131B061CE600000FFA0000796C41565367","approvalCode":"tst401","cardType":"Visa","cardNumber":"401288XXXXXX1881","cardBin":"401288","cardExpiry":"0128","avsMatch":"2 ","cvvMatch":"M","amount":"540.15","currency":"CAD","subtotal":449.95,"taxAmount":58.4935,"shippingCost":31.71} +{"id":"e4d5d03f-cd24-485a-9366-fab042e14778","timestamp":"2026-05-04T19:48:37.350Z","org":"aemsites","site":"vitamix","journal":"orders","event":"payment_completed","orderId":"2026-05-04T19-47-57.113Z-M4Z6Y6PC","attemptId":"066f0a52-7ed2-43cb-b8fd-a1c2be73b2c6","provider":"chase","transactionId":"69F8F8131B061CE600000FFA0000796C41565367","approvalCode":"tst401","cardType":"Visa","cardLast4":"1881","cardExpiry":"0128","avsMatch":"2 ","cvvMatch":"M","amount":"540.15","currency":"CAD","subtotal":449.95,"taxAmount":58.4935,"shippingCost":31.71} {"id":"42508d74-e653-4938-b287-d0c07d9f8728","timestamp":"2026-05-04T19:48:39.631Z","org":"aemsites","site":"vitamix","journal":"orders","event":"email_outcome","kind":"email","type":"order-confirmation","orderId":"2026-05-04T19-47-57.113Z-M4Z6Y6PC","jobId":"email_2026-05-04T19-48-39.018Z-94a33a88","outcome":"sent","attempts":1,"toEmail":"approve@forter.com","fromEmail":"noreply@mail.adobecommerce.live","sesMessageId":"0100019df4891b01-732ba0f2-8fcc-4669-9f11-7e01851ef4cf-000000","sentAt":"2026-05-04T19:48:39.423Z"}