Welcome to the Jose Madrid Salsa developer docs — explore features, APIs, and deployment guides.
Jose Madrid SalsaJMS Docs

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

ActionPoints
Purchase10 per dollar spent
Referral500
Writing a review50
Birthday250

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.

TierLifetime points
BRONZE0
SILVER500
GOLD1,000
PLATINUM2,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

  • /rewards is the public program page: how earning works, the live reward catalog (active DISCOUNT rewards that have not hit maxRedemptions), 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 from GET /api/loyalty so 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.ts and rounds the same way as the purchase credit. It reads POINTS_PER_DOLLAR and TIER_THRESHOLDS from packages/core/lib/loyalty-rules.ts, which has no server imports, so the client components don't bundle Prisma; lib/loyalty.ts re-exports them, along with REFERRAL_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.

  1. Opening the link (app/refer/[code]/route.ts) stores the code in the jms_ref cookie for 30 days and sends the visitor to /auth/signup?referred=1, which explains the bonus.
  2. When an account is created, by password sign-up or the first OAuth sign-in, attachReferrerFromCookie sets User.referredById to 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.
  3. On the referred customer's first paid order that earns purchase points, creditPurchaseLoyaltyPoints calls creditReferralPointsInTx. It claims User.referralRewardedAt with a conditional write, then gives REFERRAL_POINTS (500) to both customers as EARNED_REFERRAL transactions, each with its own loyalty.points_earned event 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.

How is this guide?

Edit on GitHub

Last updated on

On this page