Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .eslintrc.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
68 changes: 58 additions & 10 deletions src/actions/ebs-sync/ebs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -362,7 +368,7 @@ function buildCreateOrderXml(order, paymentSnapshot) {
ReferrerCode="${escapeXml(referrerCode)}"
Key="${escapeXml(orderKey)}"
Created="${created}">
${buildCustomerXml(order)}
${buildCustomerXml(order, paymentSnapshot)}
${buildPaymentXml(paymentSnapshot, order)}
<ns2:Tax Amount="${taxAmount}" Provisional="true" />
${giftMessage}
Expand All @@ -376,7 +382,7 @@ function buildCreateOrderXml(order, paymentSnapshot) {
</soapenv:Envelope>`;
}

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 || ''));
Expand All @@ -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 `<ns2:Customer
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
Expand All @@ -402,6 +414,30 @@ function buildCustomerXml(order) {
</ns2:Customer>`;
}

/**
* 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.
Expand Down Expand Up @@ -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.
Expand Down
19 changes: 16 additions & 3 deletions src/actions/ebs-sync/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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;
Expand All @@ -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' */
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/actions/submit/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
100 changes: 97 additions & 3 deletions test/ebs-sync/ebs-e2e.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -397,21 +404,21 @@ 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 <proxy> body: { url, method, headers, body: <SOAP XML> }
// 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 () => '<Response Succeeded="true" />' };
};
});

afterAll(() => {
globalThis.fetch = origFetch;
global.fetch = origFetch;
});

beforeEach(() => {
Expand All @@ -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)', () => {
Expand All @@ -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('');
Expand All @@ -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 ──────────────────────────────
Expand Down Expand Up @@ -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)', () => {
Expand Down
Loading
Loading