Machine coding · Wallets and payments
Online store with direct and instalment payments
- Reported in several places
- Senior Software Engineer (about 5 years of experience)
- Strategy
- Adapter
- 2 sources
Two or more independent public pages, at least one a candidate write-up.
Build the back end of a small online store that lives entirely in memory. Admins add products from the console, from a file or from an external feed, and users browse and buy. A user can pay straight through a payment gateway or in instalments within a personal credit limit that depends on their past payments. Payment methods are expected to change, so adding one should not mean rewriting checkout.
Restated in our own words, not a quote from Myntra or any page. The answer below is written for this site.
Clarify first
Pin the scope down before you name a single class.
Questions to ask first: is money held in whole paise or as decimal rupees, is the credit limit a fixed number or derived from payment history and by what rule, how many instalments are there and when is each one due, do unpaid instalments reduce what the user can borrow, can one order hold several products, and is stock taken before payment or only after it succeeds. Also ask which of the three product sources must work in the first version and whether refunds or cancellation are expected.
Assumed scope: integer paise everywhere and a single currency. An instalment order has three parts, the first charged at purchase and the others charged when the user asks to pay the next one. The credit limit is a base amount plus a percentage of everything the user has paid so far, with a cap, and unpaid instalment balances count against it. The report says the limit is configurable per user and based on previous payments; here one rule applies to everyone, and a per-user override is left as an extension. Stock is checked before payment and taken after it succeeds, which is safe because this version is single threaded. Out of scope: real gateways, interest and late fees, due dates, refunds, authentication and persistence.
Entities and relationships
Who owns what, and who talks to whom.
- Catalog owns the products keyed by sku, each with a name, a price in paise and a stock count. It validates items, merges repeats, and loads from any product source.
- ProductSource is the adapter interface with one method,
load().ListSource,CsvSourceandFeedSourceeach hide one input shape and return the same item shape. - Store is the orchestrator. It owns the accounts, the orders and a registry of payment methods, and exposes
checkoutandpayNext. - Account belongs to one user and holds the paid total and the outstanding instalment balance.
- Order records the user, the method name, the total, the status (NEW, FAILED, PAID or INSTALMENTS), the amount paid and the schedule of instalments still due.
- PaymentMethod is the strategy interface with
payandpayNext.DirectPaymentandInstalmentPaymentimplement it and are looked up by name. - CreditPolicy is a second strategy.
PastPaymentsPolicycomputes a limit from an account and can be replaced by any object withlimitFor. - Gateway is the outside collaborator with
charge(userId, amount). The code uses a fake that can be told to fail.
The design
The class map, the main flow and the patterns that earn their place.
- An admin loads products through a source, which converts its own format into catalog items.
- Checkout reads the catalog to check stock and add up the total in paise.
- Checkout hands the order to the chosen payment method by name.
- The instalment method asks the credit policy for the user's limit and compares it with the outstanding balance.
- The method charges the gateway for the part due now.
Open on the whiteboardAll 3 diagrams on one tidy board, with a step-by-step walkthrough.
Checkout is a short orchestration over three seams. Store.checkout refuses an empty cart and any line with a zero, negative or fractional quantity, merges the lines by sku, checks that each product exists with enough stock, and sums the total in integer paise. It then creates an order and asks the chosen PaymentMethod to pay. Only when that succeeds does it take the stock and set the order status. A failed payment leaves stock untouched and the order marked FAILED, so there is nothing to undo.
Strategy for payment. Direct and instalment payment differ in how much they charge now, what they remember afterwards and what they check beforehand. Each is a class with the same two methods, and the store finds it by name. Adding cash on delivery or a wallet is one new class and one registerMethod call, and checkout does not change (the last check does exactly this). Without the pattern, checkout would grow an if-else per method that every new method must edit.
Strategy for credit. The limit is computed by a CreditPolicy from the account each time, never stored, so it moves as the user pays. Here it is a base of 1,000.00 plus 10 percent of everything paid, capped. Available credit is the limit minus the outstanding balance, and a purchase above it is refused before the gateway is called. Worked example in paise: base 100000, a 90000 purchase in three parts charges 30000 now and leaves 60000 outstanding. A second 90000 purchase is refused because the limit is then 103000 and only 43000 is available. After the other two parts are paid, the paid total is 90000, the limit becomes 109000 and outstanding is back to 0.
Adapters for product sources. Each source converts its own format into the same item shape. The catalog validates a whole batch before adding any of it, so one bad row rejects the import instead of loading half of it. A feed that gives rupees with decimals is rounded once, at the edge, to paise.
Splitting money. The total is divided with integer arithmetic and the remainder goes on the first instalment, so the parts always add up: 100001 becomes 33335, 33333 and 33333. A total too small to give every part a paisa drops the empty parts, and an order left with nothing to schedule is PAID at once.
Trade-off. Checking stock and taking it later is a race once there are real threads, because two buyers can both see the last unit. The fix is to reserve stock atomically before charging and release it if payment fails, or to decrement with a conditional update. This version accepts the race to keep the flow readable.
The code
Runnable JavaScript, with the checks it passes.
class Catalog {
constructor() {
this.products = new Map();
}
check(item) {
if (!item.sku || !Number.isInteger(item.price) || item.price <= 0 || !Number.isInteger(item.stock) || item.stock < 0) {
throw new Error("invalid product " + item.sku);
}
}
add(item) {
this.check(item);
const existing = this.products.get(item.sku);
if (existing) existing.stock += item.stock;
else this.products.set(item.sku, { ...item });
}
importFrom(source) {
const items = source.load();
items.forEach((item) => this.check(item));
items.forEach((item) => this.add(item));
}
get(sku) {
return this.products.get(sku);
}
browse() {
return [...this.products.values()].filter((p) => p.stock > 0).map((p) => p.sku);
}
}
class ListSource {
constructor(items) {
this.items = items;
}
load() {
return this.items.map((item) => ({ ...item }));
}
}
class CsvSource {
constructor(text) {
this.text = text;
}
load() {
return this.text
.split("\n")
.filter((line) => line.length > 0)
.map((line) => {
const [sku, name, price, stock] = line.split(",");
return { sku, name, price: Number(price), stock: Number(stock) };
});
}
}
class FeedSource {
constructor(payload) {
this.payload = payload;
}
load() {
return this.payload.items.map((i) => ({
sku: i.id,
name: i.title,
price: Math.round(i.priceRupees * 100),
stock: i.qty,
}));
}
}
class FakeGateway {
constructor() {
this.charges = [];
this.failures = 0;
}
failNext(count) {
this.failures = count;
}
charge(userId, amount) {
if (this.failures > 0) {
this.failures -= 1;
return { ok: false };
}
this.charges.push({ userId, amount });
return { ok: true, ref: "ch" + this.charges.length };
}
}
class PastPaymentsPolicy {
constructor(base, percentOfPaid, cap) {
this.base = base;
this.percentOfPaid = percentOfPaid;
this.cap = cap;
}
limitFor(account) {
return Math.min(this.cap, this.base + Math.floor((account.paidTotal * this.percentOfPaid) / 100));
}
}
function splitEvenly(total, parts) {
const base = Math.floor(total / parts);
const amounts = Array.from({ length: parts }, () => base);
amounts[0] += total - base * parts;
return amounts;
}
class DirectPayment {
constructor(gateway) {
this.name = "direct";
this.gateway = gateway;
}
pay(account, order) {
if (!this.gateway.charge(account.id, order.total).ok) return { ok: false, reason: "declined" };
account.paidTotal += order.total;
order.paid = order.total;
return { ok: true, status: "PAID" };
}
payNext() {
return { ok: false, reason: "nothing due" };
}
}
class InstalmentPayment {
constructor(gateway, policy, count) {
this.name = "instalment";
this.gateway = gateway;
this.policy = policy;
this.count = count;
}
pay(account, order) {
const available = this.policy.limitFor(account) - account.outstanding;
if (order.total > available) return { ok: false, reason: "over credit limit" };
const amounts = splitEvenly(order.total, this.count).filter((amount) => amount > 0);
if (!this.gateway.charge(account.id, amounts[0]).ok) return { ok: false, reason: "declined" };
account.paidTotal += amounts[0];
account.outstanding += order.total - amounts[0];
order.paid = amounts[0];
order.schedule = amounts.slice(1);
return { ok: true, status: order.schedule.length === 0 ? "PAID" : "INSTALMENTS" };
}
payNext(account, order) {
if (order.schedule.length === 0) return { ok: false, reason: "nothing due" };
const amount = order.schedule[0];
if (!this.gateway.charge(account.id, amount).ok) return { ok: false, reason: "declined" };
order.schedule.shift();
order.paid += amount;
account.paidTotal += amount;
account.outstanding -= amount;
if (order.schedule.length === 0) order.status = "PAID";
return { ok: true, status: order.status };
}
}
class Store {
constructor(catalog, methods) {
this.catalog = catalog;
this.methods = new Map(methods.map((m) => [m.name, m]));
this.accounts = new Map();
this.orders = [];
}
registerMethod(method) {
this.methods.set(method.name, method);
}
account(id) {
if (!this.accounts.has(id)) this.accounts.set(id, { id, paidTotal: 0, outstanding: 0 });
return this.accounts.get(id);
}
checkout(userId, lines, methodName) {
const method = this.methods.get(methodName);
if (!method) throw new Error("unknown payment method " + methodName);
const wanted = new Map();
for (const line of lines) {
if (!Number.isInteger(line.qty) || line.qty <= 0) return { ok: false, reason: "unavailable " + line.sku };
wanted.set(line.sku, (wanted.get(line.sku) || 0) + line.qty);
}
if (wanted.size === 0) return { ok: false, reason: "empty cart" };
let total = 0;
for (const [sku, qty] of wanted) {
const product = this.catalog.get(sku);
if (!product || product.stock < qty) {
return { ok: false, reason: "unavailable " + sku };
}
total += product.price * qty;
}
const order = {
id: this.orders.length + 1,
userId,
method: methodName,
total,
status: "NEW",
paid: 0,
schedule: [],
};
this.orders.push(order);
const result = method.pay(this.account(userId), order);
if (!result.ok) {
order.status = "FAILED";
return { ok: false, reason: result.reason, orderId: order.id };
}
for (const [sku, qty] of wanted) this.catalog.get(sku).stock -= qty;
order.status = result.status;
return { ok: true, orderId: order.id, status: order.status };
}
payNext(orderId) {
const order = this.orders[orderId - 1];
if (!order) return { ok: false, reason: "unknown order" };
return this.methods.get(order.method).payNext(this.account(order.userId), order);
}
}Runs here in your browser, in a sandboxed worker. Nothing is sent anywhere.
Try your own input
Write JavaScript that calls the code above and prints with console.log. It runs after the solution.
Extend it
The change requests an interviewer throws next.
- How would you add cash on delivery or a wallet payment without changing the checkout code?
- How would you let the user choose 3, 6 or 12 instalments, with a different limit rule for each?
- A user misses an instalment: how do you record it, lower their limit and block new instalment orders?
- How would you make checkout safe when two users buy the last unit at the same moment?
- How would you add cancellation so that stock, the paid amount and the credit limit are all restored?
Where candidates slip
The mistakes this problem punishes.
Writing an if-else on the payment type inside checkout, so each new method edits the same function. Keeping money as floating point rupees, which drifts when a total is split in three. Forgetting that unpaid instalments must count against the limit, which lets a user borrow up to the limit again and again. Taking stock before the payment result is known and not putting it back on failure. Storing the credit limit once instead of computing it from the history. Loading half of a bad import. Putting the limit rule inside the instalment class so it cannot be swapped.
Have a go before you read.
Interviews reward the sketch you make cold. Clarify out loud, draw it, then compare with the answer.
Now make it stick
Where this was reported
Public pages only. Companies do not confirm these, and they rotate their questions, so treat each link as one person's account or one list's tag.