Rebasely Beta: data may be purged

examples/storefront Public

TypeScript · 116 lines · 6.3 KiB · at 48ffd57

// 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 };}