Skip to content

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:

  • PERCENTAGEvalue in basis points
  • FIXED_AMOUNTvalue in cents off subtotal
  • FREE_SHIPPINGvalue ignored

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 cents

Sale 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 saving

A 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.