Shopping cart with stackable discounts

Design SwiftKart's cart and pricing: items, coupons and automatic offers that can stack in a fixed order, with exact rounding in paise and a clear price breakdown.

SwiftKart wants a cart that shows the subtotal, every discount applied and the final amount. Support percentage coupons with a cap, flat coupons, and 'buy 2 get 1 free' offers. Some discounts stack and some do not. Marketing adds new offer types every month.

Questions to ask first

  • How many coupons can a user apply? Assume one coupon code plus any automatic offers.
  • Which order do discounts run in? Assume item offers first, then percentage coupons, then flat coupons. Order changes the final amount, so it must be fixed.
  • Can any discount stack with any other? Assume each rule is STACKABLE or EXCLUSIVE. An exclusive rule applies only alone.
  • Can the total go below zero? Assume no. A discount never takes more than what is left to pay.
  • How do we round? Assume amounts in paise, percentage results rounded half up to the nearest paisa.
  • Do delivery fees and taxes matter here? Assume they are added after discounts by another class, out of scope.

Requirements

  • Add, update and remove items in a cart; compute the subtotal.
  • Support percentage coupons with a cap and a minimum order value.
  • Support flat coupons and 'buy X get Y free' offers on a product.
  • Apply discounts in a fixed priority order, honouring stackable and exclusive rules.
  • Return a price breakdown: subtotal, each discount with its label, and the final amount.
  • Add a new discount type without editing the cart or the pricing loop.

Out of scope

  • Coupon usage limits per user across orders (see extensions).
  • Taxes, delivery fees and payment.
  • Inventory and stock reservation.

Entities

  • Cart: items -> addItem(), removeItem(), subtotalPaise()
  • CartItem: product, quantity -> lineTotalPaise()
  • Product: id, name, pricePaise, category
  • DiscountRule (interface) -> priority(), stackPolicy(), isApplicable(), discountPaise()
  • PercentageCoupon, FlatCoupon, BuyXGetYOffer implement DiscountRule
  • PricingEngine -> price(cart, coupon): PriceBreakdown
  • PriceBreakdown: subtotal, adjustments, payable; Adjustment: label, amountPaise

Classes in the model design

  • Cart (class)
  • CartItem (class)
  • Product (class)
  • PricingEngine (class)
  • DiscountRule (interface)
  • PercentageCoupon (class)
  • FlatCoupon (class)
  • BuyXGetYOffer (class)
  • StackPolicy (enum)
  • PriceBreakdown (class)
  • Adjustment (class)

The main use case, step by step

  1. Arjun's cart has 3 packs of milk at ₹30 and a ₹500 basket of fruit. `Cart.subtotalPaise()` returns 59000 paise. He applies coupon SAVE10: 10% off, capped at ₹100.
  2. The app calls `PricingEngine.price(cart, save10)`. The engine joins the coupon with the automatic offers and sorts them by `priority()`.
  3. `BuyXGetYOffer` (buy 2 milk, get 1 free) runs first. It sees 3 milk packs, so it returns 3000 paise. The engine adds an `Adjustment` and the payable amount becomes 56000.
  4. `PercentageCoupon` runs next on the running total. 10% of 56000 is 5600 paise, under the 10000 cap, so the engine adds a second adjustment. Payable is now 50400.
  5. Each step limits the discount to what is left to pay, so the total can never go below zero. An EXCLUSIVE rule is skipped if anything was applied before it.
  6. The engine returns the `PriceBreakdown`. The app shows the subtotal, two labelled discount lines and ₹504 to pay.

Patterns used

  • Strategy: DiscountRule with PercentageCoupon, FlatCoupon and BuyXGetYOffer.. Each discount type has its own condition and its own math. Marketing's new offer next month is one new class. The cart, the engine and the other rules do not change, which is the open/closed principle.
  • Chain of responsibility (a pipeline): PricingEngine passes a running PriceBreakdown through the sorted rules; each rule may add one adjustment.. Order matters: 10% then ₹50 off gives a different total from ₹50 then 10%. A fixed chain makes the order explicit, testable and the same for every user.
  • Value object: Adjustment is an immutable label and amount; all money is a long in paise.. A breakdown built from immutable lines can be shown, logged and stored on the order without anyone changing a line later. Paise avoids floating-point errors.

