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>
138 lines
5.9 KiB
JavaScript
138 lines
5.9 KiB
JavaScript
// 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;
|
|
}
|