Skip to main content

Payment Processing

Design Philosophy

  • Never trust frontend prices: Backend fetches from database
  • Webhooks for reliability: Don't rely on redirect success
  • Atomic order creation: Pending order before Stripe redirect
  • Idempotent processing: Same webhook twice = same result
  • Inventory reservation: Lock stock during checkout, release on failure/expiry

Payment Flow

┌──────────┐   ┌───────────────┐   ┌──────────┐   ┌──────────┐
│ Cart │──▶│ Checkout │──▶│ Stripe │──▶│ Success │
│ Page │ │ API │ │ Hosted │ │ Page │
└──────────┘ └───────┬───────┘ └────┬─────┘ └────┬─────┘
│ │ │
┌────────────┼────────────────┼──────────────┘
│ │ │
▼ ▼ ▼
┌───────────┐ ┌────────────┐ ┌────────────┐
│ Rate │ │ Reserve │ │ Webhook │
│ Limiting │ │ Inventory │ │ Handler │
│ (Redis) │ │ (Postgres) │ │ │
└───────────┘ └────────────┘ └─────┬──────┘

┌─────────────────────┼─────────────────────┐
│ │ │
▼ ▼ ▼
┌────────────┐ ┌────────────────┐ ┌─────────────┐
│ Update │ │ Deduct │ │ Send │
│ Status │ │ Inventory │ │ Email │
│ → paid │ │ on_hand -= N │ │ (Resend) │
└────────────┘ └────────────────┘ └─────────────┘

Inventory Reservation Model

State Transitions

┌─────────────────────────────────────────────────────────────────┐
│ INVENTORY TABLE │
│ sku_id │ on_hand │ reserved │ available (on_hand - reserved) │
├─────────┼─────────┼──────────┼───────────────────────────────────┤
│ 101 │ 100 │ 0 │ 100 (can sell) │
└─────────┴─────────┴──────────┴───────────────────────────────────┘

User starts checkout


┌─────────────────────────────────────────────────────────────────┐
│ 101 │ 100 │ 5 │ 95 (5 reserved for user) │
└─────────┴─────────┴──────────┴───────────────────────────────────┘

┌───────────────┴───────────────┐
│ │
Payment Success Payment Failed/Expired
│ │
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────┐
│ on_hand = 100 - 5 = 95 │ │ reserved = 5 - 5 = 0 │
│ reserved = 5 - 5 = 0 │ │ on_hand unchanged │
│ (Stock sold) │ │ (Stock released) │
└─────────────────────────┘ └─────────────────────────┘

Reservation SQL

-- Reserve: Check available AND lock atomically
UPDATE inventory
SET reserved = reserved + $1,
updated_at = NOW()
WHERE sku_id = $2
AND (on_hand - reserved) >= $1 -- Only if enough available
RETURNING sku_id;

-- If rowCount = 0 → Insufficient stock, ROLLBACK

Deduction SQL (on payment success)

UPDATE inventory i
SET on_hand = i.on_hand - oi.quantity,
reserved = i.reserved - oi.quantity,
updated_at = NOW()
FROM order_items oi
WHERE oi.order_id = $1
AND i.sku_id = oi.product_id;

Release SQL (on expiry/failure)

UPDATE inventory i
SET reserved = GREATEST(0, i.reserved - oi.quantity),
updated_at = NOW()
FROM order_items oi
WHERE oi.order_id = $1
AND i.sku_id = oi.product_id;

Checkout API

File: src/app/api/checkout/route.ts

const RESERVATION_MINUTES = 30;

