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
| Type | Description | Example |
|---|---|---|
PERCENTAGE | Percentage discount off subtotal | 10% off |
FIXED_AMOUNT | Fixed amount discount | K 50 off |
FREE_DELIVERY | Waives delivery fee | Free 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:
isActive = truestartDate <= now <= endDate(ifendDateis set)usageCount < usageLimit(ifusageLimitis set)cart.subtotal >= minOrderAmount(ifminOrderAmountis set)- Cart contains at least one item from
applicableCategoriesorapplicableProducts(if specified) - If
shopIdis 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
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /coupons | SHOP_OWNER | Create coupon |
GET | /coupons | None | List available coupons |
GET | /coupons/:id | None | Get coupon details |
PUT | /coupons/:id | SHOP_OWNER | Update coupon |
DELETE | /coupons/:id | SHOP_OWNER | Deactivate coupon |
POST | /coupons/:id/validate | Required | Validate 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
minRedeemPointsrequired to redeem - Points expire after
expiryMonthsof inactivity
Example:
- Points to redeem: 500
redemptionRate: 0.01- Discount value: K 5.00
3.4 Points Transaction Types
| Type | Trigger | Effect |
|---|---|---|
EARNED | Order confirmed | Points added to balance |
REDEEMED | Checkout with points | Points deducted from balance |
EXPIRED | Scheduled job | Expired points removed |
ADJUSTED | Admin action | Manual adjustment |
BONUS | Promotional | Bonus 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
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /loyalty/programs | SHOP_OWNER | Create loyalty program |
PUT | /loyalty/programs/:id | SHOP_OWNER | Update loyalty program |
GET | /loyalty/points | Required | Get current points balance (all shops) |
GET | /loyalty/points/shop/:shopId | Required | Get shop-specific balance |
GET | /loyalty/history | Required | Get points transaction history |
POST | /loyalty/redeem | Required | Redeem 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
| Metric | Description | Alert Threshold |
|---|---|---|
coupon_redemption_rate | Percentage of orders using coupons | Track trends |
coupon_fraud_attempts | Invalid coupon code attempts | > 20/hour |
loyalty_points_issued | Total points issued daily | Track trends |
loyalty_points_redeemed | Total points redeemed daily | Track trends |
loyalty_points_expired | Total points expired daily | Track trends |
loyalty_program_enrollment | New enrollments per day | Track trends |
For internal use only. Do not distribute outside Pakashop engineering.