Earning rules
Points enter the ledger in four ways: completed orders, bonus points, manual adjustments and (roadmap) rate overrides.
1. Order completion (the primary path)
The OrderListener subscribes to order.statusChanged. It earns when the order moves into a completed or paid status (and was not already in one), and revokes when it moves into cancelled or refunded.
Earning formula:
effectiveAmount = subtotal − discount_total (in minor units)
points = (effectiveAmount × points_per_unit) / 100
Worked example with points_per_unit = 10 (a promotional rate; the default is 1):
- Order subtotal $100.00 (10 000 cents), $20 discount (2 000 cents), tax + shipping ignored
- Effective amount = 8 000 cents
- Points earned =
(8000 × 10) / 100 = 800
Important behaviours:
- Tax + shipping do NOT earn. The formula reads
subtotalanddiscount_totalonly. Freight and tax columns are deliberately excluded — earning on shipping subsidies (or tax) would inflate balances against the operator's margin. - Idempotent. Before crediting, the listener checks whether any
earn-type row exists with the sameorder_id. If so, it logspoints already earned for order, skippingand returns without writing. Re-firing the sameorder.statusChangedevent is safe. - Best-effort. If
EarnPointserrors (DB hiccup, module-disable race), the failure is logged aterrorlevel and the order workflow continues. Points are not retried automatically — a manualAdjustis the recovery path. - Module-gated. When
reward-pointsis OFF for the tenant, the listener short-circuits at the top ofonOrderStatusChangedand never even reads order info. - Status semantics. The completed-set is
{completed, paid}. The cancelled-set is{cancelled, refunded}. Both sets areOldStatus → NewStatustransitions — moving frompaidtocompleteddoes NOT re-earn.
2. Bonus points (signup, reviews, referrals, birthdays)
Set these under Admin → Rewards → Settings → Bonus points. Every bonus starts at 0, which means off. Nothing changes for your customers until you enter an amount and save.
| Bonus | When the customer gets it | Rules that stop abuse |
|---|---|---|
| Signup | Once, when they create an account (form, Google or OTP sign-in). | Guest checkout does not count. Deleting the account and signing up again with the same e-mail or phone earns nothing. |
| Review | When their review of a product is published, and they have a completed order for that product. If the review is published before delivery, the points arrive when the order completes. | Once per product per customer. Optional monthly limit. If you reject or delete the review, the points are taken back; if you approve it again, they are paid back once (a second reject → approve pays nothing). |
| Referral | Every customer gets a personal invite link under My Account → Reward Points → Refer a friend, with Copy and WhatsApp buttons. When a friend signs up through the link and their first order is completed, the customer who invited gets the referral bonus and the friend gets the welcome bonus. | The link is remembered for 30 days. Only accounts created in the last 7 days with no completed order can use an invite. Nobody can use their own link (same account, e-mail or phone). If that first order is cancelled or refunded, both bonuses are taken back. Optional monthly limit for the inviting customer. Referrals turn on when either referral amount is above 0. |
| Birthday | On their birthday, once a year. A daily job checks; if it misses a day it catches up within 3 days. | Uses the Birth Date field on the customer's profile. Turn that field on under Settings → Profile fields so customers can fill it in. Changing the date later in the year does not pay again. |
What customers see: their Reward Points page lists the bonuses you switched on under Ways to earn, and each bonus appears in their points history with a clear line, such as "Welcome bonus for creating an account". You see the same lines under Admin → Rewards → User Points → History.
Bonus points behave like any other points. They count towards the balance, follow the same expiry, and can be spent at checkout. When points are taken back, the balance never goes below zero. Points the customer has already spent are not reclaimed.
3. Manual bonus (BonusPoints)
A code-only path for one-off promotional bursts. Not exposed in the admin UI today — the equivalent admin-visible action is Adjust Balance (see Admin balance adjustments). Reach for this when wiring an external listener (e.g., a marketing webhook).
4. Per-product / per-category / per-vendor rate overrides (NOT YET LIVE)
The service exposes a complete PointRate API (SetPointRate, ListPointRates, DeletePointRate) for object-typed rate overrides — for example, "5× points on this category". The handlers exist (AdminListPointRates, AdminSetPointRate, etc.) but no router mounts them today and no migration creates reward_point_rates. Treat the surface as roadmap, not a documented feature. Don't tell customers about category multipliers until the route + migration land.
Earning at a glance
order.statusChanged ──┐
(paid / completed) │
▼
┌─────────────────────┐
│ OrderListener │
│ - module gate │
│ - idempotency │
│ - effective amount │
│ - CalculateEarnings │
└──────┬──────────────┘
│
▼
rewards.EarnPoints(userID, &orderID, points, "Order #N completed")
│
▼
INSERT reward_points (type='earn', points=+N, order_id=…)
Points calculation: integer arithmetic
The formula uses integer division so the rounding rule is truncate (floor, not round-half-up). A $0.99 order with points_per_unit = 10 earns (99 × 10) / 100 = 9 points, not 10. Mention this in your customer-facing FAQ to avoid "missing 1 point" tickets.
