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
101 changes: 90 additions & 11 deletions src/actions/ebs-sync/ebs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
));

Expand All @@ -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,
));

Expand Down Expand Up @@ -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,
);
}

Expand Down
110 changes: 110 additions & 0 deletions test/ebs-sync/ebs-e2e.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(/<ns2:LineItem\s+Sku="001314"[\s\S]*?<\/ns2:LineItem>/)[0];
expect(line).toMatch(/Quantity="6"/);
expect(line).toMatch(/UnitSellingPrice="36\.00"/);
expect(line).toMatch(/UnitOfMeasure="Years"/);
});
});

// ── Warranty VitamixProductId lookup ────────────────────────────────────

describe('warranty vitamixProductId mapping', () => {
Expand Down
53 changes: 53 additions & 0 deletions test/fixtures/commerce-api-persisted-line-discounts.json
Original file line number Diff line number Diff line change
@@ -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" }]
}
]
}
Loading