Rust discount function (one automatic discount evaluating all offers in priority order, parity with the Odoo engine), React Router admin with Polaris, theme app extension and app proxy pages, webhooks incl. GDPR, managed-pricing limits, and a Docker Compose stack (Postgres + Caddy HTTPS) for a single VPS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
179 lines
7.9 KiB
JavaScript
179 lines
7.9 KiB
JavaScript
// 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;
|
|
}
|