Skip to main content

Loyalty & Coupons

Related docs: Data Models · Checkout Flow · API Design


1. Overview

Pakashop supports two promotional mechanisms:

  • Coupons: Time-limited discount codes (percentage, fixed amount, free delivery)
  • Loyalty Points: Shop-specific points programs where customers earn points on purchases and redeem them for discounts

2. Coupons

2.1 Coupon Types

TypeDescriptionExample
PERCENTAGEPercentage discount off subtotal10% off
FIXED_AMOUNTFixed amount discountK 50 off
FREE_DELIVERYWaives delivery feeFree delivery

2.2 Coupon Structure

model Coupon {
id String @id @default(cuid())
shopId String? // null = platform-wide
code String @unique
type CouponType
value Decimal @db.Decimal(10, 2)
minOrderAmount Decimal? @db.Decimal(10, 2)
maxDiscount Decimal? @db.Decimal(10, 2)
usageLimit Int? // null = unlimited
usageCount Int @default(0)
startDate DateTime
endDate DateTime?
applicableCategories String[]
applicableProducts String[]
isActive Boolean @default(true)
createdAt DateTime @default(now())

shop Shop? @relation(fields: [shopId], references: [id])
}

2.3 Validation Rules

A coupon is valid only if ALL of the following are true:

  1. isActive = true
  2. startDate <= now <= endDate (if endDate is set)
  3. usageCount < usageLimit (if usageLimit is set)
  4. cart.subtotal >= minOrderAmount (if minOrderAmount is set)
  5. Cart contains at least one item from applicableCategories or applicableProducts (if specified)
  6. If shopId is set, cart contains at least one item from that shop

2.4 Discount Calculation

// services/coupon/src/validation.service.js
function calculateDiscount(coupon, cart) {
let discount = 0;

switch (coupon.type) {
case 'PERCENTAGE':
discount = cart.subtotal * (coupon.value / 100);
break;
case 'FIXED_AMOUNT':
discount = coupon.value;
break;
case 'FREE_DELIVERY':
discount = cart.deliveryFee;
break;
}

// Apply max discount cap
if (coupon.maxDiscount && discount > coupon.maxDiscount) {
discount = coupon.maxDiscount;
}

// Ensure discount doesn't exceed subtotal
return Math.min(discount, cart.subtotal);
}

2.5 API Endpoints

MethodPathAuthDescription
POST/couponsSHOP_OWNERCreate coupon
GET/couponsNoneList available coupons
GET/coupons/:idNoneGet coupon details
PUT/coupons/:idSHOP_OWNERUpdate coupon
DELETE/coupons/:idSHOP_OWNERDeactivate coupon
POST/coupons/:id/validateRequiredValidate coupon for cart

3. Loyalty Points

3.1 Loyalty Program Structure

Each shop can run its own loyalty program:

model LoyaltyProgram {
id String @id @default(cuid())
shopId String @unique
pointsPerZMW Decimal @db.Decimal(10, 2) @default(1.00)
redemptionRate Decimal @db.Decimal(10, 4) @default(0.0100) // 1 point = K 0.01
minRedeemPoints Int @default(100)
expiryMonths Int @default(12)
isActive Boolean @default(true)
createdAt DateTime @default(now())

shop Shop @relation(fields: [shopId], references: [id])
points LoyaltyPoint[]
}

3.2 Points Earning

pointsEarned = floor(orderSubtotal × pointsPerZMW)

Example:

  • Order subtotal: K 250.00
  • pointsPerZMW: 1.00
  • Points earned: 250 points

3.3 Points Redemption

discountValue = pointsToRedeem × redemptionRate

Constraints:

  • Maximum 50% of subtotal can be paid with points
  • Minimum minRedeemPoints required to redeem
  • Points expire after expiryMonths of inactivity

Example:

  • Points to redeem: 500
  • redemptionRate: 0.01
  • Discount value: K 5.00

3.4 Points Transaction Types

TypeTriggerEffect
EARNEDOrder confirmedPoints added to balance
REDEEMEDCheckout with pointsPoints deducted from balance
EXPIREDScheduled jobExpired points removed
ADJUSTEDAdmin actionManual adjustment
BONUSPromotionalBonus points awarded

3.5 Expiration Logic

// services/scheduler/src/jobs/loyalty-expiry.js
async function expireLoyaltyPoints() {
const expiredPoints = await prisma.loyaltyPoint.findMany({
where: {
type: 'EARNED',
expiresAt: { lt: new Date() },
redeemed: false
}
});

for (const point of expiredPoints) {
await prisma.loyaltyPoint.create({
data: {
userId: point.userId,
shopId: point.shopId,
programId: point.programId,
points: -point.points,
type: 'EXPIRED',
orderId: point.orderId
}
});
}
}

// Run daily at 2 AM
cron.schedule('0 2 * * *', expireLoyaltyPoints);

3.6 API Endpoints

MethodPathAuthDescription
POST/loyalty/programsSHOP_OWNERCreate loyalty program
PUT/loyalty/programs/:idSHOP_OWNERUpdate loyalty program
GET/loyalty/pointsRequiredGet current points balance (all shops)
GET/loyalty/points/shop/:shopIdRequiredGet shop-specific balance
GET/loyalty/historyRequiredGet points transaction history
POST/loyalty/redeemRequiredRedeem points for discount

4. Checkout Integration

4.1 Coupon Application

// Frontend: checkout page
const [appliedCoupon, setAppliedCoupon] = useState(null);

async function applyCoupon(code) {
const { valid, discount, message } = await validateCoupon(authFetch, {
code,
cartItems: cart.items,
subtotal: cart.subtotal
});

if (valid) {
setAppliedCoupon({ code, discount });
recalculateTotal();
} else {
toast.error(message);
}
}

4.2 Loyalty Redemption

// Frontend: checkout page
const [redeemPoints, setRedeemPoints] = useState(0);

async function handleRedeemPoints(points) {
const maxRedeemable = Math.min(
pointsBalance,
Math.floor(cart.subtotal * 0.5 / redemptionRate)
);

if (points > maxRedeemable) {
toast.error(`Maximum redeemable: ${maxRedeemable} points`);
return;
}

setRedeemPoints(points);
recalculateTotal();
}

4.3 Total Calculation

subtotal = SUM(item.price × item.quantity)
vat = subtotal × 0.16
deliveryFee = pricingEngine.calculate(shippingAddress)
couponDiscount = coupon ? calculateDiscount(coupon, subtotal) : 0
loyaltyDiscount = redeemPoints × redemptionRate
total = subtotal + vat + deliveryFee - couponDiscount - loyaltyDiscount

5. Monitoring

MetricDescriptionAlert Threshold
coupon_redemption_ratePercentage of orders using couponsTrack trends
coupon_fraud_attemptsInvalid coupon code attempts> 20/hour
loyalty_points_issuedTotal points issued dailyTrack trends
loyalty_points_redeemedTotal points redeemed dailyTrack trends
loyalty_points_expiredTotal points expired dailyTrack trends
loyalty_program_enrollmentNew enrollments per dayTrack trends

For internal use only. Do not distribute outside Pakashop engineering.