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:

sql
-- 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.

sql
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:

sql
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 stock column 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:

sql
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

sql
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

src/server/entities/Sale.ts
ts
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:

src/client/db/schema.ts
ts
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:

src/server/stock/recount.ts
ts
/**
 * 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.