Key code

The discount contract and a percentage coupon

public enum StackPolicy { STACKABLE, EXCLUSIVE }

public record Adjustment(String label, long amountPaise) {}

public interface DiscountRule {
    int priority();                 // lower runs first: item offers 10, percent 20, flat 30
    StackPolicy stackPolicy();
    boolean isApplicable(Cart cart, PriceBreakdown sofar);
    /** Discount on what is left to pay. The engine caps it at the payable amount. */
    long discountPaise(Cart cart, PriceBreakdown sofar);
    String label();
}

public final class PercentageCoupon implements DiscountRule {
    private final String code;
    private final int basisPoints;      // 1000 = 10%
    private final long capPaise;
    private final long minOrderPaise;

    public PercentageCoupon(String code, int basisPoints, long capPaise, long minOrderPaise) {
        this.code = code; this.basisPoints = basisPoints;
        this.capPaise = capPaise; this.minOrderPaise = minOrderPaise;
    }

    @Override public int priority() { return 20; }
    @Override public StackPolicy stackPolicy() { return StackPolicy.STACKABLE; }
    @Override public String label() { return code; }

    @Override public boolean isApplicable(Cart cart, PriceBreakdown sofar) {
        return cart.subtotalPaise() >= minOrderPaise;
    }

    @Override public long discountPaise(Cart cart, PriceBreakdown sofar) {
        BigDecimal raw = BigDecimal.valueOf(sofar.payablePaise())
                .multiply(BigDecimal.valueOf(basisPoints))
                .divide(BigDecimal.valueOf(10_000), 0, RoundingMode.HALF_UP);
        return Math.min(raw.longValueExact(), capPaise);
    }
}

The pricing engine

public final class PricingEngine {
    private final List<DiscountRule> autoOffers;

    public PricingEngine(List<DiscountRule> autoOffers) {
        this.autoOffers = List.copyOf(autoOffers);
    }

    public PriceBreakdown price(Cart cart, DiscountRule coupon) {
        PriceBreakdown bill = new PriceBreakdown(cart.subtotalPaise());
        List<DiscountRule> rules = new ArrayList<>(autoOffers);
        if (coupon != null) rules.add(coupon);
        rules.sort(Comparator.comparingInt(DiscountRule::priority));

        for (DiscountRule rule : rules) {
            boolean exclusive = rule.stackPolicy() == StackPolicy.EXCLUSIVE;
            if (exclusive && !bill.adjustments().isEmpty()) continue;  // must apply alone
            if (!rule.isApplicable(cart, bill)) continue;
            long off = Math.min(rule.discountPaise(cart, bill), bill.payablePaise());
            if (off <= 0) continue;
            bill.add(new Adjustment(rule.label(), off));
            if (exclusive) break;                                        // nothing stacks on it
        }
        return bill;
    }
}

public final class PriceBreakdown {
    private final long subtotalPaise;
    private final List<Adjustment> adjustments = new ArrayList<>();

    public PriceBreakdown(long subtotalPaise) { this.subtotalPaise = subtotalPaise; }

    void add(Adjustment a) { adjustments.add(a); }

    public long payablePaise() {
        return subtotalPaise - adjustments.stream().mapToLong(Adjustment::amountPaise).sum();
    }

    public List<Adjustment> adjustments() { return List.copyOf(adjustments); }
}

Deep dives

Where the discount logic lives

Weak: Cart.total() has an if for each coupon type: if (coupon.type == PERCENT) ... else if (coupon.type == FLAT) .... Every new offer edits the cart, and a bug in one offer can break checkout for everyone.

Good: A DiscountRule interface with one class per type, and a PricingEngine that loops over rules. The cart only knows items and the subtotal. A new offer is a new class plus a line of setup. This is the strategy pattern.

