bugproof-demo-shoplite · case_20260926_db4f
Duplicate DIWALI60 coupon stacks the discount to a negative total
Applying the same promotional coupon code twice caused the system to stack the discount, driving the order total negative and allowing customers to receive products for free. The code change that introduced the bug failed to check whether the coupon was already present in the cart, letting duplicate codes slip through. The fix adds a guard clause that stops duplicate coupon codes from being added, preventing the double discount and preserving accurate order totals.
Written by IBM watsonx.ai ibm/granite-4-h-small from the case evidence.
Reproduced — a new test failed before the fix
The Reproducer confirmed a failing test
AssertionError: expected -49.98 to be greater than or equal to 0
Culprit commit identified
Pinned by git bisect in 5 steps · matches the seeded answer key · found by a follow-up bisect after publishing
185f248 feat(cart): allow stacking multiple coupon codes
Fixed — the repro test and the full suite pass
114 of 114 tests pass after the fix
Reviewed — the Critic approved
coupons.ts: one-line includes() guard directly eliminates root cause — duplicate codes can never enter couponCodes array
APPROVED
Tests untouched — the fix changed no existing test
The fix commit only adds the repro test; no existing test was modified or deleted (git diff of the fix commit)
+ tests/bugproof/bug-08-duplicate-coupon.test.ts
Written by the Fixer, whose edit permission covers src/ only; the check above confirms no existing test changed.
--- a/src/cart/coupons.ts
+++ b/src/cart/coupons.ts
@@ -52,6 +52,7 @@ export function applyCoupon(cart: Cart, input: string): Cart {
const code = normalizeCouponCode(input);
if (!code) return cart;
if (!isKnownCoupon(code)) throw new InvalidCouponError(input.trim());
+ if (cart.couponCodes.includes(code)) return cart;
return { ...cart, couponCodes: [...cart.couponCodes, code] };
}
--- a/src/cart/totals.ts
+++ b/src/cart/totals.ts
@@ -29,7 +29,11 @@ export function computeTotals(cart: Cart): CartTotals {
// Coupons come off the pre-tax subtotal; GST is charged on the list price.
- const discount = cart.couponCodes.reduce((sum, code) => sum + couponDiscount(subtotal, code), 0);
+ // Cap combined discount at subtotal so total can never go negative.
+ const discount = Math.min(
+ cart.couponCodes.reduce((sum, code) => sum + couponDiscount(subtotal, code), 0),
+ subtotal,
+ );The repro test fails on the code before the fix and passes on the fix.
git clone https://github.com/GegarinSagolsem/bugproof-demo-shoplite && cd bugproof-demo-shoplite && npm ci git checkout 0944d88~1 # the code before the fix git checkout 0944d88 -- tests/bugproof/bug-08-duplicate-coupon.test.ts # add the repro test npx vitest run tests/bugproof/bug-08-duplicate-coupon.test.ts # fails: the bug is reproduced git checkout 0944d88 # the fix npx vitest run tests/bugproof/bug-08-duplicate-coupon.test.ts # passes
commit 185f248b3b5952cbc6a4cc80d248542b4d09f7c0
Author: GegarinSagolsem <176033453+GegarinSagolsem@users.noreply.github.com>
Date: Fri Sep 25 18:56:37 2026 +0000
feat(cart): allow stacking multiple coupon codes
---
src/cart/cart.ts | 5 +++--
src/cart/coupons.ts | 5 +++--
src/cart/totals.ts | 2 +-
tests/cart/coupons.test.ts | 12 ++++++++++++
ui/src/cartView.ts | 14 ++++++++++----
5 files changed, 29 insertions(+), 9 deletions(-)
diff --git a/src/cart/cart.ts b/src/cart/cart.ts
index d8ffe37..467f700 100644
--- a/src/cart/cart.ts
+++ b/src/cart/cart.ts
@@ -11,13 +11,14 @@ export interface CartItem {
export interface Cart {
id: string;
items: CartItem[];
- couponCode?: string;
+ /** Normalised coupon codes, in the order they were applied. */
+ couponCodes: string[];
}
let cartSeq = 0;
export function createCart(id: string = `cart-${++cartSeq}`): Cart {
- return { id, items: [] };
+ return { id, items: [], couponCodes: [] };
}
export class UnknownProductError extends Error {
diff --git a/src/cart/coupons.ts b/src/cart/coupons.ts
index 2bf2429..491bd55 100644
--- a/src/cart/coupons.ts
+++ b/src/cart/coupons.ts
@@ -48,12 +48,13 @@ export function couponDiscount(subtotal: number, code?: string): number {
return Math.min(rule.value, subtotal);
}
+/** Adds a coupon to the cart. Coupons stack: each one is computed against the subtotal. */
export function applyCoupon(cart: Cart, input: string): Cart {
const code = normalizeCouponCode(input);
if (code && !isKnownCoupon(code)) throw new InvalidCouponError(input.trim());
- return { ...cart, couponCode: code };
+ return { ...cart, couponCodes: [...cart.couponCodes, code] };
}
export function removeCoupon(cart: Cart): Cart {
- return { ...cart, couponCode: undefined };
+ return { ...cart, couponCodes: [] };
}
diff --git a/src/cart/totals.ts b/src/cart/totals.ts
index 60efd0b..dfab68b 100644
--- a/src/cart/totals.ts
+++ b/src/cart/totals.ts
@@ -27,7 +27,7 @@ export function computeTotals(cart: Cart): CartTotals {
const tax = roundMoney(sumBy(lines, (line) => line.lineTotal * GST_RATE));
// Coupons come off the pre-tax subtotal; GST is charged on the list price.
- const discount = Math.min(couponDiscount(subtotal, cart.couponCode), subtotal);
+ const discount = cart.couponCodes.reduce((sum, code) => sum + couponDiscount(subtotal, code), 0);
const shipping = shippingFor(subtotal, cart.items.length);
const total = fromPaise(toPaise(subtotal) - toPaise(discount) + toPaise(tax) + toPaise(shipping));
diff --git a/tests/cart/coupons.test.ts b/tests/cart/coupons.test.ts
index f50e70a..7fcf8fd 100644
--- a/tests/cart/coupons.test.ts
+++ b/tests/cart/coupons.test.ts
@@ -54,3 +54,15 @@ describe("coupons", () => {
});
});
});
+
+describe("stacked coupons", () => {
+ it("combines a percentage and a flat coupon", () => {
+ const cart = applyCoupon(applyCoupon(speakerCart(), "SAVE10"), "FLAT200");
+ expect(computeTotals(cart).discount).toBe(449.9);
+ });
+
+ it("keeps coupons in the order they were applied", () => {
+ const cart = applyCoupon(applyCoupon(speakerCart(), "save10"), "flat200");
+ expect(cart.couponCodes).toEqual(["SAVE10", "FLAT200"]);
+ });
+});
diff --git a/ui/src/cartView.ts b/ui/src/cartView.ts
index b9970d4..c599c5e 100644
--- a/ui/src/cartView.ts
+++ b/ui/src/cartView.ts
@@ -59,6 +59,10 @@ function row(label: string, value: string, className = ""): HTMLElement[] {
return [el("dt", { className }, label), el("dd", { className }, value)];
}
+function percentOff(discount: number, subtotal: number): string {
+ return subtotal === 0 ? "" : ` (${Math.round((discount / subtotal) * 100)}% off)`;
+}
+
export function renderCart({ cart, onChange }: CartViewProps): void {
const lines = lineItems(cart);
$("#cart-lines").replaceChildren(
@@ -70,7 +74,9 @@ export function renderCart({ cart, onChange }: CartViewProps): void {
const totals = computeTotals(cart);
$("#totals").replaceChildren(
...row(`Subtotal (${totals.itemCount} items)`, formatINR(totals.subtotal)),
- ...(totals.discount !== 0 ? row("Discount", formatDiscount(totals.discount), "discount") : []),
+ ...(totals.discount !== 0
+ ? row(`Discount${percentOff(totals.discount, totals.subtotal)}`, formatDiscount(totals.discount), "discount")
+ : []),
...row(`GST (${GST_RATE_PERCENT}%)`, formatINR(totals.tax)),
...row("Shipping", totals.shipping === 0 ? "Free" : formatINR(totals.shipping)),
...row("Total", formatINR(totals.total), "grand"),
@@ -84,10 +90,10 @@ export function renderCart({ cart, onChange }: CartViewProps): void {
const status = $("#coupon-status");
if (couponError) {
status.replaceChildren(el("span", { className: "error" }, couponError));
- } else if (cart.couponCode) {
+ } else if (cart.couponCodes.length > 0) {
status.replaceChildren(
- el("span", { className: "applied" }, `Coupon ${cart.couponCode} applied`),
- el("button", { type: "button", className: "link", onclick: () => onChange(removeCoupon(cart)) }, "Remove"),
+ ...cart.couponCodes.map((code) => el("span", { className: "chip" }, code)),
+ el("button", { type: "button", className: "link", onclick: () => onChange(removeCoupon(cart)) }, "Remove all"),
);
} else {
status.replaceChildren();
Updated after publishing: a follow-up bisect on 2026-09-26 04:42 UTC identified the culprit 185f248. The Lead wrote the summary below before that.
Bug 08: applying the same coupon code DIWALI60 (60% off) twice stacked the discount to 120%, producing a negative cart total (−₹49.98 on a ₹2,499 cart). Root cause: applyCoupon() in src/cart/coupons.ts appended the code unconditionally without checking for duplicates. Fix 1 (primary, coupons.ts line 56): one-line includes() guard makes re-applying an already-active code a no-op. Fix 2 (defense-in-depth, totals.ts line 31): Math.min(discount, subtotal) cap ensures the total can never go negative regardless of coupon stacking. Repro test tests/bugproof/bug-08-duplicate-coupon.test.ts confirmed RED before fix (AssertionError: expected −49.98 ≥ 0) and GREEN after. Full suite: 114/114 passed, zero regressions. Culprit commit not identified — bisect timed out.