Code Review Expert
Performs a rigorous senior-level code review covering correctness, design, performance, and security with prioritized, actionable fixes.
Example run
Generated by Claude Opus 5.5 on September 23, 2026
Inputs used
- Programming language
- TypeScript
- Framework or stack
- Node.js 18 + Express 4, PostgreSQL via the `pg` driver, Stripe SDK for payments
- Project context
- This is the `POST /api/checkout` route handler for a small e-commerce backend (a bootstrapped online store selling handmade candles, ~200 orders/day). The endpoint receives a cart of items and an optional discount code from the frontend, validates the cart against current inventory in Postgres, calculates the total (applying the discount if valid), charges the customer's card through Stripe, decrements inventory for each purchased item, and inserts an `orders` row. It's called directly from the React checkout page after the user clicks 'Place Order'. The team is about to run a Black Friday promotion next month and is worried about correctness and abuse under concurrent load, so this handler is getting extra scrutiny before then. The reviewer (vongu3@gmail.com) is the backend lead who wrote the original inventory service last year and is now reviewing a PR from a newer teammate.
- Review focus, e.g. correctness, security, performance, readability
- Primary focus is correctness and security: we need to know whether the discount-code lookup and total calculation are safe from SQL injection and from a malicious client sending a manipulated price/total in the request body, whether the inventory check-then-decrement sequence can be exploited by concurrent requests to oversell stock during a flash-sale spike, and whether the Stripe charge and the database writes are properly wrapped so we never charge a card without recording the order (or vice versa). Secondary focus is performance (N+1 queries against inventory) and readability, since this handler will be extended soon with gift-card support and needs to stay maintainable.
- Code to review
- import { Router, Request, Response } from 'express'; import { Pool } from 'pg'; import Stripe from 'stripe'; const router = Router(); const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const stripe = new Stripe('sk_live_51Hxxxxxxxxxxxxxxxxxxxxxxxx'); router.post('/checkout', async (req: Request, res: Response) => { const { items, discountCode, cardToken, total } = req.body; let discount = 0; if (discountCode) { const query = `SELECT percent_off FROM discounts WHERE code = '${discountCode}' AND active = true`; const result = await pool.query(query); if (result.rows.length > 0) { discount = result.rows[0].percent_off; } } for (const item of items) { const stock = await pool.query( 'SELECT quantity FROM inventory WHERE product_id = $1', [item.productId] ); if (stock.rows[0].quantity < item.qty) { return res.status(400).json({ error: 'Item out of stock' }); } } const charge = await stripe.charges.create({ amount: Math.round(total * 100), currency: 'usd', source: cardToken, description: 'Candle order', }); for (const item of items) { await pool.query( 'UPDATE inventory SET quantity = quantity - $1 WHERE product_id = $2', [item.qty, item.productId] ); } const orderId = Math.floor(Math.random() * 1000000); await pool.query( 'INSERT INTO orders (id, items, total, discount_code, charge_id) VALUES ($1, $2, $3, $4, $5)', [orderId, JSON.stringify(items), total, discountCode, charge.id] ); res.json({ success: true, orderId }); }); export default router;
Code Review: POST /api/checkout
Summary
This handler can't safely take real money in its current state, and it isn't ready for Black Friday. There are four independent critical problems:
- SQL injection in the discount lookup.
- A client-controlled charge amount. The server charges whatever
totalthe browser sends, and the computeddiscountis never used. - A live Stripe secret key committed in source.
- A check-then-act race that will oversell stock under concurrent load.
On top of that, the Stripe charge and the DB writes are not coordinated, and the handler has no error handling. Any failure after the charge, including a likely order-ID collision, leaves a customer charged with no order recorded.
The core fix is structural. The server must own pricing. Inventory must be reserved atomically in a short transaction. The charge must be idempotent, with a documented recovery path. A corrected reference implementation is at the end.
Issues
1. Live Stripe secret key hardcoded in source
- Severity: Critical
- Location: module scope,
new Stripe('sk_live_51Hxxx...') - Problem: A live secret key in source control gives anyone with repo access (current or former teammates, CI logs, a leaked laptop, a future accidental public push) full control of the Stripe account. That includes issuing refunds, reading customer data, and creating charges. It's also in git history now, so deleting the line doesn't fix it.
- Fix:
- Roll the key in the Stripe dashboard immediately. Treat it as compromised regardless of whether the repo is private.
- Load the key from the environment and fail fast at boot if it's missing. Consider a restricted key scoped to PaymentIntents only.
- Add a secret scanner (gitleaks, trufflehog, or GitHub secret scanning) to CI.
const stripeKey = process.env.STRIPE_SECRET_KEY; if (!stripeKey) throw new Error('STRIPE_SECRET_KEY is not set'); const stripe = new Stripe(stripeKey, { apiVersion: '2024-06-20' }); // pin the version
2. SQL injection in the discount-code lookup
-
Severity: Critical
-
Location:
const query = `SELECT percent_off FROM discounts WHERE code = '${discountCode}' ...` -
Problem:
discountCodecomes straight fromreq.bodyand is interpolated into SQL. Three concrete attacks:discountCode: "' OR 1=1 --"returns the first discount row, active or not."x' UNION SELECT 100 --"produces a 100% discount once the discount is actually applied (see #3).- Depending on DB role privileges, stacked or time-based payloads can exfiltrate or modify data.
discountCodealso doesn't have to be a string. Express's JSON parser will happily pass an object or array, which makes the interpolation unpredictable. -
Fix: Parameterize the query, as the inventory queries already do, and validate the type and length first.
const { rows } = await client.query<{ percent_off: string }>( 'SELECT percent_off FROM discounts WHERE code = $1 AND active = true', [discountCode] );Also make sure the app's DB role has only the privileges it needs (no DDL, no superuser), so a future injection bug has limited blast radius.
3. Client controls the charge amount; the discount is computed but never applied
-
Severity: Critical
-
Location:
const { ..., total } = req.body;→amount: Math.round(total * 100) -
Problem: The amount charged is exactly what the browser sends. Anyone with DevTools can post
total: 0.50for a $200 cart and get it. Nothing on the server connectstotalto the items, and no product prices are looked up anywhere.Separately,
discountis fetched and then never used. So either the frontend computes the discounted total (making the server lookup pointless) or discounts silently do nothing. Either way,discount_codegets written to the order even when the code was invalid, which will corrupt promo reporting.Other edge cases:
totalcan be negative,NaN, or a string ("abc" * 100isNaN, and Stripe rejects it only after the stock checks have run).- Floating-point money:
19.99 * 3is59.97000000000001.
-
Fix:
- Remove
totalfrom the request contract entirely. The server computes it from authoritative prices. - Keep all money as integer cents.
- Validate
percent_offis within[0, 100], since a bad row shouldn't produce a negative charge. - Record only codes that were actually applied.
I'm assuming prices live in a
productstable or oninventory(e.g.price_cents). The snippet doesn't show where prices come from, and that gap is part of the problem.const subtotalCents = lines.reduce((s, l) => s + l.priceCents * l.qty, 0); const pct = discount ? Number(discount.percent_off) : 0; // pg returns NUMERIC as string if (!(pct >= 0 && pct <= 100)) throw new Error(`Invalid percent_off for ${discountCode}`); const discountCents = Math.round((subtotalCents * pct) / 100); const totalCents = subtotalCents - discountCents;If the frontend wants to show a total before submit, add a
POST /api/checkout/quoteendpoint that uses the same server-side function. Never accept the number back. - Remove
4. Check-then-decrement race lets concurrent requests oversell stock
-
Severity: Critical (for a flash sale specifically)
-
Location: the
SELECT quantityloop, followed later by theUPDATE ... quantity - $1loop -
Problem: This is a textbook TOCTOU (time-of-check to time-of-use) bug. Consider a candle with 1 unit left and 20 concurrent requests:
- All 20
SELECTand seequantity = 1. - All 20 pass the check.
- All 20 get charged.
- All 20 decrement, leaving
quantity = -19.
The window between check and update also includes a Stripe network round trip (hundreds of ms), which makes it huge. At Black Friday concurrency this is near-certain to happen.
The same bug can be triggered by a single request:
- Duplicate lines:
items: [{productId: 7, qty: 1}, {productId: 7, qty: 1}]with stock 1 passes, because each line is checked against full stock independently. - Negative quantities:
qty: -50passesquantity < qty, thenquantity - (-50)adds 50 units of phantom inventory.
- All 20
-
Fix: Merge duplicate lines, validate
qtyas a positive integer, and make the check and the decrement a single atomic operation. Either of these works:- A conditional update:
UPDATE ... SET quantity = quantity - $1 WHERE product_id = $2 AND quantity >= $1, then verify the rowcount. - Row locks inside a transaction:
SELECT ... FOR UPDATE.
With multi-item carts, take the locks in a consistent order (
ORDER BY product_id). Otherwise two carts{A, B}and{B, A}can deadlock each other.// Inside a transaction: const { rows } = await client.query( `SELECT i.product_id, i.quantity, p.price_cents FROM inventory i JOIN products p ON p.id = i.product_id WHERE i.product_id = ANY($1::int[]) ORDER BY i.product_id FOR UPDATE OF i`, [productIds] ); // ...verify every requested product exists and has enough stock, then: await client.query( `UPDATE inventory i SET quantity = i.quantity - c.qty FROM unnest($1::int[], $2::int[]) AS c(product_id, qty) WHERE i.product_id = c.product_id`, [productIds, qtys] );Also add
CHECK (quantity >= 0)oninventory.quantityas a last line of defense. With it, an oversell becomes a failed transaction instead of silent negative stock. - A conditional update:
5. The Stripe charge and the DB writes aren't coordinated, so money and orders can diverge
-
Severity: Critical
-
Location: everything after
stripe.charges.create(...) -
Problem: The sequence is: charge, then N separate updates, then insert, with no transaction and no compensation. Any failure after the charge leaves a paid customer with no order and no inventory decrement. Possible causes include a DB blip, a pool timeout, a process restart during deploy, or the order-ID collision in #7.
Because each
UPDATEis its own autocommit statement, a failure midway also leaves inventory partially decremented. In the other direction, if the charge fails, nothing is recorded at all, which is fine but gives you no audit trail.You also can't just wrap the Stripe call inside a DB transaction. That would hold row locks on your hottest products for the full Stripe round trip, which serializes the flash sale and invites lock timeouts. And a DB commit failure after a successful charge would still leave you charged-but-not-recorded.
-
Fix: Use a reserve → charge → finalize pattern with short transactions and an idempotency key:
- Tx 1 (short): Validate, lock and decrement inventory, and insert the order with
status = 'pending'and the server-computedtotal_cents. Commit. Stock is now reserved. - Charge via a Stripe PaymentIntent with
idempotencyKey = order.idandmetadata.order_id. Retries can't double-charge. - Tx 2: On success, set
status = 'paid'and storepayment_intent_id. On a definitive failure (decline), mark the orderfailedand restore inventory. - Recovery:
- A Stripe webhook (
payment_intent.succeeded/payment_failed) is the source of truth. It covers crashes between steps 2 and 3. - A small reconciliation job sweeps
pendingorders older than ~15 minutes. It checks Stripe by metadata, then finalizes, or releases the stock.
- A Stripe webhook (
An alternative is
capture_method: 'manual': authorize first, commit the order, then capture. That's also valid. The key point is to have a documented answer for what happens if the process dies right after Stripe says yes.Also,
charges.createwithsource: cardTokenis Stripe's legacy API. It doesn't support SCA/3-D Secure, which many cards now require, especially EU cards. Migrate to PaymentIntents with apayment_methodid. Arequires_actionstatus then has to go back to the frontend with theclient_secret. - Tx 1 (short): Validate, lock and decrement inventory, and insert the order with
6. No error handling: async rejections hang requests and crash on bad input
- Severity: High
- Location: the whole handler
- Problem: Express 4 does not catch rejected promises from
asynchandlers. Any thrown error becomes an unhandled rejection, and the client's request hangs until timeout. On Node 15+ with default settings, an unhandled rejection terminates the process, taking down every in-flight checkout. Concrete triggers:- An unknown
productIdmakesstock.rows[0]undefined, so.quantitythrows a TypeError. - A missing or non-array
itemsmakesfor...ofthrow. - A card decline makes Stripe throw
StripeCardError, which is the most common failure in normal use. - Any DB error.
- An unknown
- Fix: Wrap the handler (or use
express-async-errors, or upgrade to Express 5) and map known errors to proper statuses. Declines → 402, stock conflicts → 409, validation → 400, everything else → 500 with a logged correlation id. Don't leak internal error text to the client.
7. Random order IDs will collide within days and are guessable
- Severity: High
- Location:
const orderId = Math.floor(Math.random() * 1000000); - Problem: By the birthday bound, with 1,000,000 possible IDs a collision is likely (~50%) after only about 1,180 orders. At 200 orders a day that's about 6 days, much sooner on Black Friday. Collisions will already have happened unless something else prevents them. Once a primary-key violation happens it fires after the card was charged (see #5). The IDs are also small and enumerable, which matters if any endpoint looks up orders by id.
- Fix: Let Postgres generate the key (
id bigint GENERATED ALWAYS AS IDENTITY, oruuid DEFAULT gen_random_uuid()) and useRETURNING id. If you need a customer-facing order number, make it a separate column.
8. No idempotency: a double-click or retry causes a double charge
- Severity: High
- Location: request contract
- Problem: "Place Order" double-clicks, a flaky mobile connection retrying, or the React app re-submitting after a timeout will each create a second charge and a second order. Under Black Friday load, with slow responses, users will click twice.
- Fix: The client generates a UUID when the checkout page loads and sends it as
idempotencyKey. Store it onorderswith aUNIQUEconstraint. On conflict, return the existing order's result instead of processing again. Disable the button on the frontend too, but don't rely on that.
9. No input validation on the request body
- Severity: High
- Location:
const { items, discountCode, cardToken, total } = req.body; - Problem: Nothing checks shape, types, or bounds. Several of the issues above come directly from this: negative or fractional
qty, duplicate lines, non-stringdiscountCode, an emptyitemsarray (which currently chargestotalfor nothing), and unbounded array length (a 100k-item cart is effectively a DoS). - Fix: Validate at the boundary with a schema library, then normalize by merging duplicates.
const CheckoutBody = z.object({ items: z.array(z.object({ productId: z.number().int().positive(), // assumption: integer product ids qty: z.number().int().min(1).max(20), })).min(1).max(50), discountCode: z.string().trim().min(1).max(64).optional(), paymentMethodId: z.string().startsWith('pm_'), idempotencyKey: z.string().uuid(), }).strict();
10. N+1 queries against inventory
- Severity: Medium
- Location: both
for (const item of items)loops - Problem: The handler makes 2N+2 sequential round trips per checkout. The individual queries are cheap, but each one holds a pool connection (default pool size is 10) and adds latency. Under a spike this is what exhausts the pool, and once locking is added (#4), longer transactions mean longer lock hold times.
- Fix: Use one
SELECT ... WHERE product_id = ANY($1) ORDER BY product_id FOR UPDATEand one set-basedUPDATE ... FROM unnest(...), as in #4. That's a constant number of queries regardless of cart size.
11. The discount system is open to abuse
-
Severity: Medium
-
Location: discount lookup
-
Problem: Even with injection fixed:
- Codes can be brute-forced, because nothing rate-limits attempts.
- There's no concept of expiry, max redemptions, or per-customer limits. The schema isn't shown, so I'm assuming only
code,percent_off, andactiveexist. - An invalid code is silently ignored but still stored on the order.
For a Black Friday promo code that gets shared on coupon sites, usage caps usually matter.
-
Fix:
- Rate-limit
/checkout, and any future/discounts/validateendpoint, per IP and per session. - Return an explicit 400 for an invalid code rather than silently charging full price. Users should know their code didn't apply.
- If you add
max_redemptions, increment it atomically inside Tx 1:UPDATE discounts SET redemptions = redemptions + 1 WHERE code = $1 AND redemptions < max_redemptions RETURNING percent_off. Otherwise it has the same race as inventory. - Decide and document case sensitivity, e.g. normalize with
upper().
- Rate-limit
12. Order rows store client-supplied data and omit key facts
- Severity: Medium
- Location:
INSERT INTO orders ... [orderId, JSON.stringify(items), total, discountCode, charge.id] - Problem:
itemsis the raw client payload, which can include arbitrary extra fields. There's no price snapshot, so you can't later reconstruct what the customer paid per item after prices change.totalis the client's number. There's no status, customer or email, or timestamp (unless the DB defaults one). None of this is visible in the snippet, but I'd flag it for refunds and support. - Fix: Store server-computed lines (
product_id,qty,unit_price_cents) in anorder_itemstable or a validated JSONB column, plussubtotal_cents,discount_cents,total_cents,status,payment_intent_id, andidempotency_key.
13. Maintainability: one function does everything
-
Severity: Medium
-
Location: whole handler
-
Problem: Validation, pricing, discounting, inventory, payment, and persistence are all inline in the route. Gift-card support is coming next, and it touches pricing (a second deduction), payment (a split tender, possibly with a $0 Stripe charge), and compensation (restoring the gift-card balance on failure). Adding it to this structure will make it untestable. Module-level
PoolandStripeconstruction also makes unit testing require real infrastructure. -
Fix: Split the work into:
validate: a schemapriceCart(lines, discount): a pure function, trivially unit-testable, and where gift cards plug inreserveInventory(client, lines)chargePayment(order)finalizeOrder/releaseOrder
Inject
poolandstripe(or at least import them from a shared module) so tests can substitute fakes.
14. Minor issues
- Severity: Low
- Location: various
- Problem / Fix:
new Pool(...)inside a route module creates a separate pool per module that does this. Share one pool app-wide.- Return
201 Createdfor a new order. The error body should say which item is short ({ error: 'insufficient_stock', productId }) so the UI can show a useful message. currency: 'usd'anddescriptionare fine hardcoded for now, but put them in config before international shipping comes up.- Pin the Stripe
apiVersionso SDK upgrades don't silently change behavior. - Stripe rejects charges under $0.50. Decide what happens when a discount (or later a gift card) brings the total below that.
Strengths
- The inventory queries are parameterized (
$1,$2). That's the right habit, and the fix for #2 is just to apply it consistently. - The decrement is relative (
quantity = quantity - $1) rather than read-modify-write in JS (SET quantity = ${newQty}). That avoids one class of lost-update bug and is the right foundation for the atomic version. - The stock check happens before the charge, which shows the right intent. It just needs to be atomic.
charge_idis persisted on the order, which is essential for refunds and reconciliation. Keep that (aspayment_intent_id).Math.round(total * 100)shows awareness that Stripe wants integer minor units. Push that further and keep cents everywhere.- The handler is short and linear, so the intended flow is easy to follow, which made these issues easy to spot.
Recommendations (prioritized)
- Today, before anything else: Roll the Stripe live key, move it to an env var, and add secret scanning to CI.
- Before merge (blocking): Fix the SQL injection (#2), remove client
totaland compute prices server-side (#3), add schema validation (#9), and add async error handling (#6). Each is small and independently shippable. - Before Black Friday (blocking): Implement the reserve → charge → finalize flow:
- atomic, lock-ordered inventory reservation (#4)
- DB-generated IDs (#7)
- idempotency keys (#8)
- PaymentIntents with an idempotency key, plus a webhook and a reconciliation job (#5)
- the
CHECK (quantity >= 0)constraint as a backstop
- Load-test the concurrency fix. Seed a product with stock 10, fire 100 parallel checkouts with Stripe test mode or a mocked payment layer, and assert exactly 10 succeed, stock ends at 0, and no rows are negative. Include a mixed-order multi-item scenario to shake out deadlocks. This single test would have caught #4 and will guard against regressions.
- Before gift cards: Extract
priceCartas a pure function and the payment step behind an interface. Gift cards then become one more deduction in pricing and one more compensating action inreleaseOrder. - Add tests for these cases:
- Security: injection strings in
discountCode, a manipulatedtotalbeing ignored, a non-stringdiscountCode. - Input handling: negative, zero, or fractional
qty; duplicate product lines; an unknownproductId; an empty cart. - Discounts: invalid or inactive codes;
percent_offof 0, 100, or out of range. - Payment failures: a Stripe decline releases inventory and marks the order failed; a Stripe timeout followed by a retry with the same idempotency key does not double-charge.
- Persistence failures: a DB failure after a successful charge is recovered by the webhook or reconciliation job.
- Idempotency: a replayed request returns the original order.
- Concurrency: the load test above.
- Security: injection strings in
- Operational: Add rate limiting on
/checkout, structured logging with order id and payment intent id on every step, and alerting onpendingorders older than the reconciliation window.
Reference implementation (sketch)
This is illustrative, not drop-in. It assumes a products(id, price_cents) table, and orders columns status, total_cents, idempotency_key UNIQUE, and payment_intent_id. The idempotent-replay lookup and the webhook/reconciler are omitted for brevity.
import { Router, Request, Response, NextFunction } from 'express';
import { PoolClient } from 'pg';
import Stripe from 'stripe';
import { z } from 'zod';
import { pool } from '../db'; // single shared pool
import { stripe } from '../stripe'; // key from env, apiVersion pinned
const router = Router();
const CheckoutBody = z.object({
items: z.array(z.object({
productId: z.number().int().positive(),
qty: z.number().int().min(1).max(20),
})).min(1).max(50),
discountCode: z.string().trim().min(1).max(64).optional(),
paymentMethodId: z.string().startsWith('pm_'),
idempotencyKey: z.string().uuid(),
}).strict();
class HttpError extends Error {
constructor(public status: number, public code: string, public details?: object) { super(code); }
}
async function withTx<T>(fn: (c: PoolClient) => Promise<T>): Promise<T> {
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await fn(client);
await client.query('COMMIT');
return result;
} catch (err) {
await client.query('ROLLBACK').catch(() => {});
throw err;
} finally {
client.release();
}
}
/** Pure: easy to unit test, and where gift cards will plug in. */
export function priceCart(lines: { priceCents: number; qty: number }[], percentOff: number) {
if (!(percentOff >= 0 && percentOff <= 100)) throw new Error('invalid percent_off');
const subtotalCents = lines.reduce((s, l) => s + l.priceCents * l.qty, 0);
const discountCents = Math.round((subtotalCents * percentOff) / 100);
return { subtotalCents, discountCents, totalCents: subtotalCents - discountCents };
}
router.post('/checkout', async (req: Request, res: Response, next: NextFunction) => {
try {
const parsed = CheckoutBody.safeParse(req.body);
if (!parsed.success) throw new HttpError(400, 'invalid_request');
const { items, discountCode, paymentMethodId, idempotencyKey } = parsed.data;
// Merge duplicate lines so per-product stock checks are correct.
const qtyById = new Map<number, number>();
for (const i of items) qtyById.set(i.productId, (qtyById.get(i.productId) ?? 0) + i.qty);
const productIds = [...qtyById.keys()].sort((a, b) => a - b);
const qtys = productIds.map((id) => qtyById.get(id)!);
// Tx 1: price, reserve stock, create pending order. Short; no network calls inside.
const order = await withTx(async (client) => {
const { rows: stock } = await client.query<{ product_id: number; quantity: number; price_cents: number }>(
`SELECT i.product_id, i.quantity, p.price_cents
FROM inventory i JOIN products p ON p.id = i.product_id
WHERE i.product_id = ANY($1::int[])
ORDER BY i.product_id
FOR UPDATE OF i`,
[productIds]
);
const byId = new Map(stock.map((r) => [r.product_id, r]));
for (const [id, qty] of qtyById) {
const row = byId.get(id);
if (!row) throw new HttpError(400, 'unknown_product', { productId: id });
if (row.quantity < qty) throw new HttpError(409, 'insufficient_stock', { productId: id });
}
let percentOff = 0;
if (discountCode) {
const { rows } = await client.query<{ percent_off: string }>(
'SELECT percent_off FROM discounts WHERE code = $1 AND active = true',
[discountCode]
);
if (rows.length === 0) throw new HttpError(400, 'invalid_discount_code');
percentOff = Number(rows[0].percent_off);
}
const lines = productIds.map((id, idx) => ({ productId: id, qty: qtys[idx], priceCents: byId.get(id)!.price_cents }));
const { totalCents } = priceCart(lines, percentOff);
if (totalCents < 50) throw new HttpError(400, 'total_below_minimum');
await client.query(
`UPDATE inventory i SET quantity = i.quantity - c.qty
FROM unnest($1::int[], $2::int[]) AS c(product_id, qty)
WHERE i.product_id = c.product_id`,
[productIds, qtys]
);
const { rows: [o] } = await client.query<{ id: string }>(
`INSERT INTO orders (items, total_cents, discount_code, status, idempotency_key)
VALUES ($1, $2, $3, 'pending', $4) RETURNING id`,
[JSON.stringify(lines), totalCents, discountCode ? discountCode : null, idempotencyKey]
);
return { id: o.id, totalCents };
});
// NOTE: handle unique-violation on idempotency_key (pg code 23505) by returning the existing order.
// Charge outside any transaction; idempotent on order id.
let intent: Stripe.PaymentIntent;
try {
intent = await stripe.paymentIntents.create(
{
amount: order.totalCents,
currency: 'usd',
payment_method: paymentMethodId,
confirm: true,
automatic_payment_methods: { enabled: true, allow_redirects: 'never' },
metadata: { order_id: order.id },
},
{ idempotencyKey: `order-${order.id}` }
);
} catch (err) {
if (err instanceof Stripe.errors.StripeCardError) {
await releaseOrder(order.id, productIds, qtys);
throw new HttpError(402, 'card_declined');
}
// Ambiguous failure (timeout/5xx): leave 'pending'; webhook + reconciler resolve it.
throw err;
}
if (intent.status === 'requires_action') {
return res.status(202).json({ orderId: order.id, clientSecret: intent.client_secret });
}
await pool.query(
`UPDATE orders SET status = 'paid', payment_intent_id = $2 WHERE id = $1 AND status = 'pending'`,
[order.id, intent.id]
);
res.status(201).json({ orderId: order.id });
} catch (err) {
next(err); // central error middleware maps HttpError -> status, logs others, returns 500
}
});
async function releaseOrder(orderId: string, productIds: number[], qtys: number[]) {
await withTx(async (client) => {
const { rowCount } = await client.query(
`UPDATE orders SET status = 'failed' WHERE id = $1 AND status = 'pending'`,
[orderId]
);
if (rowCount === 0) return; // already finalized elsewhere; don't double-restore
await client.query(
`UPDATE inventory i SET quantity = i.quantity + c.qty
FROM unnest($1::int[], $2::int[]) AS c(product_id, qty)
WHERE i.product_id = c.product_id`,
[productIds, qtys]
);
});
}
export default router;
Once gift cards are added, they slot into priceCart (an extra deduction), Tx 1 (an atomic balance decrement alongside inventory), and releaseOrder (restoring the balance). The handler's shape stays the same.
Related prompts
Algorithm Design Expert
Designs, analyzes, and optimizes algorithms with multiple approaches, Big-O complexity analysis, production code, and tests.
Advanced Debugging Assistant
Diagnoses bugs in your code, pinpoints root causes, and delivers fixed code with prevention tips.
API Development Architect
Designs a production-ready RESTful API with OpenAPI spec, secure auth, validation, and scalable architecture.
SQL Query Optimizer
Diagnoses a slow SQL query and returns an optimized rewrite, index plan, and execution-plan reasoning.