A proof-of-concept loyalty and referral system built with NestJS, Prisma, and PostgreSQL. The goal is to model the core mechanics of a real loyalty program — points earned from purchases, referral bonuses, and customer segmentation — using clean event-driven architecture.
When a user makes a purchase, four things happen automatically and independently:
- They earn points — points per dollar scaled by their current tier multiplier (Bronze 1x, Silver 1.5x, Gold 2x, Platinum 3x), valid for 12 months
- Their tier is recalculated — based on total points earned in the last 12 months, tier can upgrade or downgrade in real time
- Their customer profile is updated — purchase count, total spend, and recency are recalculated, and they are placed into a behaviour-based RFM segment
- A referral bonus may be triggered — if this is their first purchase and they signed up using someone's referral code, the referrer is rewarded 50 points
None of these side-effects are orchestrated by the purchase endpoint itself. It simply saves the purchase and fires an event. Each system listens and reacts independently.
What it does: Accepts purchase records from external systems or clients, stores them, and emits an event that fans out to every other module.
Business value: This is the entry point for all revenue activity. Every dollar a customer spends flows through here. The module itself is intentionally thin — it does not know about points, referrals, or segmentation. It just records the transaction and signals that something happened. This keeps the purchase flow fast and the code decoupled: adding a new downstream behaviour (e.g. sending a receipt email) never requires touching this module.
Without it: There is no trigger. Nothing else in the system can react to customer activity.
What it does: Listens for purchase and referral events, awards points to users (scaled by their tier multiplier), tracks a full ledger of every point movement, manages tier progression, and allows users to redeem points.
Business value: Points are the core incentive mechanism. They give customers a tangible reason to return. The more a customer spends, the more points they accumulate, creating a habit loop — spend → earn → redeem → spend again.
The ledger model means every point is auditable: you can see exactly when points were earned, from which purchase, when they expire, and when they were spent. This matters for customer support ("why is my balance wrong?") and for finance (recognising loyalty liability on the books).
Real-world impact: A user who has 400 points is closer to a free reward. That balance is a retention mechanism — they are less likely to switch to a competitor because they would lose the value they have built up. This is called switching cost by design.
Without it: The system has no incentive layer. Users make purchases but receive nothing back. There is no reason to return beyond the product itself.
What it does: Assigns every user a tier (Bronze, Silver, Gold, Platinum) based on how many points they have earned in the last 12 months. The tier determines a multiplier that scales how many points they earn on every purchase. Tiers are recalculated in real time on each purchase and verified nightly by a batch scheduler.
The rolling 12-month window: Tiers are not lifetime achievements — they are based on points earned in the trailing 12 months only. Points earned 13 months ago no longer count toward the tier calculation. This means a user who stops spending will eventually drop tiers as old points fall out of the window.
| Tier | Points earned (last 12 months) | Multiplier |
|---|---|---|
| Bronze | 0 – 499 | 1x |
| Silver | 500 – 1,499 | 1.5x |
| Gold | 1,500 – 4,999 | 2x |
| Platinum | 5,000+ | 3x |
Business value: Tiers create aspirational spending. A Silver customer who knows Gold gives 2x points has a concrete financial reason to spend more. A Gold customer who sees their rolling total dropping toward the Silver threshold has a reason to act before they drop. This is how airlines sell upgrades and hotels drive repeat stays.
The multiplier compounds with time — a Platinum customer spending $100 earns 300 points where a Bronze customer earns 100. Over a year, the gap in accumulated value is significant, making tier status genuinely worth protecting.
The nightly batch (TierScheduler): Runs at 3am every night. For each user, it sums their purchase_reward and referral_reward ledger rows from the last 12 months using a single groupBy query per page. If the rolling total has crossed a tier boundary since the last evaluation, the tier is updated. This catches drops that happen passively — e.g. a user whose old points fell off the 12-month window overnight without making any new purchases.
Without it: Every customer earns at the same flat rate regardless of loyalty. Your best customers — who spend the most and most often — receive no extra reward for their behaviour. There is no incentive to spend more to reach the next level.
What it does: Generates a unique referral code for every user at signup. Tracks who referred whom. When a referred user makes their first purchase, the referrer is automatically rewarded with bonus points.
Business value: Referrals are the highest-converting acquisition channel for most consumer businesses. A recommendation from a friend carries more trust than any advertisement. This module turns your existing customers into a growth engine.
The economics are straightforward: if acquiring a customer through ads costs $30 and a referral reward costs $5 in points, referral-driven acquisition is 6x cheaper — and the referred customer is often higher quality because they came with a warm recommendation.
The reward is intentionally delayed to the first purchase (not signup). This filters out low-intent signups and ensures the referrer is only rewarded when the referee actually becomes a paying customer.
Without it: Customer growth relies entirely on paid channels or organic discovery. Existing customers have no incentive to spread the word.
What RFM is: RFM is a behavioural segmentation model that originated in direct mail marketing in the 1990s and has since become one of the most widely used frameworks in retail, e-commerce, and subscription businesses. The premise is simple: the best predictor of what a customer will do next is what they have already done. Instead of relying on demographics or surveys, RFM looks purely at transaction history and asks three questions about each customer:
- Recency — when did they last buy? A customer who bought yesterday is more valuable than one who bought two years ago, even if the latter spent more in total. Recent buyers are engaged; they remember you.
- Frequency — how many times have they bought? Someone who has made ten purchases is not just ten times as valuable as a one-time buyer — they have demonstrated a habit. Habit is the hardest thing to build and the most profitable thing to keep.
- Monetary — how much have they spent in total? High spenders may be fewer in number, but they disproportionately drive revenue. The classic finding (Pareto principle) is that roughly 20% of customers generate 80% of revenue — RFM helps you identify that 20%.
Each dimension is scored independently from 1 to 5, giving 125 possible score combinations. Those combinations are then mapped to named segments — not because the names are magic, but because "At Risk" is more actionable than "R=2, F=4, M=3".
What it does in this system: After every purchase, scores each customer on all three dimensions and places them into a named segment. Scores and segment are stored on UserMetrics and updated in real time — so the moment a customer makes a purchase, their profile reflects it.
Business value: Not all customers are equal. A business that treats a first-time buyer the same as a customer who has spent $2,000 over two years is leaving money on the table — and wasting marketing budget.
RFM segmentation answers the question: who are my best customers, and who am I about to lose?
| Segment | What it means | What you do |
|---|---|---|
| Champions | High recency, frequency, spend | Reward them, ask for reviews, make them brand advocates |
| Loyal Customers | Regular buyers, decent recency | Upsell, offer early access to new products |
| Potential Loyalists | Recent but infrequent | Nurture with targeted offers to build the habit |
| At Risk | Used to buy often, gone quiet | Re-engagement campaign — "We miss you, here are 50 bonus points" |
| Lost Customers | Low across the board | Last-resort win-back offer or write them off |
| New | First purchase or no activity yet | Onboarding sequence, explain the loyalty programme |
Without segmentation, every customer gets the same email, the same offer, the same treatment. With RFM, a "we miss you" campaign only goes to At Risk users — not to Champions who are already buying regularly and would find it patronising.
Without it: You have revenue data but no customer intelligence. You cannot differentiate marketing, cannot identify churn risk early, and cannot allocate retention budget where it matters most.
What it does: Provides a single aggregated endpoint (GET /users/me) that returns the user's profile, current point balance, tier, tier points earned, and RFM segment in one response.
Business value: In a real product, the user-facing dashboard — "You have 320 points", "You are Gold tier", and "You are a Loyal Customer" — needs to load fast and in one round trip. This module exists to serve that use case. It runs three queries in parallel (user, metrics, balance) and returns a unified response.
Without it: A frontend would need to call three separate endpoints, increasing latency and complexity on the client side.
| Layer | Technology |
|---|---|
| Framework | NestJS 11 |
| Database | PostgreSQL via Prisma 7 |
| Auth | Better Auth (email + password) |
| Events | @nestjs/event-emitter (synchronous, in-process) |
| Validation | class-validator + NestJS ValidationPipe |
src/
├── auth/ Better Auth integration + local auth endpoints
├── purchases/ Purchase creation and refund
├── loyalty/ Points ledger: earning, redeeming, balance queries
├── rfm/ RFM scoring and customer segmentation
├── referrals/ Referral creation, validation, and reward logic
├── users/ User profile endpoint (aggregates user + points + RFM)
├── prisma/ PrismaService (database client wrapper)
└── shared/
└── events.ts Event name constants and payload type definitions
Every meaningful action in the system flows from a single trigger: POST /purchases.
User makes a purchase
│
▼
INSERT Purchase row
│
└── emit: purchase.completed
│
├──▶ LoyaltyService → read tier multiplier → award floor(amount * multiplier) points
│ → recalculate tier from rolling 12m ledger sum
├──▶ RfmService → update metrics, recalculate segment
└──▶ ReferralsService → check if this is first purchase
→ if yes and referral pending:
mark referral as rewarded
emit: referral.rewarded
│
▼
LoyaltyService
award 50 pts to referrer
Why events instead of direct service calls?
If PurchasesService called LoyaltyService, RfmService, and ReferralsService directly, it would need to import all three modules, creating tight coupling. As the system grows, every new side-effect would require modifying the purchase service.
With events, the purchase service only knows about one thing: emitting purchase.completed. Any system that cares about purchases registers a listener. The purchase service never changes.
Important: Events are currently synchronous (fired in the same request thread). This is intentional for the proof of concept — zero infrastructure needed. The purchase response waits for all handlers to finish. The event payload shapes are designed to be queue-compatible, so swapping eventEmitter.emit for a BullMQ job later requires no payload changes.
Every point movement — earning, redeeming, refunding — is a row in the PointsLedger table. This is the full audit trail. The spendable balance is maintained as a running total in UserMetrics.pointsBalance, updated atomically in the same transaction as every ledger write.
userId | points | type | sourceId | expiresAt
-------|--------|------------------|-------------|------------
u_001 | +150 | purchase_reward | purchase_1 | 2027-03-08
u_001 | +50 | referral_reward | referral_1 | 2027-04-01
u_001 | -100 | redemption | null | 2099-12-31
─────────────────────────────────────────────────────────────
UserMetrics.pointsBalance = 100 (incremented/decremented on every write)
Why this model:
- Full audit trail — every point movement is traceable to its source
- O(1) balance reads —
pointsBalanceis a single integer field, no aggregation needed regardless of ledger history length - Expiry —
expiresAtrecords when earning entries expire; redemption rows use2099-12-31so they are never filtered out - Reversals are safe — a refund inserts a negative row, nothing is deleted or overwritten
- Atomic consistency — ledger insert and
pointsBalanceupdate happen in a single database transaction
Redemption rows use expiresAt: 2099-12-31 (far future) rather than null. A null value would require special-casing in every query.
Minimum redemption is 100 points. Redemption uses a database transaction: it reads pointsBalance, validates it is sufficient, inserts the deduction row, and decrements the balance atomically — throwing a 400 if the balance is insufficient.
Referrals are created at signup and rewarded at first purchase.
At signup:
New user signs up with referralCode: "REF-ABC123"
│
├── Generate a unique REF-XXXXXX code for the new user
├── Create a zeroed UserMetrics row for the new user
└── If referral code is valid:
Link referredBy on the new user
INSERT Referral { status: "pending", referrerId, referredUserId }
This signup logic lives in databaseHooks.user.create.after inside src/auth.ts. Better Auth fires this hook after inserting the user row. Because auth.ts sits outside NestJS dependency injection, it uses a standalone Prisma client directly — no services, no event bus.
At first purchase:
ReferralsService handles purchase.completed
│
├── Count purchases for this user directly from the Purchase table
│ (NOT from UserMetrics.purchaseCount — event handler order is not guaranteed,
│ UserMetrics may not have been incremented yet)
│
├── If count == 1:
│ UPDATE referral SET status = 'rewarded'
│ WHERE referredUserId = userId
│ AND status = 'pending' ← idempotency lock
│ AND createdAt >= 30 days ago
│
└── If 1 row was updated:
emit: referral.rewarded → LoyaltyService awards 50 pts to referrer
Idempotency: The status = 'pending' condition in the update acts as a compare-and-swap. If two purchase.completed events somehow fired concurrently for the same user, only the first update would match. The second would update 0 rows and skip the reward — no double-payout, no distributed lock needed.
Referral lifecycle:
pending ──▶ rewarded (referee makes first purchase within 30 days)
──▶ expired (no purchase within 30 days)
──▶ rejected (purchase was refunded)
After every purchase, the buyer's customer profile is updated and they are placed into a segment. RFM stands for Recency, Frequency, Monetary — three independent scores from 1 to 5.
Recency — how recently did the user buy?
| Days since last purchase | Score |
|---|---|
| 0 – 7 | 5 |
| 8 – 30 | 4 |
| 31 – 90 | 3 |
| 91 – 180 | 2 |
| 180+ | 1 |
Frequency — how many purchases total?
| Purchase count | Score |
|---|---|
| 10+ | 5 |
| 6 – 9 | 4 |
| 3 – 5 | 3 |
| 2 | 2 |
| 1 | 1 |
Monetary — how much have they spent in total?
| Total spend | Score |
|---|---|
| $1000+ | 5 |
| $500 – $999 | 4 |
| $200 – $499 | 3 |
| $50 – $199 | 2 |
| < $50 | 1 |
Once all three scores are computed, they are matched against an ordered set of segment rules. The first rule whose conditions are satisfied wins:
| Segment | Conditions |
|---|---|
| Champions | R ≥ 4, F ≥ 4, M ≥ 4 |
| Loyal Customers | R ≥ 3, F ≥ 3 |
| Potential Loyalists | R ≥ 3, F ≤ 2 |
| At Risk | R ≤ 2, F ≥ 3 |
| Lost Customers | R ≤ 2, F ≤ 2, M ≤ 2 |
| New | (fallback — nothing matched) |
Rule order matters. Champions is checked first because it is the most specific. If Loyal Customers were checked first, a user with R=5, F=5, M=5 would be incorrectly labelled as Loyal rather than Champion.
This range-based approach covers all 125 possible R/F/M combinations meaningfully. Adding a new segment requires inserting one rule object in the right priority position — no other code changes needed.
User
referralCode unique code assigned at signup (REF-XXXXXX)
referredBy userId of the person who referred them
Referral
referrerId the user who shared their code
referredUserId the user who used the code (unique — one referral per user)
status pending | rewarded | expired | rejected
rewardPoints default 50
Purchase
userId, amount, createdAt
PointsLedger
userId
points positive (earned) or negative (redeemed/refunded)
transactionType purchase_reward | referral_reward | redemption
sourceId purchaseId or referralId (nullable for manual redemptions)
expiresAt +12 months for rewards; 2099-12-31 for deductions
UserMetrics
purchaseCount total purchases (incremented by RfmService on purchase.completed)
totalSpend cumulative spend
lastPurchaseAt timestamp of most recent purchase
rfmRecency R score (1–5)
rfmFrequency F score (1–5)
rfmMonetary M score (1–5)
rfmSegment segment name string (e.g. "Champions")
pointsBalance current spendable point balance (running total, updated atomically on every earn/redeem)
tier current tier name (Bronze | Silver | Gold | Platinum)
tierPointsEarned rolling 12-month earned points total (used for tier calculation only, excludes redemptions)
tierEvaluatedAt timestamp of last tier recalculation
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/auth/sign-up/email |
Public | Register. Accepts optional referralCode in body. |
| POST | /api/auth/sign-in/email |
Public | Login. |
| POST | /api/auth/sign-out |
Session | Logout. |
| GET | /api/auth/get-session |
Public | Get current session. |
| GET | /auth/me |
Session | Returns the current user object. |
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /purchases |
Session | Create a purchase. Body: { amount: number }. Triggers the full event cascade. |
| GET | /purchases |
Session | List all purchases for the current user. |
| GET | /purchases/:id |
Session | Get a single purchase. |
| POST | /purchases/:id/refund |
Session | Refund a purchase. Emits purchase.refunded — reverses points and metrics. |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /loyalty/balance |
Session | Get current active point balance. |
| GET | /loyalty/ledger |
Session | Paginated ledger history. Query params: page, limit. |
| POST | /loyalty/redeem |
Session | Redeem points. Body: { points: number } (min 100). |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /rfm/me |
Session | Get the current user's RFM scores and segment name. |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /referrals/validate/:code |
Public | Check if a referral code is valid before signup. |
| GET | /referrals/mine |
Session | List all referrals sent by the current user. |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /users/me |
Session | Aggregated profile: user fields + point balance + tier + RFM segment. |
- Node.js 20+
- PostgreSQL database
- pnpm
pnpm installCopy .env.example to .env and fill in your database URL and auth secret:
DATABASE_URL="postgresql://user:password@localhost:5432/loyaltyengine"
BETTER_AUTH_SECRET="your-secret-here"
BETTER_AUTH_URL="http://localhost:3000"Run migrations and generate the Prisma client:
pnpm prisma migrate dev
pnpm prisma generateStart the development server:
pnpm start:dev| Feature | Notes |
|---|---|
| Async event processing (BullMQ) | Event payloads are already queue-shaped; swap emit for a job enqueue |
| Scheduled referral expiry | Referrals stay pending indefinitely; needs a cron job to mark them expired |
| Expired points sweep | A nightly job to decrement pointsBalance for entries whose expiresAt has passed |
| Campaign engine | Trigger bonus points by RFM segment for re-engagement campaigns |
| Tier-exclusive rewards | Perks beyond multipliers (early access, free shipping) tied to tier level |
| Fraud / abuse prevention | Velocity limits on referrals, points hold period before redemption |
| Purchase idempotency | Deduplication key to prevent double-award on retried requests |