examples/storefront Public
// Checkout: turns an open cart into an order, takes the stock, and asks the payment provider for// the PaymentIntent the shopper pays in the browser. See .rebasely/kb/concepts/checkout.md.import { getCart } from "./carts.ts";import { HttpError } from "./http.ts";import { getOrder, type Address, type Order } from "./orders.ts";import type { Payments } from "./payments.ts";export type CheckoutResult = { order: Order; client_secret: string; created: boolean };type Clock = { now: () => number; newId: () => string };export async function checkout(db: D1Database, payments: Payments, cartId: string, body: Record<string, unknown>, deps: Clock): Promise<CheckoutResult> { // Orders keep no image URLs, so the cart is read with bare keys. const keysOnly = (key: string) => key; const { email, ship_to } = parseCheckout(body); // A retry after a timeout finds the order the first attempt created. Creating the intent is // idempotent per order, so the shopper gets the same client secret back. const existing = await db.prepare("SELECT id, status FROM orders WHERE cart_id = ?").bind(cartId).first<{ id: string; status: string }>(); if (existing) { if (existing.status !== "pending_payment") throw new HttpError(409, "cart_closed", "This cart has been checked out."); const order = (await getOrder(db, existing.id))!; const intent = await payments.createIntent({ order_id: order.id, amount_cents: order.total_cents, currency: order.currency, email: order.email }); return { order, client_secret: intent.client_secret, created: false }; } const cart = await getCart(db, cartId, keysOnly); if (!cart) throw new HttpError(404, "cart_not_found", "No such cart."); if (cart.status !== "open") throw new HttpError(409, "cart_closed", "This cart has been checked out."); if (!cart.items.length) throw new HttpError(409, "cart_empty", "The cart is empty."); await assertInStock(db, cart.items); const id = deps.newId(); const now = deps.now(); const statements = [ db .prepare( `INSERT INTO orders (id, cart_id, email, ship_to, subtotal_cents, shipping_cents, total_cents, currency, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .bind(id, cartId, email, JSON.stringify(ship_to), cart.subtotal_cents, cart.shipping_cents, cart.total_cents, cart.currency, now, now), ...cart.items.flatMap((i) => [ db .prepare("INSERT INTO order_items (order_id, product_id, name, quantity, unit_price_cents) VALUES (?, ?, ?, ?, ?)") .bind(id, i.product_id, i.name, i.quantity, i.unit_price_cents), // products.stock has CHECK (stock >= 0): if another checkout took the last one first, this // statement fails and D1 rolls back the whole batch. db.prepare("UPDATE products SET stock = stock - ?, updated_at = ? WHERE id = ?").bind(i.quantity, now, i.product_id), ]), db.prepare("UPDATE carts SET status = 'checked_out', updated_at = ? WHERE id = ?").bind(now, cartId), ]; try { await db.batch(statements); } catch (e) { const msg = String(e); if (/CHECK constraint failed/i.test(msg)) await assertInStock(db, cart.items); if (/UNIQUE constraint failed: orders\.cart_id/i.test(msg)) throw new HttpError(409, "cart_closed", "This cart has been checked out."); throw e; } let clientSecret: string; try { const intent = await payments.createIntent({ order_id: id, amount_cents: cart.total_cents, currency: cart.currency, email }); await db.prepare("UPDATE orders SET payment_intent_id = ? WHERE id = ?").bind(intent.id, id).run(); clientSecret = intent.client_secret; } catch (e) { // Nothing was charged, so undo the checkout completely: the stock goes back and the cart // opens again for the shopper to retry. console.error(JSON.stringify({ msg: "creating the payment intent failed", order: id, error: String(e) })); await db.batch([ ...cart.items.map((i) => db.prepare("UPDATE products SET stock = stock + ? WHERE id = ?").bind(i.quantity, i.product_id)), db.prepare("DELETE FROM orders WHERE id = ?").bind(id), db.prepare("UPDATE carts SET status = 'open', updated_at = ? WHERE id = ?").bind(now, cartId), ]); throw new HttpError(502, "payments_unavailable", "The payment provider did not answer. Nothing was charged; try again."); } return { order: (await getOrder(db, id))!, client_secret: clientSecret, created: true };}async function assertInStock(db: D1Database, items: { product_id: string; quantity: number }[]): Promise<void> { const short: { product_id: string; available: number }[] = []; for (const i of items) { const p = await db.prepare("SELECT stock, active FROM products WHERE id = ?").bind(i.product_id).first<{ stock: number; active: number }>(); const available = p && p.active === 1 ? p.stock : 0; if (available < i.quantity) short.push({ product_id: i.product_id, available }); } if (short.length) throw new HttpError(409, "out_of_stock", "Some items are no longer available in that quantity.", { items: short });}const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;export function parseCheckout(body: Record<string, unknown>): { email: string; ship_to: Address } { const email = typeof body.email === "string" ? body.email.trim() : ""; if (!EMAIL.test(email) || email.length > 254) throw new HttpError(400, "bad_email", "email is an email address."); const raw = body.ship_to; if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new HttpError(400, "bad_address", "ship_to is the shipping address."); const a = raw as Record<string, unknown>; const field = (name: keyof Address, max: number, required = true): string => { const v = a[name] ?? ""; if (typeof v !== "string" || v.length > max || (required && !v.trim())) { throw new HttpError(400, "bad_address", `ship_to.${name} is ${required ? "required, " : ""}at most ${max} characters.`); } return v.trim(); }; const ship_to: Address = { name: field("name", 100), line1: field("line1", 200), line2: field("line2", 200, false), city: field("city", 100), postal_code: field("postal_code", 20), country: field("country", 2).toUpperCase(), }; if (!/^[A-Z]{2}$/.test(ship_to.country)) throw new HttpError(400, "bad_address", "ship_to.country is a two-letter country code."); return { email, ship_to };}