export async function POST(req: Request) {
const session = await auth();

// ─────────────────────────────────────────────────────
// 1. RATE LIMITING (Redis)
// ─────────────────────────────────────────────────────
const identifier = session?.user?.id
? `user:${session.user.id}`
: `ip:${req.headers.get("x-forwarded-for") || "unknown"}`;

const rateLimitKey = `ratelimit:checkout:${identifier}`;
const requestCount = await redis.incr(rateLimitKey);

if (requestCount === 1) {
await redis.expire(rateLimitKey, 60); // 60 second window
}

if (requestCount > 10) {
return NextResponse.json(
{ error: "Too many checkout attempts", retryAfter: 60 },
{ status: 429 }
);
}

// ─────────────────────────────────────────────────────
// 2. INPUT VALIDATION
// ─────────────────────────────────────────────────────
const body = await req.json();

if (!body.items || body.items.length === 0) {
return NextResponse.json({ error: "No items" }, { status: 400 });
}

for (const item of body.items) {
if (
!Number.isInteger(item.quantity) ||
item.quantity < 1 ||
item.quantity > 100
) {
return NextResponse.json(
{ error: "Quantity must be 1-100" },
{ status: 400 }
);
}
}

// ─────────────────────────────────────────────────────
// 3. MERGE DUPLICATE ITEMS
// ─────────────────────────────────────────────────────
// User might add same product multiple times → merge into single row
const mergedMap = new Map<number, number>();
for (const item of body.items) {
mergedMap.set(
item.productId,
(mergedMap.get(item.productId) || 0) + item.quantity
);
}
const mergedItems = Array.from(mergedMap.entries()).map(
([productId, quantity]) => ({ productId, quantity })
);

// ─────────────────────────────────────────────────────
// 4. DATABASE TRANSACTION
// ─────────────────────────────────────────────────────
const client = await pool.connect();
let orderId: number | null = null;

try {
await client.query("BEGIN");

// 4a. Load products from DB (NEVER trust frontend prices)
const productResult = await client.query(
"SELECT id, price, name FROM products WHERE id = ANY($1)",
[mergedItems.map((i) => i.productId)]
);

// Verify all products exist
const foundIds = productResult.rows.map((r) => r.id);
const missingIds = mergedItems
.map((i) => i.productId)
.filter((id) => !foundIds.includes(id));
if (missingIds.length > 0) {
await client.query("ROLLBACK");
return NextResponse.json(
{ error: `Products not found: ${missingIds}` },
{ status: 400 }
);
}

const productMap = new Map(productResult.rows.map((r) => [r.id, r]));

// 4b. Ensure inventory rows exist (for new products)
await client.query(
`INSERT INTO inventory (sku_id, on_hand, reserved)
SELECT unnest($1::bigint[]), 0, 0
ON CONFLICT (sku_id) DO NOTHING`,
[mergedItems.map((i) => i.productId)]
);

// 4c. Reserve inventory (atomic, prevents oversell)
for (const item of mergedItems) {
const reserveRes = await client.query(
`UPDATE inventory
SET reserved = reserved + $1, updated_at = NOW()
WHERE sku_id = $2 AND (on_hand - reserved) >= $1
RETURNING sku_id`,
[item.quantity, item.productId]
);

if (reserveRes.rowCount === 0) {
await client.query("ROLLBACK");
return NextResponse.json(
{ error: "Insufficient stock", productId: item.productId },
{ status: 409 }
);
}
}

// 4d. Calculate total from DB prices
const total = mergedItems.reduce((sum, item) => {
const p = productMap.get(item.productId);
return sum + Number(p.price) * item.quantity;
}, 0);

// 4e. Create pending order
const reservedUntil = new Date(
Date.now() + RESERVATION_MINUTES * 60 * 1000
);
const orderRes = await client.query(
`INSERT INTO orders (email, total, status, user_id, inventory_reserved, reserved_until)
VALUES ($1, $2, 'pending', $3, TRUE, $4)
RETURNING id`,
[email, total, userId, reservedUntil]
);
orderId = orderRes.rows[0].id;

// 4f. Insert order items
for (const item of mergedItems) {
const p = productMap.get(item.productId);
await client.query(
`INSERT INTO order_items (order_id, product_id, quantity, price)
VALUES ($1, $2, $3, $4)
ON CONFLICT (order_id, product_id) DO UPDATE SET quantity = EXCLUDED.quantity`,
[orderId, item.productId, item.quantity, p.price]
);
}

await client.query("COMMIT");

// ─────────────────────────────────────────────────────
// 5. CREATE STRIPE SESSION (outside transaction)
// ─────────────────────────────────────────────────────
let stripeSession: Stripe.Checkout.Session;
try {
stripeSession = await stripe.checkout.sessions.create({
mode: "payment",
line_items: mergedItems.map((item) => {
const p = productMap.get(item.productId);
return {
quantity: item.quantity,
price_data: {
currency: "usd",
product_data: { name: p.name },
unit_amount: Math.round(p.price * 100),
},
};
}),
success_url: `${SITE_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${SITE_URL}/cart`,
customer_email: email ?? undefined,
shipping_address_collection: {
allowed_countries: ["US", "CA", "GB", "AU", "DE", "FR", "JP", "CN"],
},
phone_number_collection: { enabled: true },
custom_text: {
submit: {
message: "We will process your order as soon as possible.",
},
},
expires_at: Math.floor(Date.now() / 1000) + RESERVATION_MINUTES * 60,
});
} catch (stripeErr) {
// Stripe failed → release reservation
await releaseInventory(orderId);
await cancelOrder(orderId);
throw stripeErr;
}

// 5b. Save stripe_session_id
await query(
`UPDATE orders SET stripe_session_id = $1 WHERE id = $2 AND stripe_session_id IS NULL`,
[stripeSession.id, orderId]
);

return NextResponse.json({ url: stripeSession.url });
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}

Rate Limiting

LimitWindowKey Pattern
10 requests60 secondsratelimit:checkout:user:{id} or ratelimit:checkout:ip:{ip}

Webhook Handler

File: src/app/api/webhooks/stripe/route.ts

Event: checkout.session.completed

if (session.payment_status === "paid") {
// 1. Update order status + save shipping snapshot
await query(
`UPDATE orders
SET status = 'paid',
shipping_name = $2,
shipping_phone = $3,
shipping_address = $4,
updated_at = NOW()
WHERE stripe_session_id = $1 AND status = 'pending'
RETURNING id`,
[session.id, shippingName, shippingPhone, shippingAddress]
);

// 2. Deduct inventory (idempotent via inventory_reserved flag)
const deductRes = await query(
`UPDATE orders
SET inventory_reserved = FALSE
WHERE stripe_session_id = $1 AND inventory_reserved = TRUE
RETURNING id`,
[session.id]
);

if (deductRes.rows.length > 0) {
const orderId = deductRes.rows[0].id;
await query(
`UPDATE inventory i
SET on_hand = i.on_hand - oi.quantity,
reserved = i.reserved - oi.quantity,
updated_at = NOW()
FROM order_items oi
WHERE oi.order_id = $1 AND i.sku_id = oi.product_id`,
[orderId]
);
}

// 3. Send confirmation email
const orderData = await query(`SELECT ... JOIN ...`, [session.id]);
await sendOrderConfirmationEmail(orderData);

// 4. Save address to user's address book (if logged in)
if (userId && session.customer_details?.address) {
await query(
`INSERT INTO addresses (user_id, name, line1, ..., is_default)
VALUES ($1, $2, $3, ..., NOT EXISTS (SELECT 1 FROM addresses WHERE user_id = $1))
ON CONFLICT (user_id, line1, postal_code) DO NOTHING`,
[userId, ...]
);
}
}

Event: checkout.session.expired

// Release reserved inventory
const releaseRes = await query(
`UPDATE orders
SET status = 'expired', inventory_reserved = FALSE
WHERE stripe_session_id = $1 AND inventory_reserved = TRUE
RETURNING id`,
[session.id]
);

if (releaseRes.rows.length > 0) {
await query(
`UPDATE inventory i
SET reserved = GREATEST(0, i.reserved - oi.quantity),
updated_at = NOW()
FROM order_items oi
WHERE oi.order_id = $1 AND i.sku_id = oi.product_id`,
[releaseRes.rows[0].id]
);
}

Webhook Flow Diagram

                    Stripe sends webhook


┌───────────────────────┐
│ Verify signature │
│ constructEvent() │
└───────────┬───────────┘

┌───────────────┴───────────────┐
│ │
checkout.session checkout.session
.completed .expired
│ │
▼ ▼
┌───────────────┐ ┌───────────────┐
│payment_status │ │ Release │
│ = paid? │ │ inventory │
└───────┬───────┘ │ reserved -= N │
│ └───────────────┘

┌───────────────┐
│ Update order │
│ status='paid' │
│ + shipping │
└───────┬───────┘


┌───────────────┐
│ Deduct stock │
│ on_hand -= N │
│ reserved -= N │
└───────┬───────┘


┌───────────────┐
│ Send email │
│ (Resend) │
└───────┬───────┘


┌───────────────┐
│ Save address │
│ (if logged in)│
└───────────────┘

Idempotency Guarantees

OperationMechanism
Order status updateWHERE status = 'pending' (only updates once)
Inventory deductionWHERE inventory_reserved = TRUE flag
Address saveON CONFLICT ... DO NOTHING
Stripe session linkWHERE stripe_session_id IS NULL

Why it matters: Stripe may send the same webhook multiple times. Each operation is designed to produce the same result regardless of how many times it runs.


Failure Scenarios

Stripe API Fails After DB Commit

try {
stripeSession = await stripe.checkout.sessions.create(...);
} catch (err) {
// Rollback: release inventory + cancel order
await query(
`UPDATE inventory i
SET reserved = GREATEST(0, i.reserved - oi.quantity)
FROM order_items oi
WHERE oi.order_id = $1 AND i.sku_id = oi.product_id`,
[orderId]
);

await query(
`UPDATE orders SET status = 'cancelled', inventory_reserved = FALSE WHERE id = $1`,
[orderId]
);

throw err;
}

User Abandons Checkout

Stripe session expires after 30 minutes → checkout.session.expired webhook fires → inventory released automatically.


Security Measures

MeasureImplementation
Price validationFetch from DB, ignore frontend
Quantity limits1-100 per item
Product existenceVerify all IDs exist before processing
Webhook signaturestripe.webhooks.constructEvent()
IdempotencyFlag-based checks prevent double processing
Rate limiting10 requests/minute per user or IP
Reservation timeout30 minute expiry

Stripe Configuration

Checkout Session Options

stripe.checkout.sessions.create({
mode: "payment",

// Collect shipping info
shipping_address_collection: {
allowed_countries: ["US", "CA", "GB", "AU", "DE", "FR", "JP", "CN"],
},

// Collect phone
phone_number_collection: { enabled: true },

// Custom submit button text
custom_text: {
submit: { message: "We will process your order as soon as possible." },
},

// Session expires in 30 minutes (matches reservation)
expires_at: Math.floor(Date.now() / 1000) + 30 * 60,
});

Testing Locally

# Install Stripe CLI
brew install stripe/stripe-cli/stripe

# Login
stripe login

# Forward webhooks to localhost
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# Copy the webhook secret (whsec_...) to .env.local

Test Cards

Card NumberResult
4242 4242 4242 4242Success
4000 0000 0000 0002Decline
4000 0027 6000 31843D Secure
4000 0000 0000 9995Insufficient funds

Order Status State Machine

                    ┌─────────┐
│ pending │
└────┬────┘

┌───────────────┼───────────────┐
│ │ │
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌───────────┐
│ paid │ │ expired │ │ cancelled │
└────┬────┘ └─────────┘ └───────────┘


┌─────────┐
│ shipped │
└────┬────┘


┌───────────┐
│ delivered │
└───────────┘

Status Transitions

FromToTrigger
pendingpaidWebhook: payment_status = paid
pendingexpiredWebhook: checkout.session.expired
pendingcancelledStripe API failure / manual
paidshippedAdmin: add tracking number
shippeddeliveredTracking API / manual