Rebasely Beta: data may be purged

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.
f13d917b1a69e1b879de49e976bf8a9c531df148
Parent 7b21f05 Browse filesHistory

Changed files

5 files changed203 additions0 deletionsagainst 7b21f05

  1. Addedmigrations/0002_carts.sql+16−0
  2. Changedsrc/app.ts+16−0
  3. Addedsrc/carts.ts+85−0
  4. Addedsrc/money.ts+19−0
  5. Addedtest/carts.test.ts+67−0
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)
+);
View file
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> {
View file
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))!;
+}
View file
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)}`;
+}
View file
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");
+});
View file

Checks

No checks reported for this commit.

Runs

No runs for this commit.