> For the complete documentation index, see [llms.txt](https://tyrix.gitbook.io/docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://tyrix.gitbook.io/docs/script-resources/tyrix-droply/promotions.md).

# Promotions

Percentage discount codes, created and managed by a business boss from the Droply owner panel.

## Creating a code

| Field                 | Rules                                                                                                                                    |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| **Code**              | 2–`promoCodeMaxLen` characters (default 32). Normalised to uppercase; only `A-Z`, `0-9`, `_`, and `-` are accepted. Unique per business. |
| **Discount**          | Whole percentage, `1`–`maxDiscountPercent` (default 50).                                                                                 |
| **Max uses**          | Optional total redemption cap. Leave empty for unlimited.                                                                                |
| **Once per customer** | On by default — each customer may redeem the code once.                                                                                  |
| **Expires at**        | Optional date. A bare date means end of that day, not midnight at its start.                                                             |
| **Active**            | Toggle to disable a code without deleting it.                                                                                            |

A business may hold up to `maxPromosPerBusiness` codes (default 25).

Codes are scoped to the issuing business — the same code string can exist at two different storefronts without collision.

## How a code is consumed

The discount applies to the **subtotal only**. Delivery fee and tip are never discounted:

```
total = max(0, subtotal − discount) + delivery fee + tip
```

The lifecycle of a single use is deliberate, because a naive implementation drains codes:

| Moment                              | What happens                                                                              |
| ----------------------------------- | ----------------------------------------------------------------------------------------- |
| Customer types the code at checkout | `preview` — **read-only**. Validates and returns the discount. Never touches the counter. |
| Order is placed                     | `claim` — atomically consumes one use and writes a redemption row.                        |
| Order is delivered                  | Nothing. The slot stays spent.                                                            |
| Order is cancelled or expires       | `release` — the use is returned and the redemption row deleted.                           |

The checkout field fires on blur and on every "Apply" click, so preview **must** stay read-only — otherwise an abandoned checkout would burn the code. Claim runs exactly once, from inside `placeOrder`, after every other validation has passed.

Because cancelling releases the redemption row, a customer whose order was cancelled is not locked out of a once-per-customer code.

## Losing a race for the last use

If two customers claim the final use simultaneously, the loser's order is **not** dropped. The promo is silently unapplied, they are notified, the total is recomputed at full price, and their funds are re-checked before the order proceeds.

## Validation

Every check runs server-side at claim time:

* Code exists at that business and is `active`.
* Not expired.
* `uses < max_uses`, when a cap is set.
* No existing redemption row for this customer, when `once_per_customer` is on.

Dates are normalised to an epoch before comparison, so string and numeric timestamp formats from different oxmysql driver configurations all behave identically.

## Counter repair

An older build consumed a use from both the preview endpoint and `placeOrder`, so every order burned two or more uses and abandoned checkouts leaked them permanently — leaving some capped codes dead long before they should have been.

On every resource start, Droply recomputes `uses` from the actual order history:

```
uses = count of orders on this promo whose status is not cancelled/expired
```

The repair only ever **claws back an inflated counter**. A correct promo is left alone, and a code deliberately revived by re-creating it (counter reset to 0 while old delivered orders still reference it) is not silently re-inflated. If anything was fixed, the console prints:

```
[Droply] repaired over-counted `uses` on N promo code(s)
```

## Logging

Creating, updating, and deleting codes is written to the `promo` Discord channel. See [Discord Logging](/docs/script-resources/tyrix-droply/discord-logging.md).
