Build a Retail POS System Backend
Build a REST API for a retail POS system with product variants and checkout.
Introduction
30 Second Summary
Every time you tap your card at a store, a system behind the counter is looking up products, checking stock, calculating totals, and recording the sale. That system is a point-of-sale (POS) backend.
In this project, you will build the REST API that powers a retail POS system. You will create a product catalog with size and color variants, process multi-item checkouts, and retrieve receipt-style order history.
What You'll Build
By the end, you will be able to add a product like "Classic T-Shirt" with variants for each size and color, check out a cart of items with a single API call, and pull up any past sale as a detailed receipt.
By the end of this project, you'll have:
- A product catalog API where you create a product with multiple variants (each with its own SKU, price, and stock level) and immediately see it listed at GET /api/products.
- A checkout endpoint that accepts a cart of items, validates stock, calculates line-by-line totals, and returns a complete order with an order number.
- A sales history API that returns receipt-style order details showing every item, its variant, quantity, unit price, and line total.
- Secret Mission: add discount support to the checkout flow so orders can apply percentage or flat-amount discounts.
Are there any prerequisites?
This project assumes you already have a Hono + Bun + Drizzle ORM project scaffold set up with SQLite. You should also be familiar with TypeScript and REST APIs.
Before We Start
Before we dive into code, let's lock in what you're about to build and why it matters.
Define Your POS Database Schema and Seed Data
A POS system lives or dies by its data model. Before you write a single API endpoint, you need tables that accurately represent what a retail store sells and how it records transactions.
The core design challenge is separating products from their sellable variants. A "Classic T-Shirt" is one product, but "Small / Black" and "Large / White" are different things a cashier scans and sells. In this step, you will create five database tables that capture this pattern and fill them with sample retail data.
In this step, get ready to:
- Create a five-table database schema with the product-variant pattern used by every major POS system.
- Push the schema to a local SQLite database.
- Seed the database with sample categories, products, and variants.
Install packages and create the database schema
Your project scaffold already has Hono, Bun, and Drizzle ORM installed. You still need two packages for request validation that you will use in later steps.
- Install zod and @hono/zod-validator by running this command in your project terminal:
bun add zod @hono/zod-validator
Bun prints the installed packages and their versions when it finishes.
Command not found?
Make sure your terminal is open inside your project folder. If you see a bun: command not found error, Bun is not installed or not on your PATH.
Ask for help: Why is bun not recognized in my terminal?
Next, you need two small configuration files to connect Drizzle to a local SQLite database.
- Open drizzle.config.ts in the root of your project. If it does not exist yet, create it. Replace its contents with this configuration:
import { defineConfig } from "drizzle-kit";
// Configure Drizzle to use SQLite with a local pos.db file
export default defineConfig({
out: "./drizzle",
schema: "./src/db/schema.ts",
dialect: "sqlite",
dbCredentials: {
url: "pos.db",
},
});
What does this config do?
- schema tells Drizzle where to find your table definitions.
- dialect sets the database engine to SQLite.
- url: "pos.db" creates a local database file in your project root.
- Create the file src/db/index.ts. If the src/db folder does not exist, create it first. Paste this code:
import { drizzle } from "drizzle-orm/bun-sqlite";
// Create and export the database connection
export const db = drizzle("pos.db");
What does this file do?
This file creates a single database connection and exports it. Every other file in the project imports db from here instead of creating its own connection.
Now for the main event: the schema file that defines all five POS tables.
Why do variants hold the price and stock, not the product?
In retail, a "Classic T-Shirt" is one product but "Small / Black" at $19.99 and "Large / Black" at $21.99 are different things you scan and sell. Each variant gets its own SKU, price, and stock count.
This is the universal product-variant pattern used by every POS system from Square to Shopify POS. Stock lives on variants because that is the level at which inventory actually moves.
- Create the file src/db/schema.ts and paste the first two tables:
import { int, sqliteTable, text } from "drizzle-orm/sqlite-core";
// Product categories like Clothing, Electronics
export const categories = sqliteTable("categories", {
id: int().primaryKey({ autoIncrement: true }),
name: text().notNull(),
});
// Products hold metadata (name, description) but NOT price or stock
export const products = sqliteTable("products", {
id: int().primaryKey({ autoIncrement: true }),
name: text().notNull(),
description: text(),
categoryId: int().references(() => categories.id),
});
What does this code do?
- categories groups products (Clothing, Electronics, Accessories).
- products stores the product name and an optional description. Notice there is no price or stock column here.
- categoryId links each product to its category using a foreign key.
- Below the products table in src/db/schema.ts, add the variants table:
// Variants are what you actually scan and sell
// Each variant has its own SKU, price, and stock level
export const productVariants = sqliteTable("product_variants", {
id: int().primaryKey({ autoIncrement: true }),
productId: int().notNull().references(() => products.id),
sku: text().notNull().unique(),
name: text().notNull(),
price: int().notNull(), // Price stored in cents (1999 = $19.99)
stock: int().notNull().default(0),
});
Why are prices stored in cents?
Floating-point math causes rounding errors in JavaScript. For example, 0.1 + 0.2 does not equal 0.3. POS systems store all monetary values as integers in the smallest currency unit. $19.99 becomes 1999. The frontend formats for display.
- Save src/db/schema.ts and push the schema to create these three tables in the database by running:
bunx drizzle-kit push
You should see output confirming the changes were applied to pos.db.
Seeing a config error?
Double check that drizzle.config.ts is in the project root (not inside src/) and that schema points to ./src/db/schema.ts.
Ask for help: My bunx drizzle-kit push command is failing. Help me debug.
The product-side tables are in the database. Now add the order-side tables that record every sale.
- Below the productVariants table in src/db/schema.ts, add the orders and order items tables:
// Each completed sale
export const orders = sqliteTable("orders", {
id: int().primaryKey({ autoIncrement: true }),
orderNumber: text().notNull().unique(),
subtotal: int().notNull(), // Sum of all line totals, in cents
discountAmount: int().notNull().default(0),
total: int().notNull(), // Final amount after discounts
status: text().notNull().default("completed"),
createdAt: text().notNull(),
});
// Individual items within an order (the receipt lines)
export const orderItems = sqliteTable("order_items", {
id: int().primaryKey({ autoIncrement: true }),
orderId: int().notNull().references(() => orders.id),
variantId: int().notNull().references(() => productVariants.id),
productName: text().notNull(), // Snapshot at time of sale
variantName: text().notNull(), // Snapshot at time of sale
quantity: int().notNull(),
unitPrice: int().notNull(), // Price at time of sale, in cents
lineTotal: int().notNull(), // quantity * unitPrice
});
Why snapshot product names on order items?
If a product is renamed later, old receipts must still show what the customer actually bought at the time. Storing productName and variantName directly on orderItems freezes those details at the moment of sale.
- Save src/db/schema.ts and push the updated schema by running:
bunx drizzle-kit push
All five tables now exist in your pos.db database.
✔️ Awesome, I've got everything!
Great. Make sure you have saved drizzle.config.ts, src/db/index.ts, and src/db/schema.ts.
ⓧ I'd like to double check the full code
Your drizzle.config.ts should look like this:
import { defineConfig } from "drizzle-kit";
export default defineConfig({
out: "./drizzle",
schema: "./src/db/schema.ts",
dialect: "sqlite",
dbCredentials: {
url: "pos.db",
},
});
Your src/db/index.ts should look like this:
import { drizzle } from "drizzle-orm/bun-sqlite";
export const db = drizzle("pos.db");
Your src/db/schema.ts has five tables. Scroll up and confirm these sections are present in order:
- The import line, categories table, and products table.
- The productVariants table with sku, price, and stock columns.
- The orders table and orderItems table with snapshot fields.
Create the seed script
Empty tables are hard to test against. A seed script populates the database with realistic sample data so you can immediately verify your API endpoints in later steps.
- Create a new file src/seed.ts and paste the function setup and category inserts:
import { db } from "./db";
import { categories, products, productVariants } from "./db/schema";
async function seed() {
console.log("Seeding database...");
// Insert three product categories
const [clothing] = await db
.insert(categories)
.values({ name: "Clothing" })
.returning();
const [electronics] = await db
.insert(categories)
.values({ name: "Electronics" })
.returning();
const [accessories] = await db
.insert(categories)
.values({ name: "Accessories" })
.returning();
// Create the first product and capture its ID
const [tshirt] = await db
.insert(products)
.values({
name: "Classic T-Shirt",
description: "Comfortable cotton t-shirt",
categoryId: clothing.id,
})
.returning();
What does this code do?
- .returning() tells Drizzle to return the inserted row, including the auto-generated id. You destructure it with const [clothing] because .returning() gives back an array.
- The captured id values are used below to link products to categories and variants to products.
- Below the T-Shirt product insert, add its variants and the second product with its variants:
// Add variants for the T-Shirt (each with unique SKU, price in cents, stock)
await db.insert(productVariants).values([
{ productId: tshirt.id, sku: "TSHIRT-S-BLK", name: "Small / Black", price: 1999, stock: 50 },
{ productId: tshirt.id, sku: "TSHIRT-M-BLK", name: "Medium / Black", price: 1999, stock: 30 },
{ productId: tshirt.id, sku: "TSHIRT-L-BLK", name: "Large / Black", price: 2199, stock: 20 },
{ productId: tshirt.id, sku: "TSHIRT-M-WHT", name: "Medium / White", price: 1999, stock: 25 },
]);
// Second product: Wireless Headphones
const [headphones] = await db
.insert(products)
.values({
name: "Wireless Headphones",
description: "Bluetooth over-ear headphones",
categoryId: electronics.id,
})
.returning();
await db.insert(productVariants).values([
{ productId: headphones.id, sku: "HDPH-BLK", name: "Black", price: 4999, stock: 15 },
{ productId: headphones.id, sku: "HDPH-WHT", name: "White", price: 4999, stock: 10 },
]);
What does this code do?
- Each variant gets a unique SKU like TSHIRT-S-BLK (product-size-color). This is what a barcode scanner reads at the register.
- Prices are integers in cents. 1999 means $19.99 and 4999 means $49.99.
- Stock levels represent how many units are available to sell right now.
- Add the third product, close the function, and add the call that runs it:
// Third product: Phone Case
const [phoneCase] = await db
.insert(products)
.values({
name: "Phone Case",
description: "Protective silicone phone case",
categoryId: accessories.id,
})
.returning();
await db.insert(productVariants).values([
{ productId: phoneCase.id, sku: "CASE-IP15-CLR", name: "iPhone 15 / Clear", price: 1499, stock: 40 },
{ productId: phoneCase.id, sku: "CASE-IP15-BLK", name: "iPhone 15 / Black", price: 1499, stock: 35 },
{ productId: phoneCase.id, sku: "CASE-S24-CLR", name: "Galaxy S24 / Clear", price: 1499, stock: 20 },
]);
console.log("Seeded: 3 categories, 3 products, 9 variants");
}
seed().catch(console.error);
What does this code do?
- The Phone Case has three variants for different phone models and colors.
- The final console.log prints a summary confirming what was seeded.
- seed().catch(console.error) at the bottom runs the function when you execute the file. If anything fails, the error is printed instead of silently swallowed.
- Save src/seed.ts.
✔️ Awesome, I've got everything!
Great. Make sure src/seed.ts is saved and ready to run.
ⓧ I'd like to double check the full code
Your src/seed.ts file has three sections. Scroll up and confirm these are present in order:
- The imports, seed() function opening, three category inserts, and the Classic T-Shirt product insert.
- The T-Shirt variants array (4 variants), the Wireless Headphones product insert, and its variants array (2 variants).
- The Phone Case product insert, its variants array (3 variants), the console.log summary, the closing brace, and seed().catch(console.error).
Run the seed script and verify your data
The schema is in the database and the seed script is ready. Time to populate the tables.
Before you run the script, what do you expect it to print in the terminal?
- Run the seed script by executing:
bun src/seed.ts
You should see two lines printed:
Seeding database...
Seeded: 3 categories, 3 products, 9 variants
That output confirms your database now holds 3 categories (Clothing, Electronics, Accessories), 3 products (Classic T-Shirt, Wireless Headphones, Phone Case), and 9 variants with SKUs, prices in cents, and stock levels.
Seeing a "table not found" error?
This means the schema was not pushed to the database. Run bunx drizzle-kit push first, confirm it succeeds, then run bun src/seed.ts again.
If you get a unique constraint error, the seed has already run once. Delete pos.db from your project root, re-run bunx drizzle-kit push, then run the seed again.
Ask for help: My seed script is failing with an error. Help me debug.
Your database is loaded with a realistic product catalog. Next up, you will build the API endpoints that let a POS terminal browse this catalog and display what is available for sale.
Build the Product Catalog API
Your database is seeded with products, variants, and categories. The next question is: how does a POS terminal actually display what's available for sale?
In this step, you'll build the REST API endpoints that serve your product catalog. These are the first routes any POS client calls to load inventory, and they'll give you a testable result you can hit with curl right away.
In this step, get ready to:
- Create the product routes file with a list-all-products endpoint.
- Add a create-product endpoint with Zod validation.
- Add a single-product lookup endpoint.
Create the product routes file and list all products
Every product in your database has variants, and a POS client needs both together. The GET endpoint will query all products, then nest each product's variants inside it before returning the response.
- In VS Code's Explorer sidebar, right-click on the src folder.
- Select New Folder and name it routes.
- Right-click on the new routes folder and select New File.
- Name the file products.ts.
- Add the following code to src/routes/products.ts to set up the imports and GET handler:
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { db } from "../db";
import { products, productVariants } from "../db/schema";
import { eq } from "drizzle-orm";
const app = new Hono();
// Return all products with their variants nested inside
app.get("/", async (c) => {
const allProducts = await db.select().from(products);
const result = [];
for (const product of allProducts) {
const variants = await db
.select()
.from(productVariants)
.where(eq(productVariants.productId, product.id));
result.push({ ...product, variants });
}
return c.json(result);
});
export default app;
What does this code do?
- The imports pull in everything the file needs: Hono for routing, Zod and zValidator for request validation (used later), Drizzle ORM helpers for database queries, and the schema tables.
- The GET handler queries every product, then for each product it queries the productVariants table filtered by productId.
- Each product gets its variants attached before being pushed into the result array, so the response is a flat list of products with nested variant arrays.
- Save src/routes/products.ts.
The routes file is ready, but Hono doesn't know about it yet. You need to mount it on the main app.
- Switch back to src/index.ts.
- Replace the entire contents of the file with this updated version that imports and mounts the products router:
import { Hono } from "hono";
import products from "./routes/products";
const app = new Hono();
app.get("/", (c) => c.json({ message: "POS API is running" }));
// Mount the products router at /api/products
app.route("/api/products", products);
export default app;
What does app.route() do?
app.route("/api/products", products) mounts the products sub-app at the /api/products path. Every route you define inside src/routes/products.ts is now accessible under that base path.
- Save src/index.ts.
- Start the dev server by running this command in your terminal:
bun run dev
- Open a second terminal (your dev server is using the first one).
- Test the product catalog endpoint by running:
curl http://localhost:3000/api/products
You should see a JSON array of three products. Each product has a nested variants array showing the SKU, name, price, and stock for each variant.
Not seeing any products?
- Make sure you saved both src/routes/products.ts and src/index.ts before testing.
- Check that the dev server is still running in your first terminal. If it crashed, restart it with bun run dev.
- If you see an empty array [], the seed script may not have run successfully in the previous step. Re-run bun src/seed.ts to populate the database.
- Still stuck? Help me debug why GET /api/products returns no data.
✔️ Awesome, I've got everything!
Great. Double check you've saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the full src/index.ts at this point:
import { Hono } from "hono";
import products from "./routes/products";
const app = new Hono();
app.get("/", (c) => c.json({ message: "POS API is running" }));
app.route("/api/products", products);
export default app;
Your src/routes/products.ts should have the six imports at the top, the app.get("/") handler that loops through products and attaches variants, and export default app at the bottom.
Add the create product endpoint with validation
A POS system also needs a way to add new products to the catalog. Zod validates the incoming request body so invalid data never reaches the database.
- In src/routes/products.ts, find the export default app; line at the bottom.
- Add the following Zod validation schema above that line:
// Define the shape of a valid create-product request
const createProductSchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
categoryId: z.number().int().positive().optional(),
variants: z
.array(
z.object({
sku: z.string().min(1),
name: z.string().min(1),
price: z.number().int().positive(),
stock: z.number().int().min(0).optional(),
})
)
.min(1),
});
What does this schema enforce?
- name is a required string with at least one character.
- description and categoryId are optional.
- variants is a required array with at least one variant. Each variant needs a SKU, name, and price (positive integer in cents). Stock defaults to 0 if omitted.
- If a request doesn't match this shape, Hono rejects it before your handler runs.
- Now add the POST handler below the schema you just added (and still above export default app;):
// Create a new product with its variants in one request
app.post("/", zValidator("json", createProductSchema), async (c) => {
const body = c.req.valid("json");
// Insert the product record
const [product] = await db
.insert(products)
.values({
name: body.name,
description: body.description,
categoryId: body.categoryId,
})
.returning();
// Insert all variants linked to the new product
const insertedVariants = await db
.insert(productVariants)
.values(
body.variants.map((v) => ({
productId: product.id,
sku: v.sku,
name: v.name,
price: v.price,
stock: v.stock ?? 0,
}))
)
.returning();
return c.json({ ...product, variants: insertedVariants }, 201);
});
What does this handler do?
- zValidator("json", createProductSchema) runs the Zod schema as middleware. If validation fails, Hono returns a 400 error automatically.
- c.req.valid("json") gives you the validated, typed request body.
- The handler inserts the product first, then maps over the variants array to insert each one linked to the new product's ID.
- stock ?? 0 defaults stock to 0 if the request didn't include it.
- Save src/routes/products.ts.
- Test the create endpoint by running this in your second terminal:
curl -X POST http://localhost:3000/api/products \
-H "Content-Type: application/json" \
-d '{"name": "Water Bottle", "variants": [{"sku": "BOTTLE-BLU", "name": "Blue", "price": 999, "stock": 25}]}'
You should see a 201 response with the new product and its variant, including the auto-generated IDs.
- Confirm the product was actually added to your catalog by running:
curl http://localhost:3000/api/products
You should now see four products in the response. The Water Bottle you just created appears alongside the three seeded products.
Seeing a validation error?
- Make sure the JSON body has a variants array with at least one object. The schema requires it.
- Check that price is a positive integer (in cents), not a decimal. 999 is correct, 9.99 will fail validation.
- On Windows, single quotes around the -d JSON may not work in PowerShell. Use double quotes and escape the inner quotes, or use a tool like Postman instead.
- Still stuck? Help me debug why my POST /api/products request is failing.
Add the single product lookup
A POS client often needs to display details for one specific product. This endpoint takes a product ID from the URL and returns that product with its variants, or a 404 if it doesn't exist.
- In src/routes/products.ts, add the following handler below the POST handler (and above export default app;):
// Look up a single product by ID with its variants
app.get("/:id", async (c) => {
const id = Number(c.req.param("id"));
const [product] = await db
.select()
.from(products)
.where(eq(products.id, id));
if (!product) {
return c.json({ error: "Product not found" }, 404);
}
const variants = await db
.select()
.from(productVariants)
.where(eq(productVariants.productId, id));
return c.json({ ...product, variants });
});
What does this handler do?
- c.req.param("id") extracts the :id path parameter from the URL. It comes as a string, so Number() converts it to an integer for the database query.
- If no product matches the ID, the handler returns a 404 with an error message instead of an empty object.
- The variant query is identical to the list endpoint. Both nest variants inside the product response.
- Save src/routes/products.ts.
Before you test this, what do you expect to see when you request product ID 1?
- Test the single product lookup by running:
curl http://localhost:3000/api/products/1
You should see the Classic T-Shirt product with its four variants (Small / Black, Medium / Black, Large / Black, Medium / White).
- Also confirm the 404 works by requesting a product that doesn't exist:
curl http://localhost:3000/api/products/999
You should see {"error":"Product not found"} with a 404 status.
Getting an unexpected response?
- If you see the full list of products instead of one, check that the /:id route is defined after the / route in your file. Route order matters.
- Make sure you're using the correct URL path: /api/products/1, not /products/1.
- Still stuck? Help me debug my GET /api/products/:id route.
✔️ Awesome, I've got everything!
Great. Make sure src/routes/products.ts is saved with all three handlers.
ⓧ I'd like to double check the full code
Your src/routes/products.ts should have the following sections in this order. Scroll up and confirm each one is present:
- Six import lines at the top: Hono, zValidator, z, db, schema tables, and eq.
- const app = new Hono(); creating the sub-app.
- app.get("/") handler that queries all products and nests variants.
- createProductSchema Zod schema with name, description, categoryId, and variants array.
- app.post("/") handler that inserts the product and its variants, returning a 201.
- app.get("/:id") handler that returns a single product with variants or a 404.
- export default app; at the very bottom.
Your product catalog API is live. You can list products, create new ones with validated data, and look up individual products by ID. Next up, you'll build the checkout flow that turns these products into actual sales.
Build the Checkout Flow
Your product catalog API can display everything on the shelf, but it cannot actually sell anything yet. The core action of any POS system is the checkout: a customer hands you a cart of items, you look up each one, calculate the total, and record the sale.
In this step, you will build a checkout endpoint that accepts a list of variant IDs and quantities, calculates line-by-line totals, and saves the order. There is a catch, though. You will discover that the checkout has a gap that lets stock go negative.
In this step, get ready to:
- Create an orders route file and wire it to /api/orders.
- Build a POST handler that processes a cart of items, calculates totals, and records the sale.
- Test the checkout and discover a stock validation bug.
Create the orders route and wire it up
Just like the products route, you need a dedicated file for order-related endpoints. The first thing to define is the shape of a valid checkout request using Zod.
- Create a new file at src/routes/orders.ts and add the following code:
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { db } from "../db";
import { orders, orderItems, productVariants, products } from "../db/schema";
import { eq } from "drizzle-orm";
const app = new Hono();
// Define the shape of a valid checkout request
const checkoutSchema = z.object({
items: z
.array(
z.object({
variantId: z.number().int().positive(),
quantity: z.number().int().positive(),
})
)
.min(1),
});
export default app;
What does checkoutSchema validate?
The schema enforces that every checkout request has an items array with at least one item. Each item must include a variantId (which variant to buy) and a quantity (how many). Both must be positive integers.
Notice you send variant IDs, not product IDs. Variants are what the cashier actually scans and sells, because each variant has its own SKU, price, and stock count.
Now wire this new route file into your main app, just like you did for the products route.
- In src/index.ts, find this import line at the top of the file:
import products from "./routes/products";
- Add this import directly below it:
import orders from "./routes/orders";
- Then find this line:
app.route("/api/products", products);
- Add this line directly below it:
app.route("/api/orders", orders);
- Save both src/routes/orders.ts and src/index.ts.
- Check your terminal. The server should reload without any errors.
Seeing an import error in the terminal?
Make sure the file is saved at exactly src/routes/orders.ts. The import path ./routes/orders must match the folder structure.
Also confirm that src/routes/orders.ts has export default app; at the bottom.
Why is my orders route not loading?
Process the cart and look up each variant
The checkout handler needs to do several things: look up each variant the customer wants, find the parent product name (so receipts stay accurate even if a product is renamed later), and calculate line totals. You will build this handler in sections.
- In src/routes/orders.ts, find the line export default app; and add the following code above it:
app.post("/", zValidator("json", checkoutSchema), async (c) => {
const { items } = c.req.valid("json");
const lineItems = [];
let subtotal = 0;
// Look up each variant and build line items
for (const item of items) {
const [variant] = await db
.select()
.from(productVariants)
.where(eq(productVariants.id, item.variantId));
if (!variant) {
return c.json({ error: `Variant ${item.variantId} not found` }, 400);
}
const [product] = await db
.select()
.from(products)
.where(eq(products.id, variant.productId));
// Calculate line total and add to running subtotal
const lineTotal = variant.price * item.quantity;
subtotal += lineTotal;
lineItems.push({
variantId: variant.id,
productName: product.name,
variantName: variant.name,
quantity: item.quantity,
unitPrice: variant.price,
lineTotal,
});
}
Why snapshot the product and variant names?
The handler stores productName and variantName directly in the line item, instead of just referencing the product by ID. This is a snapshot. If you rename "Classic T-Shirt" to "Premium T-Shirt" next month, old receipts still show what the customer actually bought at the time of sale.
Save the order and deduct stock
The for loop has built the line items and subtotal. Now you need to create the order record, insert all the line items, and deduct the purchased quantities from stock.
- In src/routes/orders.ts, add this code immediately after the closing } of the for loop you just wrote:
// Generate a unique order number
const orderNumber = `ORD-${Date.now()}`;
const total = subtotal;
// Insert the order record
const [order] = await db
.insert(orders)
.values({
orderNumber,
subtotal,
total,
createdAt: new Date().toISOString(),
})
.returning();
// Insert all order items with snapshot fields
const insertedItems = await db
.insert(orderItems)
.values(
lineItems.map((li) => ({
orderId: order.id,
variantId: li.variantId,
productName: li.productName,
variantName: li.variantName,
quantity: li.quantity,
unitPrice: li.unitPrice,
lineTotal: li.lineTotal,
}))
)
.returning();
What does this code do?
The order number uses ORD- plus a timestamp, giving each order a unique identifier like ORD-1724012345678. The total is set equal to subtotal for now. Discounts will make them differ later.
Each order item is inserted with its snapshot fields (productName, variantName, unitPrice) captured at the time of sale, so the receipt is frozen in time.
- Add this final section immediately after the .returning(); of the order items insertion:
// Deduct stock for each purchased variant
for (const item of items) {
const [variant] = await db
.select()
.from(productVariants)
.where(eq(productVariants.id, item.variantId));
await db
.update(productVariants)
.set({ stock: variant!.stock - item.quantity })
.where(eq(productVariants.id, item.variantId));
}
return c.json({ ...order, items: insertedItems }, 201);
});
What does the stock deduction do?
After the order is saved, the handler loops through each purchased item and subtracts the quantity from the variant's stock column. If a variant had 50 units and the customer bought 2, stock becomes 48.
Notice there is no check that stock is actually sufficient before deducting. Keep that in mind as you test.
- Save src/routes/orders.ts.
- Check your terminal. The server should reload without errors.
Seeing a syntax error?
Make sure the three code sections you added are all inside the same app.post handler. The closing }); of the handler should appear just above export default app;.
Check that you have matching curly braces for the for loops and the if (!variant) block.
Why am I getting a syntax error in my orders route?
✔️ Awesome, I've got everything!
Great. Double check you have saved both src/routes/orders.ts and src/index.ts.
ⓧ I'd like to double check the full code
Your src/index.ts should look like this:
import { Hono } from "hono";
import products from "./routes/products";
import orders from "./routes/orders";
const app = new Hono();
app.get("/", (c) => c.json({ message: "POS API is running" }));
app.route("/api/products", products);
app.route("/api/orders", orders);
export default app;
Your src/routes/orders.ts is longer than 30 lines, so scroll up and confirm you have each section in this order:
- Imports for Hono, zValidator, z, db, schema tables, and eq.
- The checkoutSchema Zod definition with items array containing variantId and quantity.
- The app.post handler with the variant/product lookup for loop.
- Order number generation, order record insertion, and order items insertion.
- Stock deduction loop, return c.json, closing });, and export default app;.
Test the checkout and find the bug
Time to see the checkout in action. You will create an order for two items, check that the response looks correct, and then push the system to reveal a gap.
Before you run the first checkout, think about what you expect to see. The request sends variant ID 1 (Small / Black T-Shirt at 1999 cents) with quantity 2, and variant ID 5 (Black Headphones at 4999 cents) with quantity 1. What should the subtotal be?
- Create your first order by running this command in a new terminal:
curl -X POST http://localhost:3000/api/orders \
-H "Content-Type: application/json" \
-d '{"items": [{"variantId": 1, "quantity": 2}, {"variantId": 5, "quantity": 1}]}'
You should see a JSON response containing an order with a unique orderNumber (like ORD-1724012345678), a subtotal of 8997 (that is $89.97 in cents: 1999 x 2 + 4999 x 1), and two line items with their snapshot fields.
The checkout works. Now let's break it. What happens if you run the exact same checkout again?
- Run the same command again to create a second order with the same items and quantities:
curl -X POST http://localhost:3000/api/orders \
-H "Content-Type: application/json" \
-d '{"items": [{"variantId": 1, "quantity": 2}, {"variantId": 5, "quantity": 1}]}'
It succeeds again. But should it? The seed data gave the Small / Black T-Shirt a stock of 50. After two orders of 2 each, stock should be 46. That seems fine. Let's push harder.
- Try buying 100 of the same variant by running this command:
curl -X POST http://localhost:3000/api/orders \
-H "Content-Type: application/json" \
-d '{"items": [{"variantId": 1, "quantity": 100}]}'
It still succeeds. The checkout happily processed an order for 100 units even though only 46 remained. Now check the product to see the damage.
- Check the stock of the Classic T-Shirt by running:
curl http://localhost:3000/api/products/1
Look at the stock field for the Small / Black variant. It has gone negative. The checkout deducted 100 from a stock of 46, leaving -54.
Why is negative stock a problem?
A real POS system that allows negative stock means you are selling items you do not have. Customers get charged for products that cannot be fulfilled. The checkout handler blindly deducts stock without checking whether enough is available first.
This is the exact bug you will fix in the next step by adding stock validation before creating the order.
Your checkout endpoint processes orders, calculates totals, records line items with snapshot fields, and deducts stock. But it has a real vulnerability: no stock validation. In the next step, you will close that gap and add order history so you can look up past sales.
Add Stock Validation and Sales History
Your checkout endpoint works, but it has a serious flaw. In the last step, you saw stock go negative when you placed repeated orders. A real POS system must never sell more than it has on hand.
This step closes that gap by adding a stock validation guard that rejects any order exceeding available inventory. You will also build order history endpoints so you can look up past sales as receipts.
In this step, get ready to:
- Add a stock validation check that prevents overselling.
- Build order history endpoints that return receipt-style details.
- Test the full checkout flow with valid and invalid orders.
Add the stock validation check
The checkout handler already looks up each variant before processing it. The fix is to compare the variant's current stock against the requested quantity right after that lookup. If stock is too low, the endpoint returns a 400 error before any order is created.
- In src/routes/orders.ts, find the variant not-found check inside the for loop. It looks like this:
if (!variant) {
return c.json({ error: `Variant ${item.variantId} not found` }, 400);
}
const [product] = await db
- Add the following stock validation check between the closing } of the not-found check and the const [product] line:
// Reject the order if requested quantity exceeds stock
if (variant.stock < item.quantity) {
return c.json(
{
error: `Insufficient stock for "${variant.name}". Available: ${variant.stock}, requested: ${item.quantity}`,
},
400
);
}
What does this code do?
This guard runs for every item in the cart before the order is created. If any single variant does not have enough stock, the entire checkout is rejected immediately.
The error message names the exact variant and shows both the available stock and the requested quantity. This gives the POS client enough information to display a helpful message to the cashier.
- Save src/routes/orders.ts.
Before you test, think about what should happen if you try to order 9999 units of a variant that only has about 50 in stock. Will the order go through, or will you see something different?
- Test the stock validation by attempting to order more than the available stock:
curl -X POST http://localhost:3000/api/orders \
-H "Content-Type: application/json" \
-d '{"items": [{"variantId": 1, "quantity": 9999}]}'
You should see a 400 response with an error message like Insufficient stock for "Small / Black". Available: 46, requested: 9999. The exact available number depends on how many orders you placed in the previous step. The key thing is that the order was rejected instead of driving stock negative.
Order went through instead of being rejected?
- Make sure you saved src/routes/orders.ts after adding the stock check. If your server uses hot reloading, the change picks up automatically on save.
- Check that the if (variant.stock < item.quantity) block is inside the for loop and placed after the not-found check but before the product lookup.
- Still stuck? Help me debug why my stock validation check is not working in my Hono route.
Build the order history endpoints
A POS system needs to look up past sales. Cashiers pull up receipts for returns. Managers review daily totals. These two GET endpoints make that possible.
- In src/routes/orders.ts, scroll to the bottom of the file. Find the closing }); of the POST handler (the line right before export default app;).
- Add the following two GET endpoints between that closing }); and export default app;:
// List all orders
app.get("/", async (c) => {
const allOrders = await db.select().from(orders);
return c.json(allOrders);
});
// Get a single order with its line items
app.get("/:id", async (c) => {
const id = Number(c.req.param("id"));
const [order] = await db
.select()
.from(orders)
.where(eq(orders.id, id));
if (!order) {
return c.json({ error: "Order not found" }, 404);
}
// Fetch all line items for this order
const items = await db
.select()
.from(orderItems)
.where(eq(orderItems.orderId, id));
return c.json({ ...order, items });
});
What do these endpoints do?
- GET / returns every order in the database. A manager could use this to review all sales.
- GET /:id returns a single order with all of its line items. Each line item includes productName, variantName, quantity, unitPrice, and lineTotal. This is the receipt view.
- If the order ID does not exist, the endpoint returns a 404 so the client knows it was not found.
- Save src/routes/orders.ts.
- Test the order history by fetching the receipt for your first order:
curl http://localhost:3000/api/orders/1
You should see a JSON object containing the order details plus an items array. Each item includes productName, variantName, quantity, unitPrice, and lineTotal. This is everything a printed receipt would show.
Seeing "Order not found"?
- If you did not create any orders in the previous step, there is no order with ID 1 yet. Create a valid order first by running the POST command from Step 3, then try the GET request again.
- Make sure the two new app.get handlers are placed before the export default app; line.
- Still stuck? Help me figure out why my GET /api/orders/:id endpoint is not returning order data.
Test the full checkout flow
Your POS backend now has stock validation and order history. Time to run the full flow end to end and confirm every piece works together.
Before you run the next three commands, predict what will happen for each. Will the first order succeed? What about the second one requesting 9999 units? What will the receipt look like?
- Create a valid order by running:
curl -X POST http://localhost:3000/api/orders \
-H "Content-Type: application/json" \
-d '{"items": [{"variantId": 1, "quantity": 1}]}'
You should see a 201 response with the order object, including an orderNumber, a subtotal, and an items array with your line item.
- Now try an order that exceeds the available stock:
curl -X POST http://localhost:3000/api/orders \
-H "Content-Type: application/json" \
-d '{"items": [{"variantId": 1, "quantity": 9999}]}'
This time you should see a 400 error with the message Insufficient stock for "Small / Black". Available: X, requested: 9999 where X is the current stock level. The order was blocked before it was created.
- Retrieve the receipt for your first order by running:
curl http://localhost:3000/api/orders/1
You should see the full receipt: the order details plus an items array where each line item shows productName, variantName, quantity, unitPrice, and lineTotal. Those snapshot fields mean this receipt stays accurate even if the product name changes later.
Why snapshot fields matter for receipts
The productName and variantName on each order item were captured at the moment of sale, not looked up from the products table. If a store renames "Classic T-Shirt" to "Premium T-Shirt" next week, every past receipt still shows exactly what the customer bought.
Getting unexpected stock errors on valid orders?
If your database has negative stock from the previous step's testing, even small orders can be rejected. Reset your data by deleting the database, re-pushing the schema, and re-seeding:
rm pos.db && bunx drizzle-kit push && bun src/seed.ts
Then try the checkout commands again. Help me debug stock validation issues in my POS checkout.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/routes/orders.ts with all three handlers: the POST checkout with stock validation, GET for listing orders, and GET /:id for the receipt view.
ⓧ I'd like to double check the full code
The full src/routes/orders.ts file is over 30 lines, so scroll through your file and confirm each section is present in this order:
- Imports at the top: Hono, zValidator, z, db, schema tables (orders, orderItems, productVariants, products), and eq.
- checkoutSchema with items array validation.
- POST / handler with the variant lookup, the stock validation check (if (variant.stock < item.quantity)), product lookup, line total calculation, order/items insertion, and stock deduction.
- GET / handler that returns all orders.
- GET /:id handler that returns a single order with its line items, or a 404.
- export default app; at the very end.
Your POS backend is complete. You can manage a product catalog, process checkouts with stock protection, and pull up any past sale as a receipt. Next up, a secret mission to add discount support to your checkout flow.
Secret mission
Add Discount Support
Your POS checkout works, but every real register needs a way to apply discounts. Can you add percentage and flat discount support to the checkout flow, store the discount on each order, and make sure receipts reflect the reduction?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Running Bun dev server (the terminal process serving your API on http://localhost:3000).
- POS project files (src/db/, src/routes/, src/seed.ts, src/index.ts, drizzle.config.ts).
- SQLite database file (pos.db) and generated Drizzle migrations folder (drizzle/).
Keep everything running
No action needed. Choose this if you are still actively building on your POS backend or want to keep experimenting with the API.
- Your dev server continues running in the terminal. You can send requests to it any time.
- Your pos.db file retains all seeded products, variants, and any orders you created during testing.
Pause - I'll come back to this later
Stop the running dev server to free up the terminal. Your project files and database stay on disk so you can pick up where you left off.
- Switch to the terminal where your dev server is running.
- Press Ctrl+C to stop the server.
- When you are ready to resume, navigate back to your project folder in the terminal and run:
bun run dev
Everything will be exactly as you left it.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your code, database, and all seeded data permanently.
- Switch to the terminal where your dev server is running.
- Press Ctrl+C to stop the server.
- Navigate one level above your project folder by running:
cd ..
- Delete the project folder. Replace your-project-folder with the actual name of the folder you have been working in.
On macOS or Linux:
rm -rf [[PROJECT_FOLDER="your-project-folder"]]
On Windows (PowerShell):
Remove-Item -Recurse -Force [[PROJECT_FOLDER="your-project-folder"]]
- This removes all project files, node_modules, the pos.db database, and the drizzle/ migrations folder.
Nice Work!
Nice Work!
You just built a fully functional retail POS backend from scratch. Your API can manage a product catalog with variants, process multi-item checkouts with stock validation, and return receipt-style order history.
Here's what you learned:
- Designed a POS database schema using the product-variant pattern, where each variant holds its own SKU, price, and stock independently from the parent product.
- Built a checkout endpoint that processes multi-item orders, calculates line and order totals, validates stock to prevent overselling, and snapshots product details at time of sale so receipts stay accurate.
- Created sales history endpoints that return receipt-style order details with product names, variant info, quantities, and prices.
- Secret Mission: Added discount support to the checkout flow with percentage and flat discount types.
Ready to quiz yourself?