examples/storefront Public
Add carts with quantity limits and stock checks
A cart holds up to 50 products, at most 10 of each, and never more than are in stock. Totals add flat shipping under $50.
Changed files
5 files changed203 additions0 deletionsagainst 7b21f05
Addedmigrations/0002_carts.sql+16−0
| @@ -0,0 +1,16 @@ | ||
| +-- Carts and what is in them. unit_price_cents is the price when the item was added. | ||
| +CREATE TABLE carts ( | ||
| + id TEXT PRIMARY KEY, | ||
| + status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'checked_out')), | ||
| + created_at INTEGER NOT NULL, | ||
| + updated_at INTEGER NOT NULL | ||
| +); | ||
| + | ||
| +CREATE TABLE cart_items ( | ||
| + cart_id TEXT NOT NULL REFERENCES carts (id) ON DELETE CASCADE, | ||
| + product_id TEXT NOT NULL REFERENCES products (id), | ||
| + quantity INTEGER NOT NULL CHECK (quantity BETWEEN 1 AND 10), | ||
| + unit_price_cents INTEGER NOT NULL, | ||
| + added_at INTEGER NOT NULL, | ||
| + PRIMARY KEY (cart_id, product_id) | ||
| +); | ||
Changedsrc/app.ts+16−0
| @@ -1,6 +1,7 @@ | ||
| // The storefront's HTTP API. createApp takes the environment and the few things tests replace | ||
| // (the clock and id generation) and returns a fetch handler. | ||
| +import { createCart, getCart, setQuantity } from "./carts.ts"; | ||
| import { getProduct, listProducts, parseProductInput, upsertProduct } from "./catalog.ts"; | ||
| import type { Env } from "./env.ts"; | ||
| import { errorResponse, HttpError, json, readJson, requireAdmin } from "./http.ts"; | ||
| @@ -29,6 +30,21 @@ | ||
| const input = parseProductInput(p.id!, await readJson(req)); | ||
| return json({ product: await upsertProduct(env.DB, input, deps.now()) }); | ||
| }); | ||
| + | ||
| + r.on("POST", "/carts", async () => json({ cart: await createCart(env.DB, deps.newId(), deps.now()) }, 201)); | ||
| + | ||
| + r.on("GET", "/carts/:id", async (_req, p) => { | ||
| + const cart = await getCart(env.DB, p.id!); | ||
| + if (!cart) throw new HttpError(404, "cart_not_found", "No such cart."); | ||
| + return json({ cart }); | ||
| + }); | ||
| + | ||
| + r.on("PUT", "/carts/:id/items/:product", async (req, p) => { | ||
| + const body = await readJson(req); | ||
| + return json({ cart: await setQuantity(env.DB, p.id!, p.product!, body.quantity, deps.now()) }); | ||
| + }); | ||
| + | ||
| + r.on("DELETE", "/carts/:id/items/:product", async (_req, p) => json({ cart: await setQuantity(env.DB, p.id!, p.product!, 0, deps.now()) })); | ||
| return { | ||
| async fetch(req: Request): Promise<Response> { | ||
Addedsrc/carts.ts+85−0
| @@ -0,0 +1,85 @@ | ||
| +// Carts: a shopper's items before checkout. A cart is open until it is checked out. | ||
| + | ||
| +import { HttpError } from "./http.ts"; | ||
| +import { totals, type Totals } from "./money.ts"; | ||
| + | ||
| +export const MAX_QUANTITY = 10; | ||
| +export const MAX_LINES = 50; | ||
| + | ||
| +export type CartItem = { | ||
| + product_id: string; | ||
| + name: string; | ||
| + quantity: number; | ||
| + unit_price_cents: number; | ||
| + image_url: string | null; | ||
| +}; | ||
| + | ||
| +export type Cart = { id: string; status: "open" | "checked_out"; items: CartItem[] } & Totals; | ||
| + | ||
| +type ItemRow = { product_id: string; name: string; quantity: number; unit_price_cents: number; image_key: string | null }; | ||
| + | ||
| +export async function createCart(db: D1Database, id: string, now: number): Promise<Cart> { | ||
| + await db.prepare("INSERT INTO carts (id, created_at, updated_at) VALUES (?, ?, ?)").bind(id, now, now).run(); | ||
| + return (await getCart(db, id))!; | ||
| +} | ||
| + | ||
| +export async function getCart(db: D1Database, id: string): Promise<Cart | null> { | ||
| + const cart = await db.prepare("SELECT id, status FROM carts WHERE id = ?").bind(id).first<{ id: string; status: Cart["status"] }>(); | ||
| + if (!cart) return null; | ||
| + const { results } = await db | ||
| + .prepare( | ||
| + `SELECT ci.product_id, p.name, ci.quantity, ci.unit_price_cents, p.image_key | ||
| + FROM cart_items ci JOIN products p ON p.id = ci.product_id | ||
| + WHERE ci.cart_id = ? ORDER BY ci.added_at, ci.product_id`, | ||
| + ) | ||
| + .bind(id) | ||
| + .all<ItemRow>(); | ||
| + const items = results.map((r) => ({ | ||
| + product_id: r.product_id, | ||
| + name: r.name, | ||
| + quantity: r.quantity, | ||
| + unit_price_cents: r.unit_price_cents, | ||
| + image_url: r.image_key ? `/images/${r.image_key}` : null, | ||
| + })); | ||
| + return { id: cart.id, status: cart.status, items, ...totals(items) }; | ||
| +} | ||
| + | ||
| +async function openCart(db: D1Database, id: string): Promise<void> { | ||
| + const cart = await db.prepare("SELECT status FROM carts WHERE id = ?").bind(id).first<{ status: string }>(); | ||
| + 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."); | ||
| +} | ||
| + | ||
| +// Sets how many of a product the cart holds; 0 removes it. The price is the product's price now. | ||
| +export async function setQuantity(db: D1Database, cartId: string, productId: string, quantity: unknown, now: number): Promise<Cart> { | ||
| + if (!Number.isInteger(quantity) || (quantity as number) < 0 || (quantity as number) > MAX_QUANTITY) { | ||
| + throw new HttpError(400, "bad_quantity", `quantity is a whole number from 0 to ${MAX_QUANTITY}.`); | ||
| + } | ||
| + await openCart(db, cartId); | ||
| + if (quantity === 0) { | ||
| + await db.prepare("DELETE FROM cart_items WHERE cart_id = ? AND product_id = ?").bind(cartId, productId).run(); | ||
| + } else { | ||
| + const product = await db | ||
| + .prepare("SELECT price_cents, stock FROM products WHERE id = ? AND active = 1") | ||
| + .bind(productId) | ||
| + .first<{ price_cents: number; stock: number }>(); | ||
| + if (!product) throw new HttpError(404, "product_not_found", "No such product."); | ||
| + if (product.stock < (quantity as number)) { | ||
| + throw new HttpError(409, "out_of_stock", "There are not that many left.", { product_id: productId, available: product.stock }); | ||
| + } | ||
| + const lines = await db | ||
| + .prepare("SELECT COUNT(*) AS n FROM cart_items WHERE cart_id = ? AND product_id != ?") | ||
| + .bind(cartId, productId) | ||
| + .first<number>("n"); | ||
| + if ((lines ?? 0) >= MAX_LINES) throw new HttpError(409, "cart_full", `A cart holds at most ${MAX_LINES} different products.`); | ||
| + await db | ||
| + .prepare( | ||
| + `INSERT INTO cart_items (cart_id, product_id, quantity, unit_price_cents, added_at) VALUES (?, ?, ?, ?, ?) | ||
| + ON CONFLICT (cart_id, product_id) DO UPDATE SET quantity = excluded.quantity, unit_price_cents = excluded.unit_price_cents`, | ||
| + ) | ||
| + .bind(cartId, productId, quantity, product.price_cents, now) | ||
| + .run(); | ||
| + } | ||
| + await db.prepare("UPDATE carts SET updated_at = ? WHERE id = ?").bind(now, cartId).run(); | ||
| + return (await getCart(db, cartId))!; | ||
| +} | ||
Addedsrc/money.ts+19−0
| @@ -0,0 +1,19 @@ | ||
| +// Money is whole cents in the shop's one currency. Orders under the free shipping threshold pay | ||
| +// a flat shipping rate. | ||
| + | ||
| +export const CURRENCY = "usd"; | ||
| +export const FREE_SHIPPING_CENTS = 5000; | ||
| +export const SHIPPING_CENTS = 595; | ||
| + | ||
| +export type Line = { unit_price_cents: number; quantity: number }; | ||
| +export type Totals = { subtotal_cents: number; shipping_cents: number; total_cents: number; currency: string }; | ||
| + | ||
| +export function totals(lines: Line[]): Totals { | ||
| + const subtotal = lines.reduce((sum, l) => sum + l.unit_price_cents * l.quantity, 0); | ||
| + const shipping = subtotal === 0 || subtotal >= FREE_SHIPPING_CENTS ? 0 : SHIPPING_CENTS; | ||
| + return { subtotal_cents: subtotal, shipping_cents: shipping, total_cents: subtotal + shipping, currency: CURRENCY }; | ||
| +} | ||
| + | ||
| +export function formatCents(cents: number): string { | ||
| + return `$${(cents / 100).toFixed(2)}`; | ||
| +} | ||
Addedtest/carts.test.ts+67−0
| @@ -0,0 +1,67 @@ | ||
| +import assert from "node:assert/strict"; | ||
| +import { test } from "node:test"; | ||
| +import { setup } from "./helpers/app.ts"; | ||
| + | ||
| +async function withCart() { | ||
| + const s = setup(); | ||
| + await s.addProduct("wool-socks", { name: "Wool socks", price_cents: 1800, stock: 3 }); | ||
| + await s.addProduct("canvas-tote", { name: "Canvas tote", price_cents: 2400, stock: 10 }); | ||
| + const { body } = await s.call("POST", "/carts"); | ||
| + return { ...s, cartId: body.cart.id as string }; | ||
| +} | ||
| + | ||
| +test("a new cart is open and empty", async () => { | ||
| + const s = await withCart(); | ||
| + const { body } = await s.call("GET", `/carts/${s.cartId}`); | ||
| + assert.equal(body.cart.status, "open"); | ||
| + assert.deepEqual(body.cart.items, []); | ||
| + assert.equal(body.cart.total_cents, 0); | ||
| +}); | ||
| + | ||
| +test("adds items and totals them with shipping", async () => { | ||
| + const s = await withCart(); | ||
| + await s.call("PUT", `/carts/${s.cartId}/items/wool-socks`, { quantity: 2 }); | ||
| + const { body } = await s.call("PUT", `/carts/${s.cartId}/items/canvas-tote`, { quantity: 1 }); | ||
| + assert.equal(body.cart.items.length, 2); | ||
| + assert.equal(body.cart.subtotal_cents, 6000); | ||
| + assert.equal(body.cart.shipping_cents, 0, "free shipping from $50"); | ||
| + assert.equal(body.cart.total_cents, 6000); | ||
| +}); | ||
| + | ||
| +test("charges shipping under the threshold", async () => { | ||
| + const s = await withCart(); | ||
| + const { body } = await s.call("PUT", `/carts/${s.cartId}/items/wool-socks`, { quantity: 1 }); | ||
| + assert.equal(body.cart.shipping_cents, 595); | ||
| + assert.equal(body.cart.total_cents, 2395); | ||
| +}); | ||
| + | ||
| +test("setting a quantity replaces it, and zero removes the item", async () => { | ||
| + const s = await withCart(); | ||
| + await s.call("PUT", `/carts/${s.cartId}/items/wool-socks`, { quantity: 2 }); | ||
| + let { body } = await s.call("PUT", `/carts/${s.cartId}/items/wool-socks`, { quantity: 1 }); | ||
| + assert.equal(body.cart.items[0].quantity, 1); | ||
| + ({ body } = await s.call("DELETE", `/carts/${s.cartId}/items/wool-socks`)); | ||
| + assert.deepEqual(body.cart.items, []); | ||
| +}); | ||
| + | ||
| +test("refuses more than are in stock and says how many are left", async () => { | ||
| + const s = await withCart(); | ||
| + const res = await s.call("PUT", `/carts/${s.cartId}/items/wool-socks`, { quantity: 4 }); | ||
| + assert.equal(res.status, 409); | ||
| + assert.equal(res.body.error, "out_of_stock"); | ||
| + assert.equal(res.body.available, 3); | ||
| +}); | ||
| + | ||
| +test("quantities are whole numbers up to ten", async () => { | ||
| + const s = await withCart(); | ||
| + for (const quantity of [-1, 1.5, 11, "2"]) { | ||
| + const res = await s.call("PUT", `/carts/${s.cartId}/items/canvas-tote`, { quantity }); | ||
| + assert.equal(res.status, 400, `quantity ${JSON.stringify(quantity)}`); | ||
| + } | ||
| +}); | ||
| + | ||
| +test("unknown carts and products", async () => { | ||
| + const s = await withCart(); | ||
| + assert.equal((await s.call("GET", "/carts/nope")).body.error, "cart_not_found"); | ||
| + assert.equal((await s.call("PUT", `/carts/${s.cartId}/items/nope`, { quantity: 1 })).body.error, "product_not_found"); | ||
| +}); | ||
Checks
No checks reported for this commit.
Runs
No runs for this commit.