Skip to content

Repository files navigation

Loyalty Engine

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.


What This System Does

When a user makes a purchase, four things happen automatically and independently:

  1. 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
  2. Their tier is recalculated — based on total points earned in the last 12 months, tier can upgrade or downgrade in real time
  3. Their customer profile is updated — purchase count, total spend, and recency are recalculated, and they are placed into a behaviour-based RFM segment
  4. 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.


Modules — What They Do and Why They Matter

Purchases Module

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.


Loyalty Points Module

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.


Loyalty Tiers Module

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.


Referrals Module

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.


RFM Module

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.


Users Module

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.


Architecture Overview

Tech Stack

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

Module Structure

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

Core Concepts

1. The Purchase Event Bus

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.


2. The Points Ledger

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 readspointsBalance is a single integer field, no aggregation needed regardless of ledger history length
  • ExpiryexpiresAt records when earning entries expire; redemption rows use 2099-12-31 so they are never filtered out
  • Reversals are safe — a refund inserts a negative row, nothing is deleted or overwritten
  • Atomic consistency — ledger insert and pointsBalance update 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.


3. The Referral System

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)

4. RFM Segmentation

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.


Data Model

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

API Reference

Auth

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.

Purchases

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.

Loyalty Points

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).

RFM

Method Path Auth Description
GET /rfm/me Session Get the current user's RFM scores and segment name.

Referrals

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.

Users

Method Path Auth Description
GET /users/me Session Aggregated profile: user fields + point balance + tier + RFM segment.

Getting Started

Prerequisites

  • Node.js 20+
  • PostgreSQL database
  • pnpm

Setup

pnpm install

Copy .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 generate

Start the development server:

pnpm start:dev

What Is Not Built Yet

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

About

A loyalty engine that rewards every purchase in real time — points, tier upgrades, referral bonuses, and customer segmentation, all from a single event. Clean event-driven architecture with NestJS, Prisma & PostgreSQL.

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages