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>
267 lines
11 KiB
TypeScript
267 lines
11 KiB
TypeScript
// Compiles the app's offers into what Shopify reads (brief §9, §11, §12, §18.2):
|
|
// * config -> discount metafield $app:configuration (spec/offer-config.schema.json), RUNNING offers only
|
|
// * vars -> discount metafield $app:function-vars (input query variables of the function)
|
|
// * storefront -> app-data metafield bundle_offers.storefront (theme app extension Liquid)
|
|
// Pure: no database or network, so it is unit-tested (app/lib/compile.test.ts).
|
|
import { num, offerStatus, offerSummary, moneyFormatter } from "./offer-text";
|
|
import { planOf } from "./plans";
|
|
|
|
export const CONFIG_MAX_BYTES = 10_000; // function metafields above ~10 kB are not delivered (verified Oct 2026)
|
|
export const STOREFRONT_MAX_BYTES = 60_000;
|
|
export const MAX_COLLECTIONS = 100; // function input variables: lists over 100 elements fail
|
|
export const NO_GUARD = "2999-12-31T23:59:59";
|
|
|
|
export interface CompileLine { productId: string; variantId: string | null; quantity: number; handle?: string | null; imageUrl?: string | null; title?: string | null }
|
|
export interface CompileScope { role: string; kind: string; refId: string; parentId?: string | null; handle?: string | null; imageUrl?: string | null; title?: string | null }
|
|
export interface CompileOffer {
|
|
id: string;
|
|
code: string;
|
|
handle: string;
|
|
name: string;
|
|
badge: string | null;
|
|
description?: string | null;
|
|
i18n?: unknown;
|
|
sequence: number;
|
|
active: boolean;
|
|
published: boolean;
|
|
countries: string[];
|
|
type: string;
|
|
discountType: string;
|
|
discountValue: number | string | { toString(): string };
|
|
mixQty: number;
|
|
buyQty: number;
|
|
getQty: number;
|
|
getPercent: number | string | { toString(): string };
|
|
startsAt: Date | null;
|
|
endsAt: Date | null;
|
|
maxPerOrder: number;
|
|
showHome: boolean;
|
|
showProduct: boolean;
|
|
showCartHints: boolean;
|
|
imageUrl: string | null;
|
|
createdAt: Date;
|
|
lines: CompileLine[];
|
|
scopes: CompileScope[];
|
|
}
|
|
|
|
export interface ShopSettings {
|
|
excludeTag?: string;
|
|
excludeSubscriptions?: boolean;
|
|
excludeGiftCards?: boolean;
|
|
bxgyAllocation?: "all_units" | "get_units_only";
|
|
combineProduct?: boolean;
|
|
combineOrder?: boolean;
|
|
combineShipping?: boolean;
|
|
afterAdd?: "cart" | "drawer";
|
|
accent?: string;
|
|
radius?: number;
|
|
}
|
|
|
|
export const DEFAULT_SETTINGS: Required<ShopSettings> = {
|
|
excludeTag: "no-bundle",
|
|
excludeSubscriptions: true,
|
|
excludeGiftCards: true,
|
|
bxgyAllocation: "all_units",
|
|
combineProduct: false,
|
|
combineOrder: true,
|
|
combineShipping: true,
|
|
afterAdd: "cart",
|
|
accent: "",
|
|
radius: 8,
|
|
};
|
|
|
|
export function settingsOf(raw: unknown): Required<ShopSettings> {
|
|
return { ...DEFAULT_SETTINGS, ...((raw && typeof raw === "object" ? raw : {}) as ShopSettings) };
|
|
}
|
|
|
|
export interface CompileShop {
|
|
planCode: string;
|
|
currency: string;
|
|
timezone: string;
|
|
settings: unknown;
|
|
monthOrders: number;
|
|
}
|
|
|
|
export interface CompileResult {
|
|
config: object;
|
|
vars: { collectionIds: string[]; excludeTags: string[]; guardAt: string };
|
|
storefront: object;
|
|
configBytes: number;
|
|
storefrontBytes: number;
|
|
/** included (running, allowed) offer ids in priority order */
|
|
included: string[];
|
|
/** offer id -> reason it was left out although it should run */
|
|
excluded: Record<string, string>;
|
|
nextSyncAt: Date | null;
|
|
/** blocking problem: publishing must be refused */
|
|
error: string | null;
|
|
}
|
|
|
|
/** Odoo order: sequence, date_start desc (nulls first, like Postgres), id desc (newer first). */
|
|
export function byPriority(a: CompileOffer, b: CompileOffer) {
|
|
if (a.sequence !== b.sequence) return a.sequence - b.sequence;
|
|
const sa = a.startsAt ? a.startsAt.getTime() : Infinity;
|
|
const sb = b.startsAt ? b.startsAt.getTime() : Infinity;
|
|
if (sa !== sb) return sb - sa;
|
|
return b.createdAt.getTime() - a.createdAt.getTime();
|
|
}
|
|
|
|
const iso = (d: Date) => d.toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
|
|
/** "YYYY-MM-DDTHH:mm:ss" in the shop timezone (DateTimeWithoutTimezone for shop.localTime). */
|
|
export function toShopLocal(d: Date, timezone: string) {
|
|
try {
|
|
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit",
|
|
hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23",
|
|
}).formatToParts(d);
|
|
const p = Object.fromEntries(parts.map((x) => [x.type, x.value]));
|
|
return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}`;
|
|
} catch {
|
|
return iso(d).slice(0, 19);
|
|
}
|
|
}
|
|
|
|
function scopeOf(scopes: CompileScope[], role: string) {
|
|
const s: { v?: string[]; p?: string[]; c?: string[] } = {};
|
|
const add = (k: "v" | "p" | "c", id: string) => { (s[k] ||= []).includes(id) || s[k]!.push(id); };
|
|
for (const x of scopes) {
|
|
if (x.role !== role) continue;
|
|
if (x.kind === "variant") add("v", x.refId);
|
|
else if (x.kind === "product") add("p", x.refId);
|
|
else if (x.kind === "collection") add("c", x.refId);
|
|
}
|
|
return s;
|
|
}
|
|
|
|
const round2 = (n: number) => Math.round(n * 100) / 100;
|
|
|
|
export function compile(shop: CompileShop, offers: CompileOffer[], now = new Date()): CompileResult {
|
|
const plan = planOf(shop.planCode);
|
|
const settings = settingsOf(shop.settings);
|
|
const money = moneyFormatter(shop.currency);
|
|
const excluded: Record<string, string> = {};
|
|
|
|
// Next boundary: any start or end still ahead, for offers that are switched on.
|
|
let nextSyncAt: Date | null = null;
|
|
for (const o of offers) {
|
|
if (!o.active || !o.published) continue;
|
|
for (const d of [o.startsAt, o.endsAt]) {
|
|
if (d && d > now && (!nextSyncAt || d < nextSyncAt)) nextSyncAt = d;
|
|
}
|
|
}
|
|
|
|
const running = offers.filter((o) => offerStatus(o, now) === "running").sort(byPriority);
|
|
const included: CompileOffer[] = [];
|
|
const freeOrdersExceeded = shop.monthOrders >= plan.monthlyOrders;
|
|
for (const o of running) {
|
|
if (freeOrdersExceeded) { excluded[o.id] = `The ${plan.name} plan includes ${plan.monthlyOrders} bundle orders a month.`; continue; }
|
|
if (!plan.types.includes(o.type)) { excluded[o.id] = `${o.type === "mix" ? "Mix & match" : "Buy X get Y"} bundles need a paid plan.`; continue; }
|
|
if (!plan.scheduling && (o.startsAt || o.endsAt)) { excluded[o.id] = "Start and end dates need a paid plan."; continue; }
|
|
if (!plan.markets && o.countries.length) { excluded[o.id] = "Market targeting needs the Growth plan."; continue; }
|
|
if (included.length >= plan.runningOffers) { excluded[o.id] = `The ${plan.name} plan allows ${plan.runningOffers} running offer(s).`; continue; }
|
|
included.push(o);
|
|
}
|
|
|
|
// ---- function configuration ----
|
|
const ends = included.map((o) => o.endsAt).filter((d): d is Date => !!d).sort((a, b) => a.getTime() - b.getTime());
|
|
const guard = ends[0] || null;
|
|
const collectionIds = new Set<string>();
|
|
const cfgOffers = included.map((o) => {
|
|
const x: Record<string, unknown> = { id: o.code, n: o.name.slice(0, 80), t: o.type };
|
|
if (o.endsAt) x.e = iso(o.endsAt);
|
|
if (o.maxPerOrder > 0) x.mu = o.maxPerOrder;
|
|
if (o.countries.length) x.cc = o.countries;
|
|
if (o.type === "fixed" || o.type === "mix") { x.dt = o.discountType; x.dv = num(o.discountValue); }
|
|
if (o.type === "fixed") x.l = o.lines.map((l) => (l.variantId ? { v: l.variantId, q: l.quantity } : { p: l.productId, q: l.quantity }));
|
|
if (o.type === "mix") { x.q = o.mixQty; x.sc = scopeOf(o.scopes, "mix"); }
|
|
if (o.type === "bxgy") {
|
|
x.bq = o.buyQty; x.gq = o.getQty; x.gp = num(o.getPercent);
|
|
x.bs = scopeOf(o.scopes, "buy"); x.gs = scopeOf(o.scopes, "get");
|
|
}
|
|
for (const s of o.scopes) if (s.kind === "collection") collectionIds.add(s.refId);
|
|
return x;
|
|
});
|
|
const config: Record<string, unknown> = {
|
|
v: 1,
|
|
cur: shop.currency,
|
|
ex: { subs: settings.excludeSubscriptions, gc: settings.excludeGiftCards },
|
|
offers: cfgOffers,
|
|
};
|
|
if (settings.excludeTag) (config.ex as Record<string, unknown>).tag = settings.excludeTag;
|
|
if (settings.bxgyAllocation === "get_units_only") config.ba = "get";
|
|
if (guard) config.g = iso(guard);
|
|
|
|
const vars = {
|
|
collectionIds: [...collectionIds].map((id) => `gid://shopify/Collection/${id}`),
|
|
excludeTags: settings.excludeTag ? [settings.excludeTag] : [],
|
|
guardAt: guard ? toShopLocal(guard, shop.timezone) : NO_GUARD,
|
|
};
|
|
|
|
// ---- storefront data (cards, product box, cart hints) ----
|
|
const sfOffers = included.map((o) => {
|
|
const thumbs = [...o.lines.map((l) => l.imageUrl), ...o.scopes.map((s) => s.imageUrl)].filter(Boolean).slice(0, 4);
|
|
const x: Record<string, unknown> = {
|
|
id: o.code,
|
|
h: o.handle,
|
|
n: o.name,
|
|
b: o.badge || "",
|
|
s: offerSummary(o, money),
|
|
t: o.type,
|
|
img: o.imageUrl || "",
|
|
th: thumbs,
|
|
e: o.endsAt ? iso(o.endsAt) : "",
|
|
f: [o.showHome && plan.homepage ? 1 : 0, o.showProduct ? 1 : 0, o.showCartHints && plan.cartHints ? 1 : 0],
|
|
};
|
|
if (o.countries.length) x.cc = o.countries;
|
|
if (o.type === "fixed" || o.type === "mix") { x.dt = o.discountType; x.dv = round2(num(o.discountValue)); }
|
|
if (o.type === "fixed") {
|
|
x.l = o.lines.map((l) => ({ p: l.productId, v: l.variantId || "", q: l.quantity, h: l.handle || "", tt: l.title || "" }));
|
|
x.ni = o.lines.reduce((s, l) => s + l.quantity, 0);
|
|
}
|
|
if (o.type === "mix") { x.q = o.mixQty; x.sc = scopeOf(o.scopes, "mix"); }
|
|
if (o.type === "bxgy") { x.bq = o.buyQty; x.gq = o.getQty; x.gp = num(o.getPercent); x.bs = scopeOf(o.scopes, "buy"); x.gs = scopeOf(o.scopes, "get"); }
|
|
const i18n = (o.i18n && typeof o.i18n === "object" ? o.i18n : {}) as Record<string, { name?: string; badge?: string }>;
|
|
if (plan.translations) {
|
|
const tr: Record<string, { n?: string; b?: string }> = {};
|
|
for (const [loc, t] of Object.entries(i18n)) if (t?.name || t?.badge) tr[loc] = { n: t.name || undefined, b: t.badge || undefined };
|
|
if (Object.keys(tr).length) x.i = tr;
|
|
}
|
|
return x;
|
|
});
|
|
const storefront = {
|
|
v: 1,
|
|
cur: shop.currency,
|
|
brand: plan.branding,
|
|
after: settings.afterAdd,
|
|
accent: settings.accent,
|
|
radius: settings.radius,
|
|
offers: sfOffers,
|
|
};
|
|
|
|
const configJson = JSON.stringify(config);
|
|
const storefrontJson = JSON.stringify(storefront);
|
|
const configBytes = new TextEncoder().encode(configJson).length;
|
|
const storefrontBytes = new TextEncoder().encode(storefrontJson).length;
|
|
let error: string | null = null;
|
|
if (configBytes > CONFIG_MAX_BYTES) {
|
|
error = `Too many running offers for one discount (${configBytes.toLocaleString()} of ${CONFIG_MAX_BYTES.toLocaleString()} bytes). End or merge some offers.`;
|
|
} else if (vars.collectionIds.length > MAX_COLLECTIONS) {
|
|
error = `Running offers use ${vars.collectionIds.length} collections; the limit is ${MAX_COLLECTIONS}. Use fewer collections.`;
|
|
} else if (storefrontBytes > STOREFRONT_MAX_BYTES) {
|
|
error = "Too much storefront data. Shorten offer names or reduce the number of running offers.";
|
|
}
|
|
|
|
return {
|
|
config,
|
|
vars,
|
|
storefront,
|
|
configBytes,
|
|
storefrontBytes,
|
|
included: included.map((o) => o.id),
|
|
excluded,
|
|
nextSyncAt,
|
|
error,
|
|
};
|
|
}
|