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