bugproof-demo-shoplite · case_20260926_9afc
Double-clicking Place order creates two orders and charges twice
When a customer clicked the "Place order" button twice very quickly, a second identical order was created, causing the customer to be charged twice. This happened because the order service did not properly prevent two ordering attempts from overlapping and racing for the same open cart. The code was changed to track ordering attempts in progress and block new attempts until the first one finishes, ensuring only one order is created and charged per cart even when two ordering attempts happen at the same time.
Written by IBM watsonx.ai ibm/granite-4-h-small from the case evidence.
Reproduced — a new test failed before the fix
1 of 1 repro test failed on the unfixed code
AssertionError: expected 2 to be 1 // Object.is equality
Culprit commit identified
Pinned by git bisect in 6 steps · matches the seeded answer key · found by a follow-up bisect after publishing
985e247 refactor(orders): derive duplicate-order check from order history
Fixed — the repro test and the full suite pass
118 of 118 tests pass after the fix
Reviewed — the Critic approved
Test correctly encodes the race condition: FakePaymentGateway(10) forces both concurrent calls past the synchronous guard before either resolves; assertions on orders.length, charges.length, and fulfilled.length all map to the three symptoms documented in the bug report.
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-07-double-order.test.ts
Written by the Fixer, whose edit permission covers src/ only; the check above confirms no existing test changed.
diff --git a/src/orders/service.ts b/src/orders/service.ts
index 463e9ca..02765f0 100644
--- a/src/orders/service.ts
+++ b/src/orders/service.ts
@@ -36,6 +36,7 @@ export interface OrderServiceOptions {
export class OrderService {
private readonly orders: Order[] = [];
+ private readonly pending = new Set<string>();
private readonly payments: PaymentGateway;
private readonly clock: () => Date;
private seq = 0;
@@ -49,9 +50,16 @@ export class OrderService {
async placeOrder(cart: Cart): Promise<Order> {
if (cart.items.length === 0) throw new EmptyCartError();
if (this.hasOrderFor(cart.id)) throw new DuplicateOrderError(cart.id);
+ if (this.pending.has(cart.id)) throw new DuplicateOrderError(cart.id);
+ this.pending.add(cart.id);
const totals = computeTotals(cart);
const placedAt = this.clock();
- const { paymentId } = await this.payments.charge(totals.total, cart.id);
+ let paymentId: string;
+ try {
+ ({ paymentId } = await this.payments.charge(totals.total, cart.id));
+ } finally {
+ this.pending.delete(cart.id);
+ }
const order: Order = {
id: `ORD-${String(++this.seq).padStart(4, "0")}`,
cartId: cart.id,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 0f5546f~1 # the code before the fix git checkout 0f5546f -- tests/bugproof/bug-07-double-order.test.ts # add the repro test npx vitest run tests/bugproof/bug-07-double-order.test.ts # fails: the bug is reproduced git checkout 0f5546f # the fix npx vitest run tests/bugproof/bug-07-double-order.test.ts # passes
commit 985e24791e05902a1b472644aeebeacf2c38c3f0
Author: GegarinSagolsem <176033453+GegarinSagolsem@users.noreply.github.com>
Date: Fri Sep 25 18:56:15 2026 +0000
refactor(orders): derive duplicate-order check from order history
---
src/orders/service.ts | 37 ++++++++++++++++---------------------
1 file changed, 16 insertions(+), 21 deletions(-)
diff --git a/src/orders/service.ts b/src/orders/service.ts
index 98566e1..463e9ca 100644
--- a/src/orders/service.ts
+++ b/src/orders/service.ts
@@ -36,7 +36,6 @@ export interface OrderServiceOptions {
export class OrderService {
private readonly orders: Order[] = [];
- private readonly inFlight = new Map<string, Promise<Order>>();
private readonly payments: PaymentGateway;
private readonly clock: () => Date;
private seq = 0;
@@ -46,27 +45,10 @@ export class OrderService {
this.clock = options.clock ?? (() => new Date());
}
- /** Places an order for the cart. Concurrent calls for the same cart share one submission. */
- placeOrder(cart: Cart): Promise<Order> {
- const pending = this.inFlight.get(cart.id);
- if (pending) return pending;
- const submission = this.submit(cart).finally(() => this.inFlight.delete(cart.id));
- this.inFlight.set(cart.id, submission);
- return submission;
- }
-
- /** All orders, newest first. */
- listOrders(): Order[] {
- return [...this.orders].sort((a, b) => b.placedAt.localeCompare(a.placedAt) || b.id.localeCompare(a.id));
- }
-
- getOrder(id: string): Order | undefined {
- return this.orders.find((order) => order.id === id);
- }
-
- private async submit(cart: Cart): Promise<Order> {
+ /** Places an order for the cart. A cart can only be ordered once. */
+ async placeOrder(cart: Cart): Promise<Order> {
if (cart.items.length === 0) throw new EmptyCartError();
- if (this.orders.some((order) => order.cartId === cart.id)) throw new DuplicateOrderError(cart.id);
+ if (this.hasOrderFor(cart.id)) throw new DuplicateOrderError(cart.id);
const totals = computeTotals(cart);
const placedAt = this.clock();
const { paymentId } = await this.payments.charge(totals.total, cart.id);
@@ -82,4 +64,17 @@ export class OrderService {
this.orders.push(order);
return order;
}
+
+ /** All orders, newest first. */
+ listOrders(): Order[] {
+ return [...this.orders].sort((a, b) => b.placedAt.localeCompare(a.placedAt) || b.id.localeCompare(a.id));
+ }
+
+ getOrder(id: string): Order | undefined {
+ return this.orders.find((order) => order.id === id);
+ }
+
+ private hasOrderFor(cartId: string): boolean {
+ return this.orders.some((order) => order.cartId === cartId);
+ }
}
Updated after publishing: a follow-up bisect on 2026-09-26 13:22 UTC identified the culprit 985e247. The Lead wrote the summary below before that.
Race condition in OrderService.placeOrder() allowed two concurrent requests for the same cart to both pass the hasOrderFor() guard before either payment resolved, creating duplicate orders and charges. Fixed by adding a `private readonly pending = new Set()` guard: checked synchronously before the payment await, populated before the await, and cleared in a finally block. The repro test (tests/bugproof/bug-07-double-order.test.ts) fires two concurrent placeOrder calls with a 10ms payment delay to force the race window, asserting exactly 1 order and 1 charge. Test suite went from 117 pass / 1 fail → 118 pass / 0 fail. Bisect was attempted but the culprit commit could not be identified due to module resolution failures in the git worktree.