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

Source: https://bettersupabase.com/docs/blocks/promotions

`promotions` adds discounts to [orders](/docs/blocks/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.

```bash
better-supabase sql add promotions
```

```ts title="lib/promotions.ts"
import {
  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 [#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 [#promotions]

```ts
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 [#codes]

```ts
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 [#pricing-a-cart]

```ts
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 [#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 [#events]

| Event                | Payload                                                                        |
| -------------------- | ------------------------------------------------------------------------------ |
| `promotion.redeemed` | `organizationId`, `id`, `promotionId`, `code`, `orderId`, `currency`, `amount` |

## Hooks [#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](/docs/extending/blocks)). The
TypeScript client takes `hooks` keyed by method name.

## Not included [#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.