Example source
One order. Two charges.
Find the failure, then fix it.
The payment succeeds. Its response is lost. Checkout retries with a fresh payment key and charges the customer again.
The jury responses and judge narrative are authored sample data, not a live five-model evaluation. The regression results are measured locally against a memory-only payment double. No real payment or model provider is called.
Run the same checks before and after.
A deterministic local payment double records a charge before dropping its response. No payment service or credentials are needed.
node examples/checkout-retry/verify.mjs| Implementation | Passed | Failed |
|---|---|---|
| Before | 2 | 11 |
| After | 13 | 0 |
The suite covers lost responses, concurrent requests, repeated events, distinct events for one payment, late failures, changed amounts and a separate purchase.
Download the source and test results ↗Illustrative jury
Agreement is useful. So is an objection.
These five positions are authored to explain the workflow. They are not recorded outputs from five model providers.
- Model A
Add server-side idempotency and prevent repeat clicks. After a timeout, allow a fresh payment attempt so checkout can recover.
- Model B
Keep duplicate protection in the API, not only the browser. A fresh attempt after timeout seems recoverable, but repeated events also need a payment-level guard.
- Model C
A new retry attempt looks reasonable, provided events cannot fulfill the same order twice. Add concurrent-request and event-replay tests.
- Model D
Agree on server-side protection; reject the fresh retry key. The provider records the charge before the response is lost. A new attempt charges again. Keep the original attempt and parameters.
- Model E
Keep the original attempt after an ambiguous timeout. Event-ID deduplication alone is insufficient: different events can describe the same payment. Preserve paid state and fulfill once.
Does a timeout justify a new payment attempt?
Keep D and E's objection. The lost-response regression records a payment before throwing. A new attempt produces a second charge; reusing the original attempt passes the regression.
Deduplicate events or the payment outcome?
Keep both layers: deduplicate event IDs and make the paid/fulfilled transition idempotent. Two distinct events for the same payment must still produce one fulfillment.
Five files. One coherent change.
The complete before and after versions are in the download. Inspect the proposed files below.
client/checkout.mjs
export function createCheckoutController(submit) {
let state = 'idle';
let busy = false;
return {
get state() { return state; },
async pay(orderId) {
if (busy) return;
busy = true;
state = 'submitting';
try {
await submit(orderId);
state = 'paid';
} catch (error) {
// A lost response does not tell us whether the payment succeeded.
state = error.code === 'NETWORK_ERROR' ? 'processing' : 'failed';
} finally {
busy = false;
}
},
};
}
server/checkout.mjs
import { getPaymentAttempt } from './payment-attempts.mjs';
export function createCheckout({ orders, attempts, provider }) {
return async function checkout(orderId) {
const order = orders.get(orderId);
const attempt = getPaymentAttempt(attempts, order);
const payment = await provider.charge({
orderId, amount: attempt.amount, currency: attempt.currency,
idempotencyKey: attempt.key,
});
orders.completeOnce(orderId, payment.id);
return payment;
};
}
server/payment-attempts.mjs
export function getPaymentAttempt(attempts, order) {
const existing = attempts.get(order.id);
if (existing) {
if (existing.amount !== order.amount || existing.currency !== order.currency) {
throw new Error('Reconcile the pending payment before changing this order');
}
return existing;
}
const attempt = {
key: `checkout:${order.id}`,
amount: order.amount,
currency: order.currency,
};
// Atomic within this memory-only demo. Production requires durable storage
// and a unique order constraint, including across processes and restarts.
attempts.set(order.id, attempt);
return attempt;
}
server/payment-webhook.mjs
export function createWebhook({ orders, attempts }) {
const handledEvents = new Set();
return function handle(event) {
if (handledEvents.has(event.id)) return;
const payment = event.payment;
const attempt = attempts.get(payment.orderId);
if (!attempt || attempt.key !== payment.idempotencyKey
|| attempt.amount !== payment.amount || attempt.currency !== payment.currency) return;
// Treat a signed event as evidence of this attempt, not a new payment.
// The real adapter must verify signatures before reaching this handler.
if (event.type === 'payment.succeeded') {
orders.completeOnce(payment.orderId, payment.id);
}
// Late or failed events must never move a paid order backwards.
handledEvents.add(event.id);
};
}
test/checkout.test.mjs
import test from 'node:test';
import assert from 'node:assert/strict';
import { pathToFileURL } from 'node:url';
import path from 'node:path';
import { createProvider } from '../support/provider.mjs';
import { createOrders } from '../support/orders.mjs';
const root = process.env.CHECKOUT_IMPLEMENTATION_ROOT
? pathToFileURL(path.resolve(process.env.CHECKOUT_IMPLEMENTATION_ROOT) + path.sep)
: new URL('../', import.meta.url);
const { createCheckout } = await import(new URL('server/checkout.mjs', root));
const { createWebhook } = await import(new URL('server/payment-webhook.mjs', root));
const { createCheckoutController } = await import(new URL('client/checkout.mjs', root));
function setup() {
const orders = createOrders();
orders.add('order-1');
const provider = createProvider();
const deps = { orders, provider, attempts: new Map() };
return { ...deps, checkout: createCheckout(deps), webhook: createWebhook(deps) };
}
const eventFor = (payment, id = 'event-1', type = 'payment.succeeded') => ({ id, type, payment });
test('normal checkout charges once and fulfills once', async () => {
const app = setup();
await app.checkout('order-1');
assert.equal(app.provider.charges.length, 1);
assert.equal(app.orders.get('order-1').fulfillments, 1);
});
test('lost response then retry preserves the original payment', async () => {
const app = setup();
app.provider.loseNextResponse();
await assert.rejects(app.checkout('order-1'), { code: 'NETWORK_ERROR' });
const result = await app.checkout('order-1');
assert.equal(app.provider.charges.length, 1);
assert.equal(result.id, app.provider.charges[0].id);
});
test('concurrent requests for one order produce one charge', async () => {
const app = setup();
await Promise.all([app.checkout('order-1'), app.checkout('order-1')]);
assert.equal(app.provider.charges.length, 1);
assert.equal(app.orders.get('order-1').fulfillments, 1);
});
test('client blocks a second submission while the first is pending', async () => {
let calls = 0;
let release;
const pending = new Promise((resolve) => { release = resolve; });
const client = createCheckoutController(async () => { calls += 1; await pending; });
const first = client.pay('order-1');
const second = client.pay('order-1');
release();
await Promise.all([first, second]);
assert.equal(calls, 1);
});
test('client treats an ambiguous timeout as processing, then retries safely', async () => {
const app = setup();
const client = createCheckoutController(app.checkout);
app.provider.loseNextResponse();
await client.pay('order-1');
assert.equal(client.state, 'processing');
await client.pay('order-1');
assert.equal(client.state, 'paid');
assert.equal(app.provider.charges.length, 1);
});
test('duplicate webhook delivery does not fulfill twice', async () => {
const app = setup();
const payment = await app.checkout('order-1');
app.webhook(eventFor(payment));
app.webhook(eventFor(payment));
assert.equal(app.orders.get('order-1').fulfillments, 1);
});
test('distinct events for the same payment do not fulfill twice', async () => {
const app = setup();
const payment = await app.checkout('order-1');
app.webhook(eventFor(payment, 'event-1'));
app.webhook(eventFor(payment, 'event-2'));
assert.equal(app.orders.get('order-1').fulfillments, 1);
});
test('a late failure event cannot undo a paid order', async () => {
const app = setup();
const payment = await app.checkout('order-1');
app.webhook(eventFor(payment, 'late-event', 'payment.failed'));
assert.equal(app.orders.get('order-1').status, 'paid');
});
test('a webhook from another attempt is ignored', async () => {
const app = setup();
app.provider.loseNextResponse();
await assert.rejects(app.checkout('order-1'));
app.webhook(eventFor({ ...app.provider.charges[0], idempotencyKey: 'another-attempt' }));
assert.equal(app.orders.get('order-1').status, 'pending');
});
test('a changed amount cannot silently create a fresh payment after timeout', async () => {
const app = setup();
app.provider.loseNextResponse();
await assert.rejects(app.checkout('order-1'));
app.orders.get('order-1').amount = 9900;
await assert.rejects(app.checkout('order-1'), /Reconcile/);
assert.equal(app.provider.charges.length, 1);
});
test('a separate order can still make a separate purchase', async () => {
const app = setup();
app.orders.add('order-2');
await app.checkout('order-1');
await app.checkout('order-2');
assert.equal(app.provider.charges.length, 2);
assert.notEqual(app.provider.charges[0].idempotencyKey, app.provider.charges[1].idempotencyKey);
});
test('webhook after a lost response reconciles without a second charge', async () => {
const app = setup();
app.provider.loseNextResponse();
await assert.rejects(app.checkout('order-1'));
app.webhook(eventFor(app.provider.charges[0]));
await app.checkout('order-1');
assert.equal(app.provider.charges.length, 1);
assert.equal(app.orders.get('order-1').fulfillments, 1);
});
test('a mismatched webhook amount cannot mark an order paid', async () => {
const app = setup();
app.provider.loseNextResponse();
await assert.rejects(app.checkout('order-1'));
app.webhook(eventFor({ ...app.provider.charges[0], amount: 1 }));
assert.equal(app.orders.get('order-1').status, 'pending');
});
What this example proves.
The revised implementation passes these 13 local regressions. The memory-only store does not demonstrate durability across processes or restarts. This is a small teaching example, not a production payment integration.
A production implementation also needs durable attempts, atomic order and fulfillment transitions, ownership checks, verified event signatures and provider-specific reconciliation, including expired idempotency keys.
The scenario follows Stripe’s network-error guidance and webhook delivery guidance.
