// 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' } }] }; }