0) Problem Restatement
Flipkart's machine coding round: build a BNPL (Buy Now Pay Later) system from scratch in about 90 minutes, as working code, followed by a review round on your design. Typical requirements:
- Onboard users with a credit limit (e.g., ₹5,000).
- A user buys items using BNPL. The amount is taken from their available limit and split into installments (e.g., 3 monthly payments).
- Users repay installments, which frees limit again.
- Show dues: upcoming and overdue installments.
- Block users who have overdue payments beyond N days, or who try to exceed their limit.
- Report user status (limit, used, overdue).
1) Class Design
Architecture Diagram
classDiagram
class User { +id +name +creditLimit +usedLimit +blocked }
class Purchase { +id +userId +amount +createdOn +installments }
class Installment { +purchaseId +number +amount +dueDate +paidAmount +status }
class InstallmentPlan {
<<interface>>
+split(amount, startDate) List
}
class EqualMonthlyPlan
class BNPLService {
+onboard(name, limit) User
+buy(userId, amount, plan, today) Purchase
+repay(userId, purchaseId, amount, today) void
+dues(userId, today) List
+refreshStatus(userId, today) void
}
InstallmentPlan <|.. EqualMonthlyPlan
BNPLService --> User
BNPLService --> Purchase
Purchase --> Installment
BNPLService --> InstallmentPlan2) Code (Python)
from dataclasses import dataclass, field
from datetime import date, timedelta
import itertools
@dataclass
class Installment:
number: int; amount: int; due: date; paid: int = 0
@property
def remaining(self): return self.amount - self.paid
@dataclass
class Purchase:
id: int; user_id: int; amount: int; installments: list = field(default_factory=list)
@dataclass
class User:
id: int; name: str; limit: int; used: int = 0; blocked: bool = False
class EqualMonthlyPlan:
def __init__(self, count=3, days=30): self.count, self.days = count, days
def split(self, amount, start):
base, extra = divmod(amount, self.count) # integer paise; spread the remainder
return [Installment(i + 1, base + (1 if i < extra else 0), start + timedelta(days=self.days * (i + 1)))
for i in range(self.count)]
class BNPLService:
OVERDUE_BLOCK_DAYS = 10
def __init__(self):
self.users, self.purchases, self._ids = {}, {}, itertools.count(1)
def onboard(self, name, limit):
u = User(next(self._ids), name, limit); self.users[u.id] = u; return u
def buy(self, user_id, amount, plan, today):
u = self.users[user_id]
self.refresh_status(user_id, today)
if u.blocked: raise PermissionError("user blocked due to overdue dues")
if amount <= 0 or u.used + amount > u.limit: raise ValueError("insufficient credit limit")
p = Purchase(next(self._ids), user_id, amount, plan.split(amount, today))
u.used += amount; self.purchases[p.id] = p
return p
def repay(self, user_id, purchase_id, amount, today):
p = self.purchases[purchase_id]
if p.user_id != user_id: raise PermissionError("not your purchase")
u = self.users[user_id]
for inst in sorted(p.installments, key=lambda i: i.due): # oldest dues first
if amount == 0: break
pay = min(amount, inst.remaining)
inst.paid += pay; amount -= pay; u.used -= pay # repaid money frees limit
if amount > 0: raise ValueError("payment exceeds outstanding amount")
self.refresh_status(user_id, today)
def dues(self, user_id, today):
return [(p.id, i.number, i.remaining, i.due, "OVERDUE" if i.due < today else "UPCOMING")
for p in self.purchases.values() if p.user_id == user_id
for i in p.installments if i.remaining > 0]
def refresh_status(self, user_id, today):
worst = max((today - d).days for _, _, _, d, s in self.dues(user_id, today) if s == "OVERDUE")
if any(s == "OVERDUE" for *_, s in self.dues(user_id, today)) else 0
self.users[user_id].blocked = worst > self.OVERDUE_BLOCK_DAYS
3) Design Choices to Explain in Review
- Money in integers (paise/cents), never floats. Split remainders are spread across the first installments, so the total always matches exactly.
- Strategy pattern for installment plans: add a
PayIn4PlanorInterestBearingPlanwithout changing the service. - Repayment order: oldest due first, and partial payments are allowed.
- Limit accounting:
usedincreases on purchase and decreases on repayment, so available =limit - used. - Blocking rule lives in one method (
refresh_status), easy to change (e.g., 10 days overdue → blocked; paying everything unblocks). - Validation and errors: clear exceptions for over-limit, blocked users, overpayment and wrong owner.
4) Taking It to Production
- Store users, purchases, installments and payments in a DB, and record money movements in an append-only ledger.
- Idempotency keys on buy and repay, since payment callbacks retry.
- A daily job marks installments overdue, sends reminders, applies late fees and blocks users. Don't compute everything on read.
- Concurrency: update
usedwith a conditional update (used + amount <= limit) so two simultaneous purchases can't exceed the limit. - Credit decisions: the limit comes from a risk model at onboarding and is adjusted by repayment behavior.
5) Wrap-Up
Model users (limit, used, blocked), purchases and their installments, with an installment-plan strategy that splits integer amounts exactly. Buying checks the block status and available limit, repaying pays the oldest dues first and frees limit, and a single status method blocks users with long-overdue installments. In production, add a ledger, idempotency, conditional limit updates and a daily overdue job.