Promotions
Percentage, fixed and free-shipping promotions that apply automatically or by code, with usage limits, stacking rules and priorities, priced into orders carts and redeemed at checkout.
promotions adds discounts to orders carts. A
promotion takes a percentage or a fixed amount off, or ships for free. It
applies to the whole cart or to some products, categories or collections,
either automatically or when the shopper enters a code. priceCart applies
the promotions by priority, follows their stacking rules and lists the ones
the cart gets. Checkout redeems them inside the same transaction that places
the order, with a row lock, so a usage limit holds under concurrent orders.
better-supabase sql add promotionsimport {
createPromotions,
sqlTransport,
} from "better-supabase/blocks/promotions";
export const promotions = createPromotions({
transport: sqlTransport(ctx.postgres),
});The module needs tenant, access, catalog and orders, and records
promotion.redeemed through outbox when it is installed.
Permissions
| Key | What it allows |
|---|---|
promotions.read | Read the organization's promotions, codes and uses |
promotions.manage | Create, change and delete promotions and codes |
Shoppers need neither key. They add codes to their own carts and see the
discounts in priceCart; they can't list promotions or codes. A shopper
reads their own redemptions.
Promotions
import { money } from "better-supabase/blocks/commerce";
const { id } = await promotions
.savePromotion(organizationId, {
name: "Black Friday",
discount: { kind: "percentage", percent: "20" },
scope: "categories",
targets: [apparelCategoryId],
startsAt: Temporal.Instant.from("2026-11-27T00:00:00Z"),
endsAt: Temporal.Instant.from("2026-12-01T00:00:00Z"),
usageLimit: 1000,
perCustomerLimit: 1,
})
.orThrow();| Field | What it does |
|---|---|
discount | percentage with percent (up to two decimals), fixed with an amount, or free_shipping |
minSubtotal | The cart subtotal before discounts that the promotion needs, in the cart's currency |
scope, targets | order (default) or products, categories or collections with their ids; a category includes its children |
startsAt | When the promotion starts; endsAt is when it stops |
usageLimit | Redemptions across every customer |
perCustomerLimit | Redemptions per customer, counted by user id and by the cart's email, so a new anonymous session doesn't reset it |
stackable | Combines with other stackable promotions; a promotion that isn't stackable never combines with another (default off) |
priority | Higher applies first (default 0) |
automatic | Applies to every cart it fits, without a code (default off) |
active | setPromotionActive pauses and resumes it (default on) |
A fixed amount or a minimum fixes the promotion's currency, and a cart in
another currency doesn't get it. A collection scope matches the products
added to a manual collection. deletePromotion is refused with
PROMOTIONS_IN_USE once an order redeemed the promotion; deactivate it
instead.
Codes
await promotions.createCodes(id, { codes: ["BLACKFRIDAY"] }).orThrow();
const batch = await promotions
.createCodes(id, { count: 500, prefix: "BF-", usageLimit: 1 })
.orThrow();Codes are stored upper case, unique per organization, with 3 to 40 letters,
digits, - and _. A generated code is the prefix and 8 random characters
without the look-alike 0, O, 1 and I; one call creates at most
10,000. usageLimit limits each code, on top of the promotion's own limit.
Pricing a cart
await promotions.applyCode(cart.id, "blackfriday").orThrow();
const priced = await orders.priceCart(cart.id).orThrow();
priced.promotions; // [{ name: "Black Friday", kind: "percentage", code: "BLACKFRIDAY", discount }]
priced.lines[0]?.discount;applyCode checks that the code exists, is live and has uses left, and
refuses it with PROMOTIONS_CODE_INVALID or PROMOTIONS_LIMIT_REACHED. A
cart holds at most 5 codes; removeCode takes one off.
Pricing walks the cart's code promotions and the automatic ones from the highest priority down. A percentage rounds half to even on each line, from what earlier promotions left. A fixed amount is capped at the eligible lines and split across them in proportion. Free shipping sets the shipping total to zero.
A code that doesn't apply stays on the cart as a promotion_unavailable
problem with a reason: expired, limit_reached, currency,
not_applicable, min_subtotal or not_combinable. Like every cart
problem, it keeps the cart from checkout until the shopper removes the code
or changes the cart. An automatic promotion that doesn't fit is left out
without a problem.
Redemption
orders.checkout locks each applied promotion and code, checks the limits
again, writes a redemption per promotion and records promotion.redeemed.
When another order took the last use in between, checkout fails with
PROMOTIONS_LIMIT_REACHED and the shopper can price the cart again.
Cancelling an order, or letting its payment expire, releases its
redemptions and gives the uses back.
Events
| Event | Payload |
|---|---|
promotion.redeemed | organizationId, id, promotionId, code, orderId, currency, amount |
Hooks
The SQL module calls before_promotion_save and after_promotion_save
around save_promotion when you define them, with the promotion id (null
before the first save; see blocks). The
TypeScript client takes hooks keyed by method name.
Not included
Buy-X-get-Y and tiered discounts, gift cards and store credit, and rule collections in a collection scope. Model a gift card as a fixed code with a usage limit of one until a gift card block exists.
Last updated on
Orders
Carts for anonymous and signed-in shoppers, a checkout that prices from the catalog, quotes tax, reserves stock and takes the payment once, then fulfillments, returns, refunds and invoices.
Forms
Versioned forms with steps and show or skip logic, a JSON Schema 2020-12 per version that Postgres checks on every submission, drafts, public links with a rate limit and a bot-check hook, and handlers that turn a submission into a record, plus simple electronic signatures on a frozen version.