Feature 04 — Loyalty Program
Phase: 2 Priority: 🟠 High Status: ⏳ Pending Depends on: Feature 02 (return workflow emits events for point deduction)
Context
No loyalty infrastructure exists. Need a configurable points-based system:
- Business configures earn rate (points per $ spent) and redeem rate (point value)
- Points are earned automatically when a sale is created (via event)
- Points are deducted when a return is processed
- At checkout, customer can redeem points for a discount
- Customer tier (standard/silver/gold/vip) based on lifetime points
Integration approach: Event-driven. The LoyaltyService listens to OnCreateSaleEvent (already emitted in sales.service.ts) — no modification needed in the sales module.
Relevant existing files:
apps/backend/src/sales/application/sales.service.ts— emitsOnCreateSaleEventapps/backend/src/customer-returns/application/customer-returns.service.ts— emitsOnCreateCustomerReturnEventapps/frontend-pwa/src/components/forms/sale/SaleForm.tsx— extend payment sectionapps/frontend-pwa/src/components/forms/customer/CustomerPage.tsx— extend with loyalty info
Task Checklist
Database
- Create migration:
packages/backend/database/src/migrations/2026-03-XX-loyalty-program.mjs - Create
loyalty_programtable - Create
loyalty_accounttable - Create
loyalty_transactiontable - Add indexes
- Run
pnpm run migration:local:push - Run
pnpm run generate:types
Backend
- Create module:
apps/backend/src/loyalty/ - Create
loyalty.module.ts - Create
domain/loyalty-repository.domain.ts - Create
infrastructure/loyalty.repository.ts - Create
application/loyalty.service.ts-
createProgram(),getProgram(),updateProgram() -
getOrCreateAccount(customerId, businessId) -
earnPoints(accountId, saleId, amount) -
redeemPoints(accountId, points, saleId)— validate balance -
resolveRedeem(accountId, saleAmount)— returns max redeemable - Event listener:
@OnEvent(OnCreateSaleEvent.eventName) - Event listener:
@OnEvent(OnCreateCustomerReturnEvent.eventName)
-
- Create
interfaces/loyalty.controller.ts - Create all DTOs
- Register
LoyaltyModuleinapps/backend/src/app.module.ts
PWA Frontend
- Create
apps/frontend-pwa/src/types/loyalty.ts - Create
apps/frontend-pwa/src/services/loyaltyService.ts - Create
apps/frontend-pwa/src/components/forms/loyalty/LoyaltyProgramPage.tsx - Extend
SaleForm.tsx— loyalty balance display + points redemption in payment section - Extend
CustomerPage.tsx— loyalty balance, tier, lifetime points, transaction history - Register
LoyaltyProgramPageinMainPage.tsx
Verification
- Migration applies cleanly
- Types regenerated
- Backend builds
- Creating a sale auto-earns points for linked customer
-
POST /loyalty/resolve-redeemreturns correct max redeemable amount - Points are correctly deducted when a return is processed
- SaleForm shows loyalty balance and allows redemption input
- CustomerPage shows loyalty account info
Database Schema
// packages/backend/database/src/migrations/2026-03-XX-loyalty-program.mjs
import { Kysely, sql } from "kysely";
export async function up(db) {
// 1. Loyalty program config (one per business)
await db.schema
.createTable("loyalty_program")
.addColumn("id", "uuid", (col) => col.primaryKey().defaultTo(sql`gen_random_uuid()`))
.addColumn("business_id", "uuid", (col) => col.notNull().references("business.id").onDelete("cascade"))
.addColumn("name", "varchar", (col) => col.notNull())
.addColumn("points_per_unit", "numeric", (col) => col.notNull().defaultTo(1))
// points earned per 1 unit of base currency spent
.addColumn("points_value", "numeric", (col) => col.notNull().defaultTo(0.01))
// monetary value of 1 point in base currency
.addColumn("min_redeem_points", "integer", (col) => col.defaultTo(100))
.addColumn("max_redeem_pct", "numeric", (col) => col.defaultTo(100))
// max % of sale total payable with points
.addColumn("expiry_days", "integer") // null = points never expire
.addColumn("tier_silver_threshold", "integer", (col) => col.defaultTo(1000))
.addColumn("tier_gold_threshold", "integer", (col) => col.defaultTo(5000))
.addColumn("tier_vip_threshold", "integer", (col) => col.defaultTo(15000))
.addColumn("is_active", "boolean", (col) => col.notNull().defaultTo(true))
.addColumn("created_at", "timestamptz", (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`))
.addColumn("created_by", "uuid", (col) => col.notNull().references("user.id").onDelete("cascade"))
.addColumn("updated_at", "timestamptz")
.addColumn("updated_by", "uuid", (col) => col.references("user.id").onDelete("set null"))
.execute();
// 2. Customer loyalty account
await db.schema
.createTable("loyalty_account")
.addColumn("id", "uuid", (col) => col.primaryKey().defaultTo(sql`gen_random_uuid()`))
.addColumn("business_id", "uuid", (col) => col.notNull().references("business.id").onDelete("cascade"))
.addColumn("customer_id", "uuid", (col) => col.notNull().references("customer.id").onDelete("cascade"))
.addColumn("points", "integer", (col) => col.notNull().defaultTo(0))
.addColumn("lifetime_points", "integer", (col) => col.notNull().defaultTo(0))
.addColumn("tier", "varchar", (col) => col.notNull().defaultTo("standard"))
// 'standard' | 'silver' | 'gold' | 'vip'
.addColumn("is_active", "boolean", (col) => col.notNull().defaultTo(true))
.addColumn("created_at", "timestamptz", (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`))
.addColumn("updated_at", "timestamptz")
.execute();
await db.schema.createIndex("idx_loyalty_account_customer")
.on("loyalty_account")
.columns(["business_id", "customer_id"])
.unique()
.execute();
// 3. Points ledger
await db.schema
.createTable("loyalty_transaction")
.addColumn("id", "uuid", (col) => col.primaryKey().defaultTo(sql`gen_random_uuid()`))
.addColumn("business_id", "uuid", (col) => col.notNull().references("business.id").onDelete("cascade"))
.addColumn("account_id", "uuid", (col) => col.notNull().references("loyalty_account.id").onDelete("cascade"))
.addColumn("type", "varchar", (col) => col.notNull())
// 'earn' | 'redeem' | 'adjust' | 'expire'
.addColumn("points", "integer", (col) => col.notNull())
// positive = earn/adjust up, negative = redeem/expire
.addColumn("reference_type", "varchar")
// 'sale' | 'return' | 'manual'
.addColumn("reference_id", "uuid")
.addColumn("note", "text")
.addColumn("created_at", "timestamptz", (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`))
.addColumn("created_by", "uuid", (col) => col.references("user.id").onDelete("set null"))
.execute();
await db.schema.createIndex("idx_loyalty_transaction_account")
.on("loyalty_transaction")
.columns(["account_id", "created_at"])
.execute();
}
export async function down(db) {
await db.schema.dropIndex("idx_loyalty_transaction_account").execute();
await db.schema.dropIndex("idx_loyalty_account_customer").execute();
await db.schema.dropTable("loyalty_transaction").ifExists().execute();
await db.schema.dropTable("loyalty_account").ifExists().execute();
await db.schema.dropTable("loyalty_program").ifExists().execute();
}
Backend Implementation
Service: loyalty.service.ts
@Injectable()
export class LoyaltyService {
constructor(
@Inject(DATABASE) private readonly database: KyselyDatabase,
private readonly repository: LoyaltyRepository,
) {}
// Program management
async createProgram(data: CreateLoyaltyProgramDTO): Promise<LoyaltyProgram>
async getProgram(businessId: string): Promise<LoyaltyProgram | undefined>
async updateProgram(id: string, data: UpdateLoyaltyProgramDTO): Promise<LoyaltyProgram>
// Account management
async getOrCreateAccount(customerId: string, businessId: string): Promise<LoyaltyAccount>
async getAccountByCustomer(customerId: string, businessId: string): Promise<LoyaltyAccount | undefined>
async getTransactions(accountId: string, page: number, size: number): Promise<IOffsetPagination<LoyaltyTransaction>>
// Core operations
async resolveRedeem(accountId: string, saleBaseAmount: number, businessId: string): Promise<{
availablePoints: number;
maxRedeemablePoints: number;
maxRedeemableAmount: number; // in base currency
}>
async redeemPoints(accountId: string, points: number, saleId: string, createdBy: string): Promise<LoyaltyAccount>
async manualAdjust(accountId: string, points: number, note: string, createdBy: string): Promise<LoyaltyTransaction>
// Event listeners
@OnEvent(OnCreateSaleEvent.eventName)
async handleSaleCreated(event: OnCreateSaleEvent) {
const sale = event.createdSaleRecord;
if (!sale.customerId) return;
const program = await this.getProgram(sale.businessId);
if (!program?.isActive) return;
const account = await this.getOrCreateAccount(sale.customerId, sale.businessId);
const earnedPoints = Math.floor(sale.totalBaseAmount * program.pointsPerUnit);
if (earnedPoints <= 0) return;
await this.database.transaction().execute(async (trx) => {
await this.repository.createTransaction({
accountId: account.id,
businessId: sale.businessId,
type: 'earn',
points: earnedPoints,
referenceType: 'sale',
referenceId: sale.id,
}, trx);
const newTier = this.calculateTier(account.lifetimePoints + earnedPoints, program);
await this.repository.updateAccount(account.id, {
points: account.points + earnedPoints,
lifetimePoints: account.lifetimePoints + earnedPoints,
tier: newTier,
}, trx);
});
}
private calculateTier(lifetimePoints: number, program: LoyaltyProgram): string {
if (lifetimePoints >= program.tierVipThreshold) return 'vip';
if (lifetimePoints >= program.tierGoldThreshold) return 'gold';
if (lifetimePoints >= program.tierSilverThreshold) return 'silver';
return 'standard';
}
}
Endpoints: loyalty.controller.ts
@Controller("loyalty")
export class LoyaltyController {
// Program
@Post("programs") createProgram(@Body() dto: CreateLoyaltyProgramDTO)
@Get("programs") getProgram(@Query("businessId") businessId: string)
@Patch("programs/:id") updateProgram(@Param("id") id: string, @Body() dto: UpdateLoyaltyProgramDTO)
// Accounts
@Post("accounts") createAccount(@Body() dto: CreateLoyaltyAccountDTO)
@Get("accounts/customer/:customerId") getByCustomer(
@Param("customerId") customerId: string,
@Query("businessId") businessId: string
)
@Get("accounts/:id") getAccount(@Param("id") id: string)
@Get("accounts/:id/transactions") getTransactions(
@Param("id") id: string,
@Query("page") page: number,
@Query("size") size: number,
)
// Operations
@Post("resolve-redeem") resolveRedeem(@Body() dto: ResolveRedeemDTO)
@Post("transactions") adjustPoints(@Body() dto: AdjustPointsDTO)
}
PWA Frontend
Types: types/loyalty.ts
export interface LoyaltyProgram {
id?: string;
businessId: string;
name: string;
pointsPerUnit: number; // points per $1 spent
pointsValue: number; // $ value per point
minRedeemPoints: number;
maxRedeemPct: number;
expiryDays?: number;
tierSilverThreshold: number;
tierGoldThreshold: number;
tierVipThreshold: number;
isActive: boolean;
}
export interface LoyaltyAccount {
id: string;
customerId: string;
businessId: string;
points: number;
lifetimePoints: number;
tier: 'standard' | 'silver' | 'gold' | 'vip';
isActive: boolean;
}
export interface LoyaltyTransaction {
id: string;
accountId: string;
type: 'earn' | 'redeem' | 'adjust' | 'expire';
points: number;
referenceType?: string;
referenceId?: string;
note?: string;
createdAt: string;
}
export interface ResolveRedeemResult {
availablePoints: number;
maxRedeemablePoints: number;
maxRedeemableAmount: number;
}
Extend SaleForm.tsx — Loyalty Section
// In the payment section, after customer is selected:
{selectedCustomer && loyaltyAccount && (
<div className="border rounded p-3 bg-amber-50">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">Loyalty Points</span>
<span className="text-sm">{loyaltyAccount.points} pts ({loyaltyAccount.tier})</span>
</div>
<div className="flex items-center gap-2 mt-2">
<Checkbox
checked={useLoyaltyPoints}
onCheckedChange={setUseLoyaltyPoints}
/>
<label>Redeem points</label>
{useLoyaltyPoints && (
<Input
type="number"
max={resolveResult?.maxRedeemablePoints}
value={pointsToRedeem}
onChange={(e) => setPointsToRedeem(Number(e.target.value))}
/>
)}
{useLoyaltyPoints && (
<span className="text-sm text-green-600">
= ${(pointsToRedeem * program.pointsValue).toFixed(2)} discount
</span>
)}
</div>
</div>
)}
Extend CustomerPage.tsx — Loyalty Tab
// Add loyalty tab to customer detail view:
<Tab label="Loyalty">
<div className="grid grid-cols-3 gap-4">
<Card title="Current Points" value={account.points} />
<Card title="Lifetime Points" value={account.lifetimePoints} />
<Card title="Tier" value={account.tier} badge />
</div>
<LoyaltyTransactionList accountId={account.id} />
<Button onClick={() => setShowAdjustModal(true)}>Manual Adjustment</Button>
</Tab>