Pricing rules
Five mechanisms shape what a customer pays. Merchants configure them on the
merchant surface; storefronts evaluate carts against them on the public
surface. All money is integer cents, and percentage values are basis
points (1–10000, i.e. 100 bps = 1%). Examples reuse the
api() helper.
The shared discount type enum across coupons, auto-discounts, and bundles:
PERCENTAGE—valuein basis pointsFIXED_AMOUNT—valuein cents off subtotalFREE_SHIPPING—valueignored
Coupons
Code-based discounts. Merchant CRUD requires high-risk pricing:write;
Owner/Admin retain broad access, and Staff needs that group. Soft-disable has
a customer-visible blast radius and is included in the high-risk audit matrix.
await api("/merchant/coupons", { method: "POST", token,
body: JSON.stringify({
code: "WELCOME10", // required, 1–50, alphanum/-/_; upper-cased server-side
type: "PERCENTAGE", // required
value: 1000, // 1000 bps = 10%
minimumSubtotalInCents: 5000, // optional, default 0
maxUses: 500, // optional, null = unlimited
expiresAt: "2026-07-01T00:00:00Z", // optional; must be > startsAt
}) });
// DELETE soft-disables (status → DISABLED, row kept); idempotent.A storefront previews a code at the cart:
const result = await api("/public/cart/apply-coupon", {
method: "POST",
body: JSON.stringify({ code: "WELCOME10", subtotalInCents: 12000 }),
});
// → { valid: true, code, type, value, discountInCents } OR { valid: false, message: "…" } (always 200)Apply is a pure preview — it does not consume a use. Usage is counted at
order placement (with litecheckout, M3). CouponStatus: ACTIVE · DISABLED · EXPIRED.
Automatic discounts
Like coupons but no code — applied to any qualifying cart. Adds a scope
and a priority (tie-breaker), drops code/maxUses.
await api("/merchant/auto-discounts", { method: "POST", token,
body: JSON.stringify({
name: "Summer 15% off packs",
scope: "ITEMS_BY_COLLECTION", // ALL_ITEMS | ITEMS_BY_COLLECTION | ORDER_TOTAL
scopeCollectionId: "…", // required iff scope is ITEMS_BY_COLLECTION
type: "PERCENTAGE",
value: 1500,
priority: 10, // optional tie-breaker, default 0
}) });Storefront evaluates a cart (cart + PDP both call this):
await api("/public/auto-discounts/calculate", {
method: "POST",
body: JSON.stringify({
subtotalInCents: 12000,
items: [{ itemId: "…", variantId: "…", quantity: 1, unitPriceInCents: 12000, collectionIds: ["…"] }],
}),
});
// → every ACTIVE matching discount with its per-discount saving in centsSale windows
A scheduled price change on an item or variant — no code, no cart call. The public catalog read reflects an active window's price automatically, so there's no public sale-window endpoint.
await api("/merchant/sale-windows", { method: "POST", token,
body: JSON.stringify({
itemId: "…", // required (item-level unless variantId set)
variantId: "…", // optional; null = item-level
salePriceInCents: 14900, // required
startsAt: "2026-07-04T00:00:00Z",
endsAt: "2026-07-08T00:00:00Z", // required; must be > startsAt
}) });Windows use a half-open interval [startsAt, endsAt) — back-to-back windows
don't overlap — and overlapping windows on the same (itemId, variantId) are
rejected. Unlike coupons, sale windows hard-delete (DELETE), since
there's no usage history to preserve. itemId isn't editable — delete and
recreate to move a window.
Bundles
Composition-based: buy a set of items together, get a discount. Merchant CRUD (≥2 component items required).
await api("/merchant/bundles", { method: "POST", token,
body: JSON.stringify({
name: "Pack + rain cover",
type: "FIXED_AMOUNT",
value: 2000, // $20 off when the set is in the cart
items: [ // ≥2 required
{ itemId: "…", minQuantity: 1 }, // variantId omitted = any variant
{ itemId: "…", variantId: "…", minQuantity: 1 },
],
}) });Storefront checks a cart for satisfied bundles:
await api("/public/cart/check-bundles", {
method: "POST",
body: JSON.stringify({
cartLines: [{ itemId: "…", variantId: "…", quantity: 1, unitPriceInCents: 18900 }],
}),
});
// → every ACTIVE bundle whose slots are satisfied + per-bundle savingA product page can also read the bundles a given item belongs to — keyed by the item's slug, so the storefront can surface them before anything is in the cart (a "part of the Trail Kit set — add X to save Y" module, or a "one item away from savings" nudge):
const { bundles } = await api(`/public/items/${slug}/bundles`);
// → { bundles: [{ id, name, type, value, discountInCents, slots: [...] }] }
// A published item that's in no bundle returns { bundles: [] }.Each slot carries the component's itemId, slug, name, priceInCents,
minQuantity, and variantId (plus a variant: { name, options, priceInCents }
when the slot is variant-specific) — enough to render and deep-link the rest of
the set. discountInCents is the minimum qualifying saving (current catalog
prices × minQuantity); the real cart saving scales with quantity and is
finalized by check-bundles and the order path.
Where check-bundles is reactive (the items must already be in the cart),
this read is proactive and returns catalog detail — so it surfaces only
published items and ACTIVE bundles, never draft or archived ones.
A slot with no variantId matches any variant of the item; with one, it
requires an exact match. PATCH with items replaces the composition
wholesale. Soft-disables on DELETE.
Compare-at pricing
The simplest one: compareAtPriceInCents is an optional field on items and
variants (see Catalog). Set it above the live price to
render a "was / now" strike-through; it's data on the product, not a rule.
Related
- Catalog — items +
compareAtPriceInCents - Orders & fulfillment — where a discounted cart lands
- Shipping —
FREE_SHIPPINGdiscounts pair with rates