diff --git a/src/actions/ebs-sync/ebs.js b/src/actions/ebs-sync/ebs.js index bb6ba6a..212dbfe 100644 --- a/src/actions/ebs-sync/ebs.js +++ b/src/actions/ebs-sync/ebs.js @@ -656,6 +656,83 @@ function stripBundleSuffix(sku) { return String(sku || '').replace(/-VB$/, ''); } +/** + * Convert a non-negative decimal amount to integer cents without floating-point + * arithmetic. Persisted Commerce API allocations are fixed two-decimal strings; + * accepting numbers as well keeps the reader tolerant of historical data. + * + * @param {string|number} value monetary amount + * @returns {number} non-negative integer cents, or zero for an invalid value + */ +function moneyToCents(value) { + const match = /^(\d+)(?:\.(\d{1,2}))?$/.exec(String(value ?? '')); + if (!match) return 0; + return (Number(match[1]) * 100) + Number((match[2] || '').padEnd(2, '0')); +} + +/** + * Return the purchasable quantity used to turn a unit catalogue price into a + * line total. Orders use positive integer quantities; malformed data falls + * back to one so EBS never receives a division-by-zero price. + * + * @param {object} item stored order item or bundle component + * @returns {number} positive quantity + */ +function lineQuantity(item) { + const quantity = Number(item.quantity); + return Number.isFinite(quantity) && quantity > 0 ? quantity : 1; +} + +/** + * Sum persisted allocations for one stored line. Aggregated + * `estimates.discounts` deliberately never appears here: it no longer has the + * eligibility information necessary to price EBS lines correctly. + * + * @param {object} item stored order item or bundle component + * @returns {number} non-negative integer cents + */ +function allocatedDiscountCents(item) { + if (!Array.isArray(item.discounts)) return 0; + return item.discounts.reduce((total, discount) => total + moneyToCents(discount?.amount), 0); +} + +/** + * Compute the discounted line total from the catalogue unit price and the + * server-authoritative allocations persisted on that same line. + * + * @param {object} item stored order item or bundle component + * @returns {number} non-negative cents + */ +function effectiveLineTotalCents(item) { + const unitCents = moneyToCents(item.price?.final || item.price?.regular || '0.00'); + return Math.max(0, (unitCents * lineQuantity(item)) - allocatedDiscountCents(item)); +} + +/** + * Format an EBS unit selling price. EBS accepts only two decimal places, so + * round the authoritative discounted line total once when dividing it across + * the emitted quantity, matching Magento's `number_format(..., 2)` behavior. + * + * @param {number} lineTotalCents discounted line total, in cents + * @param {number} quantity EBS line quantity + * @returns {string} fixed two-decimal unit price + */ +function unitSellingPrice(lineTotalCents, quantity) { + return (Math.round(lineTotalCents / quantity) / 100).toFixed(2); +} + +/** + * Derive the EBS price of a simple product or resolved bundle component from + * its own persisted allocation. Bundle parents are never passed here: their + * allocation is an alternate, non-additive view of component allocations. + * + * @param {object} item stored order item or bundle component + * @returns {string} fixed two-decimal EBS unit price + */ +function effectiveItemUnitPrice(item) { + return unitSellingPrice(effectiveLineTotalCents(item), lineQuantity(item)); +} + function buildLineItemsXml(order) { const orderKey = order.friendlyId || order.id; @@ -688,7 +765,7 @@ function buildLineItemsXml(order) { lines.push(buildLineItemXml( stripBundleSuffix(child.sku), child.quantity ?? 1, - child.price?.final || '0.00', child.taxAmount || '0.00', 'Each', serial, + effectiveItemUnitPrice(child), child.taxAmount || '0.00', 'Each', serial, reasonCode, )); @@ -703,7 +780,7 @@ function buildLineItemsXml(order) { lines.push(buildLineItemXml( item.sku || '', item.quantity ?? 1, - item.price?.final || item.price?.regular || '0.00', item.taxAmount || '0.00', + effectiveItemUnitPrice(item), item.taxAmount || '0.00', 'Each', serial, )); @@ -748,20 +825,22 @@ function buildLineItemXml(sku, qty, price, tax, unitOfMeasure, serialNumber, pro /** * 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 + * The order carries the warranty's price.final as the per-warranty total for + * its full coverage term. EBS models warranties as a per-year unit price, so + * divide the discounted full-term total across every purchased coverage year: + * Quantity = coverageYears × item.quantity + * UnitSellingPrice = effective warranty total / Quantity + * + * A warranty uses only its own persisted allocations. Its linked product's + * allocations must never change its price. */ 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)); + const quantity = coverageYears * lineQuantity(w); + const unitPrice = unitSellingPrice(effectiveLineTotalCents(w), quantity); return buildLineItemXml( - wSku, coverageYears, unitPrice, w.taxAmount || '0.00', 'Years', serial, + wSku, quantity, unitPrice, w.taxAmount || '0.00', 'Years', serial, ); } diff --git a/test/ebs-sync/ebs-e2e.test.js b/test/ebs-sync/ebs-e2e.test.js index 7ea235b..e8a85b5 100644 --- a/test/ebs-sync/ebs-e2e.test.js +++ b/test/ebs-sync/ebs-e2e.test.js @@ -758,6 +758,116 @@ describe('ebs-sync e2e', () => { }); }); + // ── Persisted Commerce API line discount allocations ─────────────────── + + describe('persisted line discount allocations', () => { + const ccJournal = loadJournal('journal-cc-approved.ndjson'); + const bundleJournal = loadJournal('journal-pp-bundle-warranty.ndjson'); + + test('consumes the persisted Commerce API bundle and warranty contract fixture', async () => { + const order = structuredClone(PP_BUNDLE_WARRANTY_ORDER); + order.items = JSON.parse(loadFixture('commerce-api-persisted-line-discounts.json')).items; + + const xml = await buildXml(order, bundleJournal); + expect(xml).toMatch(/Sku="061724-04"[\s\S]*?UnitSellingPrice="192\.96"/); + expect(xml).toMatch(/Sku="069834"[\s\S]*?UnitSellingPrice="5\.43"/); + expect(xml).toMatch(/Sku="060488"[\s\S]*?UnitSellingPrice="43\.65"/); + expect(xml).toMatch(/Sku="001372-1093"[\s\S]*?UnitSellingPrice="637\.91"/); + expect(xml).toMatch(/Sku="001314"[\s\S]*?UnitSellingPrice="33\.00"/); + }); + + test('uses a simple line’s persisted coupon and override allocations with cents rounding', async () => { + const order = structuredClone(CC_APPROVED_ORDER); + order.items[0] = { + ...order.items[0], + price: { final: '799.95', currency: 'CAD' }, + discounts: [ + { id: 'coupon:SAVE100', amount: '100.00' }, + { id: 'promo:override', amount: '99.99' }, + ], + }; + order.items.push({ + sku: 'unaffected-sku', + quantity: 1, + price: { final: '50.00', currency: 'CAD' }, + }); + + const xml = await buildXml(order, ccJournal); + expect(xml).toMatch(/Sku="068051-04"[\s\S]*?UnitSellingPrice="599\.96"/); + expect(xml).toMatch(/Sku="unaffected-sku"[\s\S]*?UnitSellingPrice="50\.00"/); + }); + + test('uses the discounted total across a multi-quantity simple line', async () => { + const order = structuredClone(CC_APPROVED_ORDER); + order.items[0] = { + ...order.items[0], + quantity: 2, + price: { final: '100.00', currency: 'CAD' }, + discounts: [ + { id: 'coupon:SAVE8', amount: '8.00' }, + { id: 'promo:targeted', amount: '12.00' }, + ], + }; + + const xml = await buildXml(order, ccJournal); + expect(xml).toMatch(/Quantity="2"[\s\S]*?UnitSellingPrice="90\.00"/); + }); + + test('rounds a persisted half-cent split to two decimal places', async () => { + const order = structuredClone(CC_APPROVED_ORDER); + order.items[0] = { + ...order.items[0], + quantity: 2, + price: { final: '100.00', currency: 'CAD' }, + discounts: [{ id: 'coupon:ONE-CENT', amount: '0.01' }], + }; + + const xml = await buildXml(order, ccJournal); + // (100.00 × 2 − 0.01) ÷ 2 = 99.995, which Magento rounds to 100.00. + expect(xml).toMatch(/Quantity="2"[\s\S]*?UnitSellingPrice="100\.00"/); + }); + + test('does not infer merchandise prices from aggregate or free-shipping estimates', async () => { + const order = structuredClone(CC_APPROVED_ORDER); + order.estimates.discounts = [ + { id: 'coupon:UNSCOPED', amount: 200, source: 'coupon' }, + { id: 'free-shipping', amount: 0, freeShipping: true, source: 'pricing_rule' }, + ]; + + const xml = await buildXml(order, ccJournal); + expect(xml).toMatch(/Sku="068051-04"[\s\S]*?UnitSellingPrice="449\.95"/); + }); + + test('uses only component allocations when emitting bundle children', async () => { + const order = structuredClone(PP_BUNDLE_WARRANTY_ORDER); + order.items[0].discounts = [{ id: 'coupon:BUNDLE', amount: '20.00' }]; + order.items[0].bundleItems[0].discounts = [{ id: 'coupon:BUNDLE', amount: '8.00' }]; + order.items[0].bundleItems[1].discounts = [{ id: 'coupon:BUNDLE', amount: '12.00' }]; + + const xml = await buildXml(order, bundleJournal); + expect(xml).toMatch(/Sku="061724-04"[\s\S]*?UnitSellingPrice="192\.96"/); + expect(xml).toMatch(/Sku="069834"[\s\S]*?UnitSellingPrice="5\.43"/); + expect(xml).toMatch(/Sku="060488"[\s\S]*?UnitSellingPrice="43\.65"/); + expect(xml).toMatch(/Sku="001372-1093"[\s\S]*?UnitSellingPrice="637\.91"/); + expect(xml).toMatch(/Sku="001314"[\s\S]*?UnitSellingPrice="39\.00"/); + }); + + test('allocates a discounted warranty across every purchased coverage year', async () => { + const order = structuredClone(PP_BUNDLE_WARRANTY_ORDER); + order.items[1].quantity = 2; + order.items[1].discounts = [ + { id: 'coupon:WARRANTY', amount: '12.00' }, + { id: 'promo:WARRANTY', amount: '6.00' }, + ]; + + const xml = await buildXml(order, bundleJournal); + const line = xml.match(//)[0]; + expect(line).toMatch(/Quantity="6"/); + expect(line).toMatch(/UnitSellingPrice="36\.00"/); + expect(line).toMatch(/UnitOfMeasure="Years"/); + }); + }); + // ── Warranty VitamixProductId lookup ──────────────────────────────────── describe('warranty vitamixProductId mapping', () => { diff --git a/test/fixtures/commerce-api-persisted-line-discounts.json b/test/fixtures/commerce-api-persisted-line-discounts.json new file mode 100644 index 0000000..aef24dc --- /dev/null +++ b/test/fixtures/commerce-api-persisted-line-discounts.json @@ -0,0 +1,53 @@ +{ + "items": [ + { + "sku": "001372-1093-VB", + "path": "/ca/en_ca/products/5200-legacy-bundle", + "quantity": 1, + "name": "5200 Standard - Getting Started", + "price": { "final": "899.95", "currency": "CAD" }, + "discounts": [{ "id": "coupon:EXAMPLE", "amount": "20.00" }], + "bundleItems": [ + { + "sku": "061724-04-VB", + "name": "Personal Cup Adapter", + "quantity": 1, + "price": { "final": "200.96", "currency": "CAD" }, + "taxAmount": "26.13", + "discounts": [{ "id": "coupon:EXAMPLE", "amount": "8.00" }] + }, + { + "sku": "069834-VB", + "name": "Silicone Blender Spatula", + "quantity": 1, + "price": { "final": "17.43", "currency": "CAD" }, + "taxAmount": "2.26", + "discounts": [{ "id": "coupon:EXAMPLE", "amount": "12.00" }] + }, + { + "sku": "060488-VB", + "name": "Classic Series Tamper Holder", + "quantity": 1, + "price": { "final": "43.65", "currency": "CAD" }, + "taxAmount": "5.67" + }, + { + "sku": "001372-1093-VB", + "name": "5200 Standard - Getting Started", + "quantity": 1, + "price": { "final": "637.91", "currency": "CAD" }, + "taxAmount": "82.94" + } + ] + }, + { + "sku": "001314", + "quantity": 1, + "name": "Extended Warranty, add 3 yrs", + "price": { "final": "117.00", "currency": "CAD" }, + "custom": { "linkedTo": "001372-1093-VB", "coverageYears": 3 }, + "taxAmount": "15.20", + "discounts": [{ "id": "coupon:EXAMPLE", "amount": "18.00" }] + } + ] +}