Loyalty Points rewards members automatically for buying: every paid shop order earns points at a configurable rate, and points redeem for money off at checkout. There's nothing to install client-side and no third-party rewards platform — balances, history, and redemption all live in the CMS.
The problem
Repeat purchases are cheaper than new customers, but a loyalty program usually means a Smile.io-style subscription bolted onto the store — another monthly fee, another script tag, another place customer data lives.
The fix
A self-contained feature module under app/Features/Loyalty/: one points account per member (loyalty_accounts) plus a transaction ledger where every earn, redemption, adjustment, and refund movement is a typed row. Earning hooks into the same atomic once-only step that marks a shop order paid; redemption plugs into the shared checkout-credits pipeline the Gift Cards feature uses.
How members experience it
- Earning is automatic. A paid order credits
floor(eligible spend × rate × tier multiplier)points to the buyer's member account. Guests who check out with a member account's email earn too — orders link to members by email at payment time. The confirmation page shows "Points earned +N." - Cross-module earning. With the "Earn on restaurant, booking, and ticket orders" toggle on (the default), paid Restaurant orders (food subtotal), Online Booking appointments (amount paid), and Event Ticketing orders (amount paid) earn points too — matched to the member account by the order's email. Free bookings and free tickets earn nothing.
- Tiers. Optional membership tiers (e.g. Silver 0 / Gold 1,000 / Platinum 5,000 lifetime points) each carry an earn multiplier (≥1×). A member's tier comes from lifetime points earned — it never drops when points are spent or expire. The member dashboard card shows the current tier badge, its multiplier, and a progress bar toward the next tier.
- Bonus points for actions. Flat, one-off bonuses (each off by default at 0 points): account signup (once per account, both the registration form and social signup), an approved first-party review (once per review, matched by reviewer email), referring a new member (points go to the referring affiliate's linked member account), and a yearly birthday bonus. Members add their birthday from an inline prompt on the dashboard loyalty card; the daily
loyalty:birthday-pointssweep awards on the day (at most once per 300 days). Action bonuses are flat — the tier multiplier applies to purchase earning only. - Balance and history live on the member dashboard: current points, their cash value, tier progress, and the last few movements.
- Redeeming happens on the cart page: "You have 450 points (worth $4.50)" with a field for how many to redeem (a configurable minimum applies). The value comes off the order like a discount, shown as its own line and named in the Stripe checkout.
- Expiry (optional). When an inactivity window is set, balances untouched for that many months are zeroed by the daily
loyalty:expire-pointssweep with an "Expired" ledger row. Any earn or redemption resets the clock. - Nudge emails (optional). When enabled, the daily
loyalty:send-nudgessweep emails members who are between 50% and 100% of the redemption minimum ("You're N points away from $X off", throttled to one per 60 days vialoyalty_accounts.nudged_at) and — when expiry is on — warns once per idle stretch when points are within 30 days of expiring (expiry_warned_at, re-armed by new ledger activity). Sends ride the shared Marketing transport and respect the Marketing unsubscribe list.
Configuration (Dashboard → Shop → Loyalty → Settings, admin)
| Setting | Default | Meaning |
|---|---|---|
| Points earned per $1 | 1 | Earn rate on the discounted, pre-tax merchandise total. 0 pauses earning. |
| Cash value per point (cents) | 1 | 1 = 100 points are worth $1.00. |
| Minimum points per redemption | 100 | Smallest redemption the cart accepts. |
Cross-module earning (loyalty.earn_cross_module) |
on | Restaurant / booking / ticket orders earn too. |
Membership tiers (loyalty.tiers) |
none | Repeater of name / min lifetime points / multiplier rows. Empty = flat rate. |
| Signup / review / referral / birthday points | 0 each | Flat action bonuses; 0 turns a bonus off. |
Expire points after N months (loyalty.expiry_months) |
0 | 0 = never expire. |
Nudge emails (loyalty.nudge_enabled) |
off | Reward-threshold nudges + expiry warnings. |
The settings page previews the math live ("Spend $100 → earn 100 points → worth $1.00").
Dashboard → Shop → Loyalty (manager and up) shows points outstanding (and their liability value), lifetime points issued, enrolled members, and the full transaction feed — plus a manual Adjust Points action (positive or negative, with a note) for bonuses and support fixes. A dashboard widget ("Loyalty points") surfaces the outstanding balance.
The earn/redeem math (mechanics)
- Earn base = order subtotal − discounts − gift-card product lines. Buying a gift card never earns points; a promo or credit-covered portion doesn't either (the discount already reduced the base). Tax and shipping never earn.
- Earning is once-only per order — stamped on the order row and guarded by the same paid-claim that prevents double emails and double stock decrements. Renewal invoices for product subscriptions don't earn (they never pass through checkout finalize).
- Redemption rides the shared checkout-credits coupon (see gift-cards.md for the one-discount-slot and minimum-charge rules — they apply identically). Points convert at the configured value, clamped to whole points and to what the cart can absorb; the point balance is captured only when the order actually pays.
- Full refunds return the redeemed points and revoke the earned ones (clamped at zero — a member who already spent them doesn't go negative). Partial refunds don't touch points.
- Redemption is stacked after any gift card and store credit on the same order.
- The rate and the floor are global.
loyalty.point_value_centsandloyalty.min_redeem_pointsmean the same thing at every checkout — there are deliberately no per-module overrides, so "what are my points worth" has one answer.
Redeeming outside the shop
Redemption is no longer Ecommerce-Order-typed. Loyalty::captureRedeemedPoints() / returnRedeemedPoints() take a member, a point count and a ledger label, and the shop's captureRedemption(Order) / returnRedeemed(Order) are thin wrappers over them. Cross-module redemptions are identified by their ledger note, exactly the way awardForExternalOrder() already records cross-module earning — no order_id on the row.
Non-shop checkouts drive this through the shared credit engine (App\Support\Checkout\Credits, which stacks points after gift card and store credit and enforces the redeem floor server-side). See gift-cards.md → "Redemption outside the shop" for reservations, the 50¢ floor, guest handling, and the once-only capture/release claims.
Points appear at the restaurant, event-ticketing and online-booking checkouts alongside gift cards, stacked after them, and only for a signed-in member — points are an account balance, unlike a gift card whose code is its own credential. Guests see a “Sign in to spend your points” nudge instead.
Limitations
- Members only — there's deliberately no anonymous/cookie-based points balance. Guests are nudged to create an account.
- Redemption covers the shop, restaurant, event-ticketing and online-booking checkouts. Subscriptions, memberships, donations and client invoices are deliberately out of scope.
- A pay-at-store restaurant order can't redeem — nothing captures the balance without a payment.
- Points can't be redeemed alongside a promo code — the two are mutually exclusive at every checkout.
- No refund-revoke for cross-module earning. A refunded restaurant / booking / ticket order keeps its earned points (only shop-order refunds revoke) — use a manual adjustment if it matters.
- Cross-module earn stamps
loyalty_points_earnedon the source row. The column is added by the Loyalty v2 migration for tables that exist at migrate time; if Restaurant / Online Booking / Event Ticketing is first enabled (and migrated) after Loyalty v2 migrated, that module's table lacks the column and its orders silently earn nothing until the column is added (re-run the Loyalty migration path or add it manually). - The birthday sweep matches month + day exactly — Feb 29 birthdays only award in leap years.
- Referral points require the affiliate to be linked to a member account (
Affiliate.member_id); unlinked affiliates earn cash commission only. - Action bonuses (signup/review/referral/birthday) are flat — the tier multiplier applies to purchase earning only.
Scheduled sweeps (LazyCron, daily)
| Command | What it does |
|---|---|
loyalty:birthday-points |
Awards the birthday bonus to members whose birthday (month/day) is today; at most once per 300 days per account. |
loyalty:expire-points |
Zeroes balances with no ledger activity for loyalty.expiry_months months (typed expire row). No-op at 0. |
loyalty:send-nudges |
Threshold nudges (50–100% of the redeem minimum, 60-day throttle) + expiry warnings (30 days out, once per idle stretch). Requires loyalty.nudge_enabled and a configured Marketing transport. |
Files
| Piece | Where |
|---|---|
| Module (account, ledger, math, dashboard) | app/Features/Loyalty/ |
| Earn/capture/revoke/action/tier entry points | App\Features\Loyalty\Support\Loyalty |
| Nudge emails | app/Features/Loyalty/Support/LoyaltyEmails.php |
| Sweeps | app/Features/Loyalty/Console/ |
| Checkout orchestration, shop | app/Features/Ecommerce/Support/CheckoutCredits.php |
| Checkout orchestration, every other module | app/Support/Checkout/Credits.php + App\Models\CheckoutCreditHold |
| Cross-module earn hooks | OrderPlacer::afterPlaced (Restaurant), AppointmentBooker::afterConfirmed (Online Booking), TicketOrderFinalizer::afterCompleted (Event Ticketing) |
| Action hooks | member registration + MemberResolver (signup), ExternalReview::approve (review), AffiliateConversions::recordSignup (referral) |
| Tests | tests/Feature/ShopLoyaltyTest.php, tests/Feature/LoyaltyV2Test.php, tests/Feature/CheckoutCreditsSharedTest.php, tests/Feature/CheckoutCreditsFlowTest.php |