// Reference JavaScript port of the Odoo bundle engine // (reference/odoo-c2c_bundle_offers/models/bundle_engine.py). // // Pure functions, no Shopify imports: drop this into the Discount Function (JavaScript) or port it // line by line to Rust. It must keep passing spec/test-vectors.json (run: node run-vectors.mjs). // // 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; } function take(pool, predicate, qty, cheapestFirst = true) { const found = pool.filter(predicate); // keeps pool order found.sort((a, b) => (cheapestFirst ? a.price - b.price : b.price - a.price)); // stable (ES2019+) if (found.length < qty) return null; const taken = found.slice(0, qty); for (const unit of taken) pool.splice(pool.indexOf(unit), 1); return taken; } function applyOnce(pool, offer) { let used; let discount; if (offer.type === 'fixed') { const lines = (offer.lines || []).filter((l) => l.quantity > 0); if (!lines.length) return null; const backup = pool.slice(); used = []; for (const line of lines) { const pred = line.variantId ? (u) => u.variantId === line.variantId : (u) => u.productId === line.productId; // product-level line: any variant const taken = take(pool, pred, line.quantity); if (!taken) { pool.splice(0, pool.length, ...backup); return null; } used.push(...taken); } discount = bundleDiscount(sum(used), offer.discountType, offer.value); } else if (offer.type === 'mix') { if (!(offer.qty > 0)) return null; used = take(pool, (u) => matches(u, offer.scope), offer.qty, true); if (!used) return null; discount = bundleDiscount(sum(used), offer.discountType, offer.value); } else if (offer.type === 'bxgy') { if (!(offer.buyQty > 0) || !(offer.getQty > 0)) return null; const backup = pool.slice(); const bought = take(pool, (u) => matches(u, offer.buyScope), offer.buyQty, false); if (!bought) return null; const got = take(pool, (u) => matches(u, offer.getScope), offer.getQty, true); if (!got) { pool.splice(0, pool.length, ...backup); return null; } used = bought.concat(got); const pct = Math.min(Math.max(offer.getPercent || 0, 0), 100); discount = (sum(got) * pct) / 100; } else { return null; } if (discount <= 0) { pool.push(...used); return null; } return { offerId: offer.id, discount, units: used }; } function sum(units) { return units.reduce((s, u) => s + u.price, 0); } /** units: [{lineId, variantId, productId, collectionIds: string[], price}] ; offers in priority order */ export function applyOffers(units, offers) { const pool = units.slice(); const applications = []; for (const offer of offers) { let uses = 0; const maxUses = offer.maxUses || 0; while (!maxUses || uses < maxUses) { const app = applyOnce(pool, offer); if (!app) break; applications.push(app); uses += 1; } } return applications; } /** Discount per (offerId, lineId), spread by price exactly like the Odoo split_by(). */ export function allocateByLine(applications) { const totals = new Map(); for (const app of applications) { const base = sum(app.units); for (const unit of app.units) { const share = base ? app.discount * (unit.price / base) : app.discount / app.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; }