In part 1 we settled the architecture: the till owns what happened, the server owns what it means. Now the schema — and it starts with the decision most POS systems get wrong.
Never store a stock level
The instinct is a column:
-- Do not do this.
CREATE TABLE products (
id uuid PRIMARY KEY,
name text NOT NULL,
stock integer NOT NULL DEFAULT 0
);Then every sale does UPDATE products SET stock = stock - 1.
This is fine with one till and one connection. It falls apart the moment two tills are offline at the same time, and it fails in a way that cannot be repaired: both send "decrement by 1", the server applies both, and now you have a number that agrees with neither till's history. There is no way to audit it because the intermediate states were never recorded.
Store the movements. Derive the level.
CREATE TABLE stock_movements (
id uuid PRIMARY KEY,
product_id uuid NOT NULL REFERENCES products(id),
quantity integer NOT NULL, -- negative for a sale, positive for a delivery
reason text NOT NULL, -- 'sale' | 'delivery' | 'count' | 'wastage'
sale_id uuid REFERENCES sales(id),
occurred_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);Now the stock level is a question, not a value:
SELECT COALESCE(SUM(quantity), 0) AS on_hand
FROM stock_movements
WHERE product_id = $1;The properties this buys you are worth the trade:
- Order stops mattering. Sum is commutative. Two tills syncing in either order produce the same total.
- Replaying is safe. Movements are append-only and idempotent by id, so a duplicated request changes nothing.
- Every number is explainable. "Why is this 3?" has an answer with
timestamps and a cashier's name on it. On a
stockcolumn the answer is "it just is."
occurred_at is separate from created_at on purpose. A sale that happened at
14:32 offline and reached the server at 18:05 must report 14:32 — that is
when the stock physically left the shop. Reports built on created_at quietly
attribute Tuesday's trade to Wednesday.
The obvious objection
Summing a movements table on every product lookup does not scale. Correct. The answer is a materialised view refreshed after each sync batch — not a mutable column:
CREATE MATERIALIZED VIEW product_stock AS
SELECT p.id AS product_id,
COALESCE(SUM(m.quantity), 0) AS on_hand
FROM products p
LEFT JOIN stock_movements m ON m.product_id = p.id
GROUP BY p.id;
CREATE UNIQUE INDEX ON product_stock (product_id);CREATE UNIQUE INDEX is required for REFRESH MATERIALIZED VIEW CONCURRENTLY,
which lets the refresh run without locking readers — the till must keep
answering while it happens.
The distinction that matters: the view is a cache of a derivable fact. If it
is wrong you rebuild it. A stock column is a fact with no source. If it is
wrong you have lost information permanently.
Sales are append-only facts
CREATE TABLE sales (
id uuid PRIMARY KEY, -- generated on the till
device_id text NOT NULL,
cashier_id uuid NOT NULL REFERENCES users(id),
occurred_at timestamptz NOT NULL, -- when it happened, on the till
received_at timestamptz NOT NULL DEFAULT now(),
subtotal_cents integer NOT NULL,
discount_cents integer NOT NULL DEFAULT 0,
total_cents integer NOT NULL,
currency char(3) NOT NULL DEFAULT 'USD',
payment_method text NOT NULL, -- 'cash' | 'khqr' | 'card'
payment_state text NOT NULL, -- 'paid' | 'pending_verification'
voided_at timestamptz
);
CREATE TABLE sale_lines (
id uuid PRIMARY KEY,
sale_id uuid NOT NULL REFERENCES sales(id) ON DELETE CASCADE,
product_id uuid NOT NULL REFERENCES products(id),
quantity integer NOT NULL CHECK (quantity > 0),
unit_price_cents integer NOT NULL,
line_total_cents integer NOT NULL
);Four decisions in there worth defending.
Money is integer cents. Never a float. 0.1 + 0.2 !== 0.3 is a curiosity in
a blog post and a rounding dispute in a shop. Cambodia's dual USD/KHR
circulation makes this worse, not better — see part 5.
unit_price_cents is copied onto the line. Not looked up from the product.
The price at the moment of sale is part of the fact. If someone edits the
product tomorrow, last week's receipts must not silently change.
Voiding sets voided_at. It never deletes. A void is itself an event, and
it gets a compensating stock movement rather than removing the original.
payment_state exists because of KHQR. A cash sale offline is paid — you
are holding the money. A KHQR sale offline is pending_verification, because
nobody has confirmed anything. Part 5 is entirely about this.
The TypeORM entities
import { Entity, PrimaryColumn, Column, OneToMany, Index } from "typeorm";
import { SaleLine } from "./SaleLine";
@Entity("sales")
export class Sale {
/**
* PrimaryColumn, not PrimaryGeneratedColumn. The till generates this id
* before the server knows the sale exists — that is the whole basis of
* idempotent retries in part 4.
*/
@PrimaryColumn("uuid")
id!: string;
@Column({ name: "device_id" })
deviceId!: string;
@Column({ name: "cashier_id", type: "uuid" })
cashierId!: string;
/** When the sale happened on the till. Reports use this. */
@Index()
@Column({ name: "occurred_at", type: "timestamptz" })
occurredAt!: Date;
/** When it reached the server. Diagnostics only — never report on this. */
@Column({ name: "received_at", type: "timestamptz", default: () => "now()" })
receivedAt!: Date;
@Column({ name: "total_cents", type: "integer" })
totalCents!: number;
@Column({ name: "payment_method" })
paymentMethod!: "cash" | "khqr" | "card";
@Column({ name: "payment_state" })
paymentState!: "paid" | "pending_verification";
@Column({ name: "voided_at", type: "timestamptz", nullable: true })
voidedAt!: Date | null;
@OneToMany(() => SaleLine, (line) => line.sale, { cascade: ["insert"] })
lines!: SaleLine[];
}cascade: ["insert"] matters more than it looks. A sale and its lines must be
written in one transaction — a sale with no lines is corrupt data, and under
retry conditions a partial write is exactly what you would get.
Mirroring the schema on the till
IndexedDB holds a subset. The till needs the catalogue to sell, and its own queue:
import { openDB, type DBSchema } from "idb";
interface TillDB extends DBSchema {
/** Read-only catalogue copy, refreshed whenever the server is reachable. */
products: {
key: string;
value: { id: string; name: string; priceCents: number; barcode?: string };
indexes: { "by-barcode": string };
};
/** Sales made on this device. Never deleted — the local audit trail. */
sales: {
key: string;
value: LocalSale;
indexes: { "by-synced": number };
};
/**
* The outbox: ids awaiting acknowledgement. Separate from `sales` so that
* "what happened" and "what still needs sending" cannot drift apart. A sale
* is never removed from `sales` when it syncs — only from here.
*/
outbox: {
key: string;
value: { saleId: string; attempts: number; lastAttemptAt: number | null };
};
}
export const db = await openDB<TillDB>("till", 1, {
upgrade(database) {
const products = database.createObjectStore("products", { keyPath: "id" });
products.createIndex("by-barcode", "barcode");
const sales = database.createObjectStore("sales", { keyPath: "id" });
// IndexedDB cannot index booleans — 0/1 is the standard workaround.
sales.createIndex("by-synced", "synced");
database.createObjectStore("outbox", { keyPath: "saleId" });
},
});Keeping sales and outbox as separate stores is the single most useful
decision in the client schema. The sale is the record of what happened; the
outbox is a to-do list. Merge them and a bug in sync logic can destroy sales
history. Separate, the worst a sync bug can do is send something twice — and
part 4 makes that harmless.
Reconciling a physical count
Real shops count their stock and find the system wrong. Handle it as a movement, not a correction:
/**
* A stock count does not overwrite anything. It records the difference between
* what the system believed and what the shelf actually holds, as its own
* movement with a reason.
*
* The discrepancy is the valuable number — it is theft, breakage or a scanning
* mistake. Overwriting the level erases the one figure worth looking at.
*/
export async function recordCount(
productId: string,
countedQty: number,
countedBy: string,
) {
const onHand = await currentStock(productId);
const delta = countedQty - onHand;
if (delta === 0) return { adjusted: false, delta: 0 };
await movements.insert({
id: newId(),
productId,
quantity: delta,
reason: "count",
occurredAt: new Date(),
note: `Counted ${countedQty}, system had ${onHand}, by ${countedBy}`,
});
return { adjusted: true, delta };
}Over a few months the count movements become a shrinkage report nobody had to
build.
Next
Part 3 puts this to work on the till: writing a sale to IndexedDB, printing without a server, and the outbox that survives a browser crash mid-sale.
This series documents the approach behind SuiteWright POS. Planning a system with these constraints? Get in touch.





Comments
Loading comments...