Loyalty Program
Points, tiers, and the reward catalog customers redeem points against.
Loyalty Program
Customers accumulate points, points determine a tier, and points are spent on rewards from a
catalog staff manage. Implemented in lib/loyalty.ts and lib/loyalty-rewards.ts, exposed
through /api/loyalty and the customer page /account/rewards.
Earning
| Action | Points |
|---|---|
| Purchase | 10 per dollar spent |
| Referral | 500 |
| Writing a review | 50 |
| Birthday | 250 |
Purchase points are awarded by creditPurchaseLoyaltyPoints when an order is paid, and the
referral bonus is paid alongside a referred customer's first earning order (see Referrals).
The review and birthday amounts are constants in lib/loyalty.ts that nothing calls yet, so the
storefront advertises purchase and referral points only.
Tiers
Tier is derived from lifetime points, so it never goes down when points are redeemed. A tier gates which rewards a customer may redeem.
| Tier | Lifetime points |
|---|---|
BRONZE | 0 |
SILVER | 500 |
GOLD | 1,000 |
PLATINUM | 2,500 |
TIER_BENEFITS in lib/loyalty.ts lists 5/10/15% "standing discounts", but nothing applies
them — checkout does not read a customer's tier. Treat them as unbuilt.
Rewards
Staff manage the catalog at Admin → Settings → Loyalty Rewards
(/admin/settings/loyalty-rewards, settings:read to view, settings:write to change), or in
the desktop app under Customers → Loyalty rewards. Both call the same rules in
lib/loyalty-rewards.ts.
A reward has a points cost, a dollar discount, a minimum tier, an optional total redemption limit and an on/off switch. The screen shows each reward's return — the discount as a share of the spend that earned the points (at 10 points per $1, $5 for 500 points is 10%).
Only DISCOUNT rewards exist. Redeeming one (POST /api/loyalty/redeem) runs in one
transaction: it checks the tier, claims a slot under the limit, debits the balance only if it
covers the cost, records a REDEEMED_REWARD transaction, and issues a single-use, fixed-amount
REWARD-… discount code valid for 90 days. The customer sees the code on /account/rewards and
enters it at checkout.
A reward that has been redeemed cannot be deleted — redemptions and issued codes point at it — so switch it off instead. Its limit also cannot be lowered below the number already redeemed.
On the storefront
/rewardsis the public program page: how earning works, the live reward catalog (activeDISCOUNTrewards that have not hitmaxRedemptions), the tier ladder with what each tier unlocks, and a short FAQ. A signed-in visitor also sees their balance, tier and progress to the next tier, fetched in the browser fromGET /api/loyaltyso the page itself stays cached. It is in the sitemap and linked from the Shop menu and the footer.- Product pages and the cart show the most points the jar or the cart subtotal can earn
(
components/store/LoyaltyPointsHint.tsx), worded "up to" because a discount code applied at checkout lowers the base. Guests are asked to sign in, since guest orders earn nothing. Printify merchandise (MerchOrder) does not earn points, so the copy only promises them on salsa. - The display math (points for an amount, tier progress) is in
lib/loyalty-progress.tsand rounds the same way as the purchase credit. It readsPOINTS_PER_DOLLARandTIER_THRESHOLDSfrompackages/core/lib/loyalty-rules.ts, which has no server imports, so the client components don't bundle Prisma;lib/loyalty.tsre-exports them, along withREFERRAL_POINTS.
Referrals
Each customer's account rewards page shows a refer-a-friend link, /refer/<code>. The code is
issued the first time the page is opened (getOrCreateReferralCode in
packages/core/lib/referrals.ts) and stored in User.referralCode.
- Opening the link (
app/refer/[code]/route.ts) stores the code in thejms_refcookie for 30 days and sends the visitor to/auth/signup?referred=1, which explains the bonus. - When an account is created, by password sign-up or the first OAuth sign-in,
attachReferrerFromCookiesetsUser.referredByIdto the code's owner. It only runs for a new account, never for an existing one, and a customer's own code is ignored. The cookie is deleted once it is read, so it credits only one new account. - On the referred customer's first paid order that earns purchase points,
creditPurchaseLoyaltyPointscallscreditReferralPointsInTx. It claimsUser.referralRewardedAtwith a conditional write, then givesREFERRAL_POINTS(500) to both customers asEARNED_REFERRALtransactions, each with its ownloyalty.points_earnedevent keyed on that customer. Like purchase points, this runs on the payment's transaction, so a rolled-back payment takes the bonus with it and a racing webhook cannot pay it twice.
The migration 20261020000000_customer_referrals adds the three User columns. It was applied
to production by hand on 2026-10-20, before the code shipped.
Data model
LoyaltyAccount is created lazily — getOrCreateLoyaltyAccount(userId) returns the
existing account or makes one, with the ten most recent transactions attached. Every
change goes through awardPoints(), which writes a transaction row, updates the
balance and lifetime total, recalculates the tier, and reports whether the tier
changed so the caller can congratulate the customer.
Points are awarded with a positive value and redeemed with a negative one — there is one code path, not two.
Related
How is this guide?
Last updated on