Great: Do the same, and make the loop a chain: rules sorted by priority, each one reads the running PriceBreakdown and may add one adjustment. Stacking rules sit on each rule, not in the engine. Coupon codes map to rules through a small factory that reads coupon data. The decorator pattern (wrapping a price in layers of discounts) also works, but it hides the order inside object nesting and makes a line-by-line breakdown harder.

Trade-off: A priority number is simple but global: two teams can pick the same number. Some teams use named phases (ITEM, CART, PAYMENT) and order within a phase. Keep it as simple as the offer list needs.

Money and rounding

Weak: double total = subtotal * 0.9. Floating point stores 0.1 inexactly, so 0.1 + 0.2 gives 0.30000000000000004. The app shows one number, the payment gateway gets another, and finance finds paise missing every month.

Good: Use BigDecimal for every amount and round with an explicit RoundingMode. Results are exact. But BigDecimal everywhere is verbose, and two developers can still round at different steps and get different totals.

Great: Store every amount as a long in paise. Use BigDecimal only inside percentage math, with RoundingMode.HALF_UP to a whole paisa, and convert back at once. Round each discount once, when it is computed, never the total again. If a cart discount must be split across items for refunds, share it in proportion and give the leftover paise to the largest line, so the item shares add up to the discount exactly.

Trade-off: Integer paise needs care at the edges: parsing '₹49.50' and formatting for display. A small Money value class (paise plus currency) hides that and stops someone adding rupees to paise, at the cost of one more class.

Stacking and conflicts

Weak: Apply every discount the user and the system offer, in whatever order they come in. Two users with the same cart can pay different amounts, and three offers together can make an item free.

Good: Fixed priority order plus a StackPolicy on each rule. An EXCLUSIVE rule applies only when nothing else has, and nothing runs after it. Every discount is capped at the amount left to pay, so the total never goes negative.

Great: Do the same, and when an exclusive coupon conflicts with stackable offers, price both options and give the user the better one, with a message such as 'SAVE10 gave you more than the milk offer'. Validate the coupon (dates, user limits, minimum order) again at checkout, not only when it is added. Store the final breakdown on the order, so refunds use the same numbers.

Trade-off: Trying both options costs an extra pricing pass and makes the result harder to explain to the support team. With one coupon and a few offers it is cheap. With many coupons, the number of combinations grows fast, so cap how many a user can combine.

Now add one more feature

  • Now limit each coupon to one use per user. What changes? Add a `CouponRepository` that records uses. `isApplicable` checks it when pricing, and checkout marks the coupon used in the same transaction as placing the order, so two parallel checkouts cannot both use it.
  • Now add a bank offer: 10% off with a specific card. What changes? Add a `BankOffer` rule with a later priority. Pricing needs the payment method, so pass a `PricingContext` (cart, user, payment method) to rules instead of the cart alone.
  • Now marketing wants to create offers without a deploy. What changes? Store offer data (type, numbers, priority, dates) in a table. A factory builds rule objects from that data. Only a brand-new kind of rule needs code.
  • Now refund one item from an order with a cart-level discount. What changes? At order time, allocate each cart discount across lines in proportion and store the line shares. The refund returns the line's paid amount, which is exact because the shares add up.

What each level is expected to show

SDE-1: Cart and CartItem with a subtotal in paise, a DiscountRule interface with percentage and flat coupons, and a separate pricing class. The total never goes below zero. A main method shows a cart with one coupon and the breakdown.

SDE-2: Everything at SDE-1, plus item offers, a fixed priority order, stackable and exclusive rules, caps and minimum order checks, and exact rounding with BigDecimal only where needed. Explain strategy versus decorator, and how coupon limits stay correct under parallel checkouts.

Common mistakes

  • Using `double` for prices and discounts.
  • Putting discount `if` blocks inside `Cart`, so every new offer edits the cart.
  • Leaving the order of discounts to chance, so the same cart gives different totals.
  • Letting stacked discounts push the total below zero.
  • Returning only the final number, with no breakdown to show the user or to use for refunds.
  • Rounding at several steps, so the lines on the bill do not add up to the total.

Lessons behind this answer