Skip to main content

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 — emits OnCreateSaleEvent
  • apps/backend/src/customer-returns/application/customer-returns.service.ts — emits OnCreateCustomerReturnEvent
  • apps/frontend-pwa/src/components/forms/sale/SaleForm.tsx — extend payment section
  • apps/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_program table
  • Create loyalty_account table
  • Create loyalty_transaction table
  • 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 LoyaltyModule in apps/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 LoyaltyProgramPage in MainPage.tsx

Verification

  • Migration applies cleanly
  • Types regenerated
  • Backend builds
  • Creating a sale auto-earns points for linked customer
  • POST /loyalty/resolve-redeem returns 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>