Loyalty Points & Rewards
Earning at the till, redeeming for a discount, and keeping the ledger honest
v1 customers could earn points and have them manually adjusted. v2.1 completes the program: customers now redeem points for a discount during checkout, earning respects each customer's tier multiplier and a configurable rounding mode, refunds prorate points instead of all-or-nothing, and points can expire. Every rule is set per tenant and recorded onto each transaction so history never re-interprets itself.
The Program Settings
One LoyaltyProgram per tenant holds the rules. ADMINs edit them via
PATCH /api/v1/customers/loyalty/program (permission
loyalty:manage); the change is audited as
LOYALTY_PROGRAM_UPDATED.
| Setting | Meaning | Default |
|---|---|---|
isActive | Whether the program runs at all | false |
earnRate | Points earned per unit of spend | 1 |
redeemRate | Currency value of one point when redeemed | 0.5 |
minRedeemPoints | Smallest redeemable balance | 100 |
redemptionEnabled | Allow redeeming points at checkout | false |
maxRedeemPoints | Hard cap on points per redemption | — (none) |
maxRedeemPercent | Cap redemption to a share of the bill | — (none) |
eligibleStatuses | Which sale statuses earn points | [COMPLETED] |
includeTaxInEarning | Count tax in the earning base | true |
earnOnRedeemedAmount | Earn on the part paid with points too | false |
roundingMode | FLOOR · ROUND · CEIL | FLOOR |
pointsExpiryDays | Days until earned points expire | — (never) |
isPaused | Temporarily stop earning & redeeming | false |
Out of the box, only COMPLETED sales earn, redemption is off, and
points never expire — exactly how loyalty behaved before v2.1. You opt into
each new capability by changing the relevant setting.
Earning Points
When an eligible sale completes, POSVelo computes the points earned from the
spend, the customer's tier multiplier (resolved from their prior committed
spend), the tax / redeemed-amount toggles, and the rounding mode. Each earning
is written as an EARNED transaction.
Each EARNED / REDEEMED row stores a
ruleSnapshot of the settings in force at the time, plus the
resulting balanceAfter. So if you later change the earn rate or
rounding, historical transactions are never recomputed — and a refund knows
exactly how many points the original sale granted.
Redeeming at Checkout
With redemptionEnabled, a cashier can apply a customer's points as a discount
during checkout by passing pointsToRedeem on the checkout request.
POSVelo recomputes the discount from redeemRate server-side and
validates the customer's balance and the min / max / percent caps. A
client-supplied discount amount is never trusted. The points are deducted
inside the same atomic checkout transaction (a conditional decrement, the same
guard used for stock), so a checkout that fails never loses points — there is
no separate hold to leak, because in-store checkout is synchronous and
single-transaction.
The sale records what happened in three fields: pointsEarned,
pointsRedeemed, and pointsDiscount.
Refunds Prorate Points
Returns and voids no longer treat points as all-or-nothing. A partial return adjusts points in proportion to the value of the items actually returned.
| Action | Effect on points |
|---|---|
| Partial return | Prorates by returned value: reverses earned points (REVERSED) and restores redeemed points (RESTORED) for the returned portion only |
| Repeated partial returns | Cumulative reversal / restoration is capped so you can never reverse more than was originally earned or restore more than was spent |
| Balance already spent | The balance floors at zero rather than going negative |
| Void | Reverses 100% — closing a v1 gap where a void left points untouched |
Manual Adjustments
Staff with customer:write can credit or debit a customer's points directly —
for a goodwill gesture or a correction.
An adjustment applies as an atomic increment / decrement
(not a racy absolute-balance write), records who performed it and the resulting
balance, and writes a LOYALTY_POINTS_ADJUSTED audit row. A
notes reason is required — every manual change to a
balance has a stated reason.
Expiry
If pointsExpiryDays is set, each EARNED transaction carries an expiresAt.
A daily background job (01:00 UTC) sweeps expired points and offsets them with an
EXPIRED transaction per tenant, audit-logged. Leave pointsExpiryDays unset
and points never expire.
Transaction Types
| Type | When it's written |
|---|---|
EARNED | An eligible sale grants points |
REDEEMED | Points applied as a discount at checkout |
RESTORED | Redeemed points returned by a refund/void |
REVERSED | Earned points clawed back by a refund/void |
ADJUSTED | A manual credit / debit by staff |
EXPIRED | The expiry sweep retires aged points |
In the App
| Task | Where |
|---|---|
| See a customer's point history | /customers/[id] → Loyalty tab |
| Adjust a customer's points | Customer detail → adjust points |
| Configure the program | Loyalty program settings (loyalty:manage) |
| Redeem at the till | POS checkout, when redemption is enabled |
Loyalty rides on the customer record and the sale lifecycle — see the customer, sales, and POS modules in the API Reference for the surrounding endpoints.