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>
93 lines
3.8 KiB
JavaScript
93 lines
3.8 KiB
JavaScript
// Reference implementation of the Discount Function in JS (the deployed function is the Rust port in
|
|
// extensions/bundle-discount). Kept for tests and to document the expected behaviour.
|
|
import { applyOffers, allocateByLine, largestRemainderCents, toUnits } from './engine.js';
|
|
import { numericId, offersFromConfig } from './config.js';
|
|
|
|
/**
|
|
* @typedef {import("../generated/api").CartInput} RunInput
|
|
* @typedef {import("../generated/api").CartLinesDiscountsGenerateRunResult} CartLinesDiscountsGenerateRunResult
|
|
*/
|
|
|
|
const NO_CHANGES = { operations: [] };
|
|
|
|
/** Cart lines that may count towards a bundle (F11). */
|
|
function eligibleLines(input, ex) {
|
|
const lines = [];
|
|
for (const line of input.cart.lines) {
|
|
const m = line.merchandise;
|
|
if (!m || m.__typename !== 'ProductVariant') continue;
|
|
if (!(line.quantity >= 1)) continue;
|
|
const unitPrice = Number(line.cost?.amountPerQuantity?.amount);
|
|
if (!(unitPrice > 0)) continue;
|
|
if (m.product.hasAnyTag) continue;
|
|
if (ex.gc !== false && m.product.isGiftCard) continue;
|
|
if (ex.subs !== false && line.sellingPlanAllocation) continue;
|
|
lines.push({
|
|
id: line.id,
|
|
variantId: numericId(m.id),
|
|
productId: numericId(m.product.id),
|
|
collectionIds: (m.product.inCollections || []).filter((c) => c.isMember).map((c) => numericId(c.collectionId)),
|
|
unitPrice,
|
|
quantity: line.quantity,
|
|
});
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
/**
|
|
* Evaluates ALL running offers in priority order in one run, so every cart item counts towards at most one offer.
|
|
* @param {RunInput} input
|
|
* @returns {CartLinesDiscountsGenerateRunResult}
|
|
*/
|
|
export function cartLinesDiscountsGenerateRun(input) {
|
|
const cfg = input.discount?.metafield?.jsonValue;
|
|
if (!cfg || cfg.v !== 1 || !Array.isArray(cfg.offers) || !cfg.offers.length) return NO_CHANGES;
|
|
if (!input.discount.discountClasses.includes('PRODUCT')) return NO_CHANGES;
|
|
|
|
const offers = offersFromConfig(cfg, {
|
|
rate: Number(input.presentmentCurrencyRate) || 1,
|
|
country: input.localization?.country?.isoCode,
|
|
pastGuard: input.shop?.localTime?.pastGuard === true,
|
|
});
|
|
if (!offers.length) return NO_CHANGES;
|
|
|
|
const lines = eligibleLines(input, cfg.ex || {});
|
|
if (!lines.length) return NO_CHANGES;
|
|
|
|
const applications = applyOffers(toUnits(lines), offers);
|
|
if (!applications.length) return NO_CHANGES;
|
|
const totals = allocateByLine(applications, { bxgyGetUnitsOnly: cfg.ba === 'get' });
|
|
|
|
// Round per offer (largest remainder), then one candidate per cart line. A line can carry units of two
|
|
// offers (e.g. qty 4 split over two bundles); those amounts are merged so the line gets one fixed amount.
|
|
const names = new Map((cfg.offers || []).map((o) => [o.id, o.n]));
|
|
const perLine = new Map();
|
|
for (const offer of offers) {
|
|
const items = [];
|
|
for (const line of lines) {
|
|
const exact = totals.get(offer.id + '\u0000' + line.id);
|
|
if (exact !== undefined) items.push([line.id, exact]);
|
|
}
|
|
if (!items.length) continue;
|
|
const rounded = largestRemainderCents(items.map(([, a]) => a));
|
|
items.forEach(([lineId], i) => {
|
|
if (!(rounded[i] > 0)) return;
|
|
const entry = perLine.get(lineId) || { amount: 0, offers: [] };
|
|
entry.amount += rounded[i];
|
|
entry.offers.push(offer.id);
|
|
perLine.set(lineId, entry);
|
|
});
|
|
}
|
|
if (!perLine.size) return NO_CHANGES;
|
|
|
|
const candidates = [];
|
|
for (const [lineId, entry] of perLine) {
|
|
candidates.push({
|
|
message: entry.offers.map((id) => names.get(id) || 'Bundle offer').join(' + ').slice(0, 255),
|
|
targets: [{ cartLine: { id: lineId } }],
|
|
value: { fixedAmount: { amount: (Math.round(entry.amount * 100) / 100).toFixed(2), appliesToEachItem: false } },
|
|
});
|
|
}
|
|
return { operations: [{ productDiscountsAdd: { candidates, selectionStrategy: 'ALL' } }] };
|
|
}
|