// 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 = { 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 { 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; 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 = {}; // 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(); const cfgOffers = included.map((o) => { const x: Record = { 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 = { v: 1, cur: shop.currency, ex: { subs: settings.excludeSubscriptions, gc: settings.excludeGiftCards }, offers: cfgOffers, }; if (settings.excludeTag) (config.ex as Record).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 = { 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; if (plan.translations) { const tr: Record = {}; 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, }; }