metatroncubeswdev 365e45406a Bundle Offers Shopify app: discount function, admin, storefront, deploy stack
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>
2026-10-13 21:35:58 -04:00

163 lines
8.0 KiB
JavaScript

import { describe, test, expect } from 'vitest';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { cartLinesDiscountsGenerateRun as run } from '../function-reference.js';
const P = (n) => `gid://shopify/Product/${n}`;
const V = (n) => `gid://shopify/ProductVariant/${n}`;
const C = (n) => `gid://shopify/Collection/${n}`;
function line(id, { v, p, price, qty = 1, cols = [], tag = false, gc = false, sub = false }) {
return {
id: `gid://shopify/CartLine/${id}`,
quantity: qty,
cost: { amountPerQuantity: { amount: String(price) } },
sellingPlanAllocation: sub ? { sellingPlan: { id: 'gid://shopify/SellingPlan/1' } } : null,
merchandise: {
__typename: 'ProductVariant',
id: V(v),
product: { id: P(p), isGiftCard: gc, hasAnyTag: tag,
inCollections: cols.map((c) => ({ collectionId: C(c), isMember: true })) },
},
};
}
function input(lines, config, extra = {}) {
return {
cart: { lines },
localization: { country: { isoCode: extra.country || 'CA' } },
presentmentCurrencyRate: extra.rate ?? '1.0',
shop: { localTime: { pastGuard: extra.pastGuard ?? false } },
discount: { discountClasses: ['PRODUCT'], metafield: config === null ? null : { jsonValue: { v: 1, ex: { subs: true, gc: true }, ...config } } },
};
}
const amounts = (result) => Object.fromEntries(
(result.operations[0]?.productDiscountsAdd.candidates || []).map((c) => [c.targets[0].cartLine.id.split('/').pop(), c.value.fixedAmount.amount]));
const messages = (result) => (result.operations[0]?.productDiscountsAdd.candidates || []).map((c) => c.message);
const fixed20 = { id: 'o1', n: 'Puja Kit', t: 'fixed', dt: 'percent', dv: 20, l: [{ v: '11', q: 1 }, { p: '2', q: 2 }] };
const mix3for20 = { id: 'o2', n: 'Any 3 Sweets for $20', t: 'mix', dt: 'fixed_price', dv: 20, q: 3, sc: { c: ['50'] } };
const b2g1 = { id: 'o3', n: 'Buy 2 Get 1', t: 'bxgy', bq: 2, gq: 1, gp: 100, bs: { c: ['60'] }, gs: { c: ['60'] } };
describe('bundle discount function', () => {
test('no configuration -> no operations', () => {
expect(run(input([line(1, { v: 11, p: 1, price: 10 })], null))).toEqual({ operations: [] });
});
test('fixed bundle: percent off spread over lines, product-level line takes any variant', () => {
const r = run(input([line(1, { v: 11, p: 1, price: 10 }), line(2, { v: 21, p: 2, price: 5, qty: 2 })], { offers: [fixed20] }));
expect(amounts(r)).toEqual({ 1: '2.00', 2: '2.00' });
expect(messages(r)).toEqual(['Puja Kit', 'Puja Kit']);
expect(r.operations[0].productDiscountsAdd.selectionStrategy).toBe('ALL');
});
test('fixed bundle incomplete -> nothing', () => {
const r = run(input([line(1, { v: 11, p: 1, price: 10 }), line(2, { v: 21, p: 2, price: 5 })], { offers: [fixed20] }));
expect(r.operations).toEqual([]);
});
test('mix & match uses cheapest matching items, bundle price', () => {
const r = run(input([
line(1, { v: 1, p: 1, price: 12, cols: [50] }),
line(2, { v: 2, p: 2, price: 8, qty: 2, cols: [50] }),
line(3, { v: 3, p: 3, price: 9, cols: [50] }),
], { offers: [mix3for20] }));
// cheapest 3 = 8 + 8 + 9 = 25 -> price 20 -> 5 off, spread 1.60/1.60/1.80
expect(amounts(r)).toEqual({ 2: '3.20', 3: '1.80' });
});
test('bxgy: most expensive are "buy", cheapest remaining is "get" (parity allocation over all units)', () => {
const r = run(input([
line(1, { v: 1, p: 1, price: 30, cols: [60] }),
line(2, { v: 2, p: 2, price: 20, cols: [60] }),
line(3, { v: 3, p: 3, price: 10, cols: [60] }),
], { offers: [b2g1] }));
// discount 10 spread over 60 -> 5 / 3.33 / 1.67
expect(amounts(r)).toEqual({ 1: '5.00', 2: '3.33', 3: '1.67' });
});
test('bxgy get-units-only allocation setting', () => {
const r = run(input([
line(1, { v: 1, p: 1, price: 30, cols: [60] }),
line(2, { v: 2, p: 2, price: 20, cols: [60] }),
line(3, { v: 3, p: 3, price: 10, cols: [60] }),
], { ba: 'get', offers: [b2g1] }));
expect(amounts(r)).toEqual({ 3: '10.00' });
});
test('priority + exclusivity: an item counts towards one offer only', () => {
const both = { id: 'a', n: 'First', t: 'mix', dt: 'percent', dv: 10, q: 2, sc: { c: ['50'] } };
const second = { id: 'b', n: 'Second', t: 'mix', dt: 'percent', dv: 50, q: 2, sc: { c: ['50'] } };
const r = run(input([line(1, { v: 1, p: 1, price: 10, qty: 3, cols: [50] })], { offers: [both, second] }));
// first offer takes 2 units (2.00), one unit left -> second can't apply
expect(amounts(r)).toEqual({ 1: '2.00' });
expect(messages(r)).toEqual(['First']);
});
test('two offers on one line are merged into one candidate', () => {
const a = { id: 'a', n: 'A', t: 'mix', dt: 'amount', dv: 1, q: 1, mu: 1, sc: { p: ['1'] } };
const b = { id: 'b', n: 'B', t: 'mix', dt: 'amount', dv: 2, q: 1, mu: 1, sc: { p: ['1'] } };
const r = run(input([line(1, { v: 1, p: 1, price: 10, qty: 2 })], { offers: [a, b] }));
expect(amounts(r)).toEqual({ 1: '3.00' });
expect(messages(r)).toEqual(['A + B']);
});
test('repeats while possible, max uses caps it', () => {
const lines = [line(1, { v: 1, p: 1, price: 10, qty: 7, cols: [50] })];
const unlimited = run(input(lines, { offers: [{ ...mix3for20, dt: 'amount', dv: 5 }] }));
expect(amounts(unlimited)).toEqual({ 1: '10.00' }); // 2 uses
const capped = run(input(lines, { offers: [{ ...mix3for20, dt: 'amount', dv: 5, mu: 1 }] }));
expect(amounts(capped)).toEqual({ 1: '5.00' });
});
test('amount and fixed price are converted to the presentment currency', () => {
const r = run(input([line(1, { v: 1, p: 1, price: 15, qty: 3, cols: [50] })], { offers: [mix3for20] }, { rate: '1.5' }));
// regular 45, bundle price 20 * 1.5 = 30 -> 15 off
expect(amounts(r)).toEqual({ 1: '15.00' });
});
test('exclusions: tag, gift card, subscription, zero price', () => {
const offers = [{ id: 'm', n: 'M', t: 'mix', dt: 'percent', dv: 10, q: 1, sc: { p: ['1'] } }];
const r = run(input([
line(1, { v: 1, p: 1, price: 10, tag: true }),
line(2, { v: 2, p: 1, price: 10, gc: true }),
line(3, { v: 3, p: 1, price: 10, sub: true }),
line(4, { v: 4, p: 1, price: 0 }),
line(5, { v: 5, p: 1, price: 10 }),
], { offers }));
expect(amounts(r)).toEqual({ 5: '1.00' });
});
test('country targeting', () => {
const offers = [{ ...mix3for20, cc: ['US'] }];
const lines = [line(1, { v: 1, p: 1, price: 10, qty: 3, cols: [50] })];
expect(run(input(lines, { offers }, { country: 'CA' })).operations).toEqual([]);
expect(amounts(run(input(lines, { offers }, { country: 'US' })))).toEqual({ 1: '10.00' });
});
test('schedule guard: once the guard time passed, offers ending by then are dropped', () => {
const g = '2026-11-09T04:59:59Z';
const offers = [{ ...mix3for20, e: g }, { ...b2g1, id: 'o4' }];
const lines = [line(1, { v: 1, p: 1, price: 10, qty: 3, cols: [50] })];
expect(amounts(run(input(lines, { g, offers }, { pastGuard: false })))).toEqual({ 1: '10.00' });
expect(run(input(lines, { g, offers }, { pastGuard: true })).operations).toEqual([]);
});
test('large cart: 200 lines x 5 qty with 50 offers stays fast', () => {
const lines = Array.from({ length: 200 }, (_, i) => line(i + 1, { v: i + 1, p: i + 1, price: 1 + (i % 37), qty: 5, cols: [i % 10] }));
const offers = Array.from({ length: 50 }, (_, i) => ({ id: `x${i}`, n: `Offer ${i}`, t: i % 2 ? 'mix' : 'bxgy',
dt: 'percent', dv: 10, q: 3, sc: { c: [String(i % 10)] }, bq: 2, gq: 1, gp: 100, bs: { c: [String(i % 10)] }, gs: { c: [String(i % 10)] } }));
const t0 = performance.now();
const r = run(input(lines, { offers }));
expect(performance.now() - t0).toBeLessThan(500);
expect(r.operations[0].productDiscountsAdd.candidates.length).toBeGreaterThan(0);
});
});
test('engine parity vectors (spec/test-vectors.json)', () => {
const script = fileURLToPath(new URL('./run-vectors.mjs', import.meta.url));
const out = execFileSync(process.execPath, [script], { encoding: 'utf8' });
expect(out).toContain('all 19 vectors pass');
});