Skip to content
Merged

Dev #69

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
34 changes: 22 additions & 12 deletions src/actions/ebs-sync/ebs.js
Original file line number Diff line number Diff line change
Expand Up @@ -638,12 +638,7 @@ function buildLineItemsXml(order) {
));

if (hasWarranty) {
const w = warrantyBySku.get(child.sku);
const wSku = WARRANTY_VITAMIX_ID[w.sku] || w.sku || '';
lines.push(buildLineItemXml(
wSku, w.quantity ?? 1,
w.price?.final || '0.00', w.taxAmount || '0.00', 'Years', serial,
));
lines.push(buildWarrantyLineItemXml(warrantyBySku.get(child.sku), serial));
}
}
} else {
Expand All @@ -658,12 +653,7 @@ function buildLineItemsXml(order) {
));

if (hasWarranty) {
const w = warrantyBySku.get(item.sku);
const wSku = WARRANTY_VITAMIX_ID[w.sku] || w.sku || '';
lines.push(buildLineItemXml(
wSku, w.quantity ?? 1,
w.price?.final || '0.00', w.taxAmount || '0.00', 'Years', serial,
));
lines.push(buildWarrantyLineItemXml(warrantyBySku.get(item.sku), serial));
}
}
}
Expand All @@ -684,6 +674,26 @@ function buildLineItemXml(sku, qty, price, tax, unitOfMeasure, serialNumber) {
</ns2:LineItem>`;
}

/**
* Build a warranty line item (UnitOfMeasure="Years").
*
* The order carries the warranty's price.final as the total for the full
* coverage term, with the number of years in custom.coverageYears. EBS models
* warranties as a per-year unit price billed against a quantity of years, so we
* split the total back out:
* Quantity = coverageYears
* UnitSellingPrice = price.final / coverageYears
*/
function buildWarrantyLineItemXml(w, serial) {
const wSku = WARRANTY_VITAMIX_ID[w.sku] || w.sku || '';
const coverageYears = Number(w.custom?.coverageYears) || 1;
const total = Number(w.price?.final) || 0;
const unitPrice = String((total / coverageYears).toFixed(2));
return buildLineItemXml(
wSku, coverageYears, unitPrice, w.taxAmount || '0.00', 'Years', serial,
);
}

/**
* Build the ns2:Message element for a gift message.
* PHP parity: sanitises to [a-zA-Z0-9 ] and wraps in CDATA.
Expand Down
29 changes: 8 additions & 21 deletions src/actions/ebs-sync/sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -265,18 +265,16 @@ function sleep(ms) {
}

/**
* Reduce a sync failure to a compact, stable code (with a short detail suffix
* where useful) for storage in the order's custom.syncError. Keeps the value
* small and free of stack traces while still distinguishing failure modes:
* Reduce a sync failure to a compact, stable code for storage in the order's
* custom.syncError. This value is customer-readable via the API, so it must
* never carry raw error messages, payloads, or other internals — only a vague
* code naming the system involved. The matching detail lives in the logs.
*
* payment_snapshot_missing - journal had no usable payment_completed entry
* ebs_rejected: <detail> - EBS responded but did not accept the order
* ebs_rejected - EBS responded but did not accept the order
* ebs_http_<status> - EBS/proxy returned a non-2xx HTTP status
* ebs_unreachable - network/timeout/DNS — no response from EBS
* sync_failed: <detail> - anything else
*
* The result is truncated to MAX_SYNC_ERROR_LEN — the commerce API caps custom
* string values at 128 characters.
* sync_failed - anything else
*
* @param {(Error & { ebsStatus?: number, response?: { statusCode?: number, error?: { statusCode?: number } } }) | null} err
* @returns {string}
Expand All @@ -290,24 +288,13 @@ function describeSyncError(err) {
}

if (err.ebsStatus != null || /EBS rejected order/i.test(message)) {
const detail = message.replace(/^EBS rejected order:\s*/i, '');
return truncate(`ebs_rejected: ${detail}`);
return 'ebs_rejected';
}

const status = err?.response?.statusCode ?? err?.response?.error?.statusCode;
if (status) return `ebs_http_${status}`;

if (isRetriableError(err)) return 'ebs_unreachable';

return truncate(`sync_failed: ${message}`);
}

/** Commerce API caps custom string values at 128 characters. */
const MAX_SYNC_ERROR_LEN = 128;

/** Truncate to the commerce custom-field limit, marking elision with an ellipsis. */
function truncate(value) {
return value.length > MAX_SYNC_ERROR_LEN
? `${value.slice(0, MAX_SYNC_ERROR_LEN - 1)}…`
: value;
return 'sync_failed';
}
61 changes: 61 additions & 0 deletions test/ebs-sync/ebs-e2e.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,18 @@ describe('ebs-sync e2e', () => {
expect(serials[0][1]).toBe(serials[1][1]);
});

test('warranty quantity is coverageYears and price is the per-year unit price', async () => {
// Order carries price.final=117.00 as the total for the full 3-year term.
// EBS expects Quantity=coverageYears (3) and a per-year unit price (117/3=39.00).
await syncOrderToEbs(MOCK_CTX, MOCK_PARAMS, PP_BUNDLE_WARRANTY_ORDER, journal);
const line = capturedXml.match(/<ns2:LineItem\s+Sku="001314"[\s\S]*?<\/ns2:LineItem>/)[0];
expect(line).toMatch(/Quantity="3"/);
expect(line).toMatch(/UnitSellingPrice="39\.00"/);
expect(line).toMatch(/UnitOfMeasure="Years"/);
// The total (price.final) must not leak through as the unit price
expect(line).not.toMatch(/UnitSellingPrice="117\.00"/);
});

test('item-level tax amounts are emitted from taxAmount fields', async () => {
await syncOrderToEbs(MOCK_CTX, MOCK_PARAMS, PP_BUNDLE_WARRANTY_ORDER, journal);
// Each bundle child carries its own tax
Expand Down Expand Up @@ -645,6 +657,55 @@ describe('ebs-sync e2e', () => {
});
});

// ── Warranty per-year pricing math ──────────────────────────────────────

describe('warranty per-year pricing', () => {
const journal = loadJournal('journal-pp-bundle-warranty.ndjson');

/** Run the sync with a warranty whose total price.final and coverageYears are overridden. */
async function syncWarranty({ final, coverageYears }) {
const order = structuredClone(PP_BUNDLE_WARRANTY_ORDER);
order.items[1].price.final = final;
if (coverageYears === undefined) {
delete order.items[1].custom.coverageYears;
} else {
order.items[1].custom.coverageYears = coverageYears;
}
await syncOrderToEbs(MOCK_CTX, MOCK_PARAMS, order, journal);
return capturedXml.match(/<ns2:LineItem\s+Sku="001314"[\s\S]*?<\/ns2:LineItem>/)[0];
}

test('splits the term total evenly: 117.00 over 3 years => 39.00 × 3', async () => {
const line = await syncWarranty({ final: '117.00', coverageYears: 3 });
expect(line).toMatch(/Quantity="3"/);
expect(line).toMatch(/UnitSellingPrice="39\.00"/);
});

test('5-year term: 250.00 over 5 years => 50.00 × 5', async () => {
const line = await syncWarranty({ final: '250.00', coverageYears: 5 });
expect(line).toMatch(/Quantity="5"/);
expect(line).toMatch(/UnitSellingPrice="50\.00"/);
});

test('non-even division rounds the unit price to 2 decimals: 100.00 over 3 years', async () => {
const line = await syncWarranty({ final: '100.00', coverageYears: 3 });
expect(line).toMatch(/Quantity="3"/);
expect(line).toMatch(/UnitSellingPrice="33\.33"/);
});

test('1-year term: unit price equals the full total', async () => {
const line = await syncWarranty({ final: '49.00', coverageYears: 1 });
expect(line).toMatch(/Quantity="1"/);
expect(line).toMatch(/UnitSellingPrice="49\.00"/);
});

test('missing coverageYears falls back to a single year at full price', async () => {
const line = await syncWarranty({ final: '99.00', coverageYears: undefined });
expect(line).toMatch(/Quantity="1"/);
expect(line).toMatch(/UnitSellingPrice="99\.00"/);
});
});

// ── Commercial vs Household order type ─────────────────────────────────

describe('order type (Commercial / Household)', () => {
Expand Down
4 changes: 2 additions & 2 deletions test/fixtures/expected-pp-bundle-warranty.xml
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,8 @@
</ns2:LineItem>
<ns2:LineItem
Sku="001314"
Quantity="1"
UnitSellingPrice="117.00"
Quantity="3"
UnitSellingPrice="39.00"
UnitOfMeasure="Years">
<ns2:Tax Amount="15.20" Provisional="true" />
<ns2:SerialNumber>ciomuat5711040325-1</ns2:SerialNumber>
Expand Down
Loading