// JavaScript port of the Odoo bundle engine // (reference/odoo-c2c_bundle_offers/models/bundle_engine.py). // // Pure functions, no Shopify imports. Used by the admin app (live example-cart preview) and as the reference // for the deployed Rust function (extensions/bundle-discount/src/engine.rs). Both must keep passing // spec/test-vectors.json (npm test). // // Behaviour that MUST be preserved (it decides which items are discounted): // * One unit per item; pool order = cart line order, then unit order. // * Offers are tried in priority order; every unit an offer uses leaves the pool. // * An offer repeats while it still matches, up to maxUses (0 = unlimited). // * _take sorts matching units by price with a STABLE sort (ties keep pool order). // * fixed: per bundle line, cheapest matching units first; all-or-nothing (pool restored on failure). // * mix: cheapest matching units first. // * bxgy: "buy" = MOST expensive matching units, "get" = cheapest remaining matching units. // * A use that gives 0 discount puts its units back at the END of the pool and stops that offer. // * The discount of a use is spread over ALL its units in proportion to price (bxgy included). export function bundleDiscount(regular, discountType, value) { regular = Math.max(regular, 0); value = Math.max(value || 0, 0); if (discountType === 'percent') return (regular * Math.min(value, 100)) / 100; if (discountType === 'amount') return Math.min(value, regular); if (discountType === 'fixed_price') return Math.max(regular - value, 0); return 0; } function matches(unit, scope) { // scope = { variantIds: Set, productIds: Set, collectionIds: Set } if (scope.variantIds && scope.variantIds.has(unit.variantId)) return true; if (scope.productIds && scope.productIds.has(unit.productId)) return true; if (scope.collectionIds) for (const c of unit.collectionIds) if (scope.collectionIds.has(c)) return true; return false; } // Performance (Shopify instruction limit): the reference port re-filtered and re-sorted the whole pool on // every use. Here each offer builds its sorted candidate lists ONCE and consumes them in order. Because units // only ever leave the pool while an offer repeats (they are appended back only when the offer stops), the // remaining candidates keep exactly the order a fresh filter + stable sort would give, so results are identical // (proved by the vectors and tests/differential.test.js against tests/reference/bundle-engine.reference.js). function candidates(pool, predicate, cheapestFirst) { const found = pool.filter(predicate); // keeps pool order found.sort((a, b) => (cheapestFirst ? a.price - b.price : b.price - a.price)); // stable (ES2019+) return { list: found, pos: 0 }; } /** Take qty not-yet-used units from a candidate list (all or nothing). */ function takeFrom(cand, used, qty) { const taken = []; let i = cand.pos; while (i < cand.list.length && taken.length < qty) { const unit = cand.list[i]; if (!used.has(unit)) taken.push(unit); i += 1; } if (taken.length < qty) return null; for (const unit of taken) used.add(unit); while (cand.pos < cand.list.length && used.has(cand.list[cand.pos])) cand.pos += 1; return taken; } function sum(units) { let s = 0; for (const u of units) s += u.price; return s; } /** Runs one offer as many times as allowed against the pool; returns its applications and the new pool. */ function runOffer(pool, offer) { const apps = []; const used = new Set(); // units taken by this offer so far (left the pool) let returned = null; // units of a zero-discount use: go back at the END of the pool const maxUses = offer.maxUses || 0; let lists; if (offer.type === 'fixed') { const lines = (offer.lines || []).filter((l) => l.quantity > 0); if (!lines.length) return { apps, pool }; lists = lines.map((line) => ({ qty: line.quantity, cand: candidates(pool, line.variantId ? (u) => u.variantId === line.variantId : (u) => u.productId === line.productId, true), // product-level line: any variant })); } else if (offer.type === 'mix') { if (!(offer.qty > 0)) return { apps, pool }; lists = [{ qty: offer.qty, cand: candidates(pool, (u) => matches(u, offer.scope), true) }]; } else if (offer.type === 'bxgy') { if (!(offer.buyQty > 0) || !(offer.getQty > 0)) return { apps, pool }; lists = [ { qty: offer.buyQty, cand: candidates(pool, (u) => matches(u, offer.buyScope), false) }, { qty: offer.getQty, cand: candidates(pool, (u) => matches(u, offer.getScope), true) }, ]; } else { return { apps, pool }; } while (!maxUses || apps.length < maxUses) { const parts = []; let failed = false; for (const part of lists) { const savedPos = part.cand.pos; const taken = takeFrom(part.cand, used, part.qty); if (!taken) { failed = true; part.cand.pos = savedPos; break; } parts.push(taken); } if (failed) { // all or nothing: restore and stop for (const taken of parts) for (const unit of taken) used.delete(unit); break; } const units = [].concat(...parts); let discount; let getUnits = null; if (offer.type === 'bxgy') { getUnits = parts[1]; const pct = Math.min(Math.max(offer.getPercent || 0, 0), 100); discount = (sum(getUnits) * pct) / 100; } else { discount = bundleDiscount(sum(units), offer.discountType, offer.value); } if (discount <= 0) { returned = units; for (const unit of units) used.delete(unit); break; } apps.push({ offerId: offer.id, discount, units, getUnits }); } if (!used.size && !returned) return { apps, pool }; const back = new Set(returned || []); const next = pool.filter((u) => !used.has(u) && !back.has(u)); if (returned) next.push(...returned); return { apps, pool: next }; } /** units: [{lineId, variantId, productId, collectionIds: string[], price}] ; offers in priority order */ export function applyOffers(units, offers) { let pool = units.slice(); const applications = []; for (const offer of offers) { const r = runOffer(pool, offer); pool = r.pool; for (const app of r.apps) applications.push(app); } return applications; } /** Discount per (offerId, lineId), spread by price exactly like the Odoo split_by(). * options.bxgyGetUnitsOnly: Shopify-only shop setting; spreads a bxgy discount over the "get" units only * (default false = Odoo parity, covered by the vectors). */ export function allocateByLine(applications, options = {}) { const totals = new Map(); for (const app of applications) { const units = options.bxgyGetUnitsOnly && app.getUnits ? app.getUnits : app.units; const base = sum(units); for (const unit of units) { const share = base ? app.discount * (unit.price / base) : app.discount / units.length; const key = app.offerId + '\u0000' + unit.lineId; totals.set(key, (totals.get(key) || 0) + share); } } return totals; } /** Round amounts to cents so they add up to round(total): largest remainder, ties by input order. */ export function largestRemainderCents(amounts) { const exact = amounts.map((a) => a * 100); const target = Math.round(amounts.reduce((s, a) => s + a, 0) * 100 + 1e-9); const floors = exact.map((c) => Math.floor(c + 1e-9)); let remainder = target - floors.reduce((s, f) => s + f, 0); const order = exact.map((c, i) => i).sort((i, j) => (exact[j] - floors[j]) - (exact[i] - floors[i]) || i - j); for (const i of order) { if (remainder <= 0) break; floors[i] += 1; remainder -= 1; } return floors.map((f) => f / 100); } /** cart lines -> units, one per item, in cart order. */ export function toUnits(lines) { const units = []; for (const l of lines) { for (let i = 0; i < l.quantity; i += 1) { units.push({ lineId: l.id, variantId: l.variantId, productId: l.productId, collectionIds: l.collectionIds || [], price: l.unitPrice }); } } return units; }