From 09124bd5ff255af3237474b3b5f656083f7d8c11 Mon Sep 17 00:00:00 2001 From: Max Edell Date: Tue, 2 Jun 2026 09:04:15 -0700 Subject: [PATCH 1/2] fix: correct warranty price --- src/actions/ebs-sync/ebs.js | 34 +++++++---- test/ebs-sync/ebs-e2e.test.js | 61 +++++++++++++++++++ test/fixtures/expected-pp-bundle-warranty.xml | 4 +- 3 files changed, 85 insertions(+), 14 deletions(-) diff --git a/src/actions/ebs-sync/ebs.js b/src/actions/ebs-sync/ebs.js index 9cd428d..d5a5414 100644 --- a/src/actions/ebs-sync/ebs.js +++ b/src/actions/ebs-sync/ebs.js @@ -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 { @@ -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)); } } } @@ -684,6 +674,26 @@ function buildLineItemXml(sku, qty, price, tax, unitOfMeasure, serialNumber) { `; } +/** + * 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. diff --git a/test/ebs-sync/ebs-e2e.test.js b/test/ebs-sync/ebs-e2e.test.js index ec597b7..da7bdb4 100644 --- a/test/ebs-sync/ebs-e2e.test.js +++ b/test/ebs-sync/ebs-e2e.test.js @@ -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(//)[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 @@ -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(//)[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)', () => { diff --git a/test/fixtures/expected-pp-bundle-warranty.xml b/test/fixtures/expected-pp-bundle-warranty.xml index 4dba2c5..cebd8a0 100644 --- a/test/fixtures/expected-pp-bundle-warranty.xml +++ b/test/fixtures/expected-pp-bundle-warranty.xml @@ -151,8 +151,8 @@ ciomuat5711040325-1 From e0d77497a7da29d53a2876ea649a992b7302a2b5 Mon Sep 17 00:00:00 2001 From: Max Edell Date: Tue, 2 Jun 2026 09:07:26 -0700 Subject: [PATCH 2/2] fix: generic ebsError codes --- src/actions/ebs-sync/sync.js | 29 ++++++++--------------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/src/actions/ebs-sync/sync.js b/src/actions/ebs-sync/sync.js index feeb8cc..0033e05 100644 --- a/src/actions/ebs-sync/sync.js +++ b/src/actions/ebs-sync/sync.js @@ -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: - EBS responded but did not accept the order + * ebs_rejected - EBS responded but did not accept the order * ebs_http_ - EBS/proxy returned a non-2xx HTTP status * ebs_unreachable - network/timeout/DNS — no response from EBS - * sync_failed: - 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} @@ -290,8 +288,7 @@ 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; @@ -299,15 +296,5 @@ function describeSyncError(err) { 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'; }