Part 2 built a schema where stock is derived and sales are facts. Now the till itself — the part that has to work with the router unplugged.

The rule from part 1, restated because everything here depends on it:

The sale is complete when it is written locally. Not when the server replies.

The sale path

One function, and its ordering is the whole design:

src/client/sales/completeSale.ts
ts
import { db } from "../db/schema";
import { newId } from "../../shared/id";
import { printReceipt } from "../printing/receipt";
import { scheduleSync } from "../sync/worker";

/**
 * Completes a sale. Deliberately does NOT await the network.
 *
 * Order matters: persist, then print, then attempt sync. If the browser dies
 * between any two steps the sale still exists, and the outbox will find it on
 * next launch.
 */
export async function completeSale(input: {
  lines: { productId: string; quantity: number; unitPriceCents: number }[];
  paymentMethod: "cash" | "khqr" | "card";
  cashierId: string;
  deviceId: string;
}) {
  const subtotal = input.lines.reduce(
    (sum, l) => sum + l.unitPriceCents * l.quantity,
    0,
  );

  const sale = {
    id: newId(),
    occurredAt: new Date().toISOString(),
    subtotalCents: subtotal,
    totalCents: subtotal,
    // Cash offline is genuinely paid — the money is in the drawer. KHQR is not
    // verifiable without a connection, so it stays pending. Part 5 covers this.
    paymentState:
      input.paymentMethod === "cash" ? "paid" : "pending_verification",
    synced: 0,
    ...input,
  } as const;

  // 1. Persist. One transaction across both stores so a crash cannot leave a
  //    sale that nothing will ever send.
  const tx = db.transaction(["sales", "outbox"], "readwrite");
  await Promise.all([
    tx.objectStore("sales").add(sale),
    tx.objectStore("outbox").add({
      saleId: sale.id,
      attempts: 0,
      lastAttemptAt: null,
    }),
    tx.done,
  ]);

  // 2. Print. Local, no network involved.
  await printReceipt(sale);

  // 3. Try to sync — fire and forget. Failure is normal, not exceptional.
  void scheduleSync();

  return sale;
}

Note what is absent: no try/catch around the network, no loading state, no error path for "server unreachable". The server is not on the critical path, so it cannot fail the sale.

Why the transaction spans both stores

A single IndexedDB transaction across sales and outbox guarantees you never get one without the other. The failure it prevents is specific and nasty: a sale written, the browser killed before the outbox entry, and a sale that exists on the till forever and never reaches the server. Nobody notices until the monthly figures disagree.

Stock the cashier can trust

The till cannot know true stock while offline — another device may have sold the last one. Rather than pretend, show a local estimate and label it:

src/client/stock/estimate.ts
ts
/**
 * Server level at last sync, minus everything sold on this device since.
 *
 * Deliberately called an estimate. A till that displays a confident number it
 * cannot possibly know teaches cashiers to distrust the whole screen the first
 * time it is wrong.
 */
export async function estimatedStock(productId: string): Promise<{
  value: number;
  confident: boolean;
}> {
  const snapshot = await db.get("stockSnapshot", productId);
  const unsynced = await unsyncedQuantityFor(productId);

  return {
    value: (snapshot?.onHand ?? 0) - unsynced,
    // Confident only when nothing on this device is still queued.
    confident: unsynced === 0,
  };
}

In the UI: 12 in stock when confident, ~12 in stock when not. One character, and it is the difference between a system that is honest about uncertainty and one that is caught guessing.

Do not block a sale on estimated stock. If the screen says zero and the cashier is holding the item, the item wins. Sell it, and let the count movement from part 2 sort out the discrepancy later. A POS that refuses to sell merchandise the customer is physically holding will be abandoned within a week.

Printing without a server

Receipts must render locally. Two workable routes:

Browser printing — build the receipt as HTML sized to the paper and call window.print(). Works everywhere, no drivers, no hardware assumptions. The downside is a print dialog unless the browser is configured with a default printer in kiosk mode.

Direct thermal printing — ESC/POS over WebUSB or a local bridge. Faster and dialog-free, but tied to hardware.

Start with the first. The receipt is just a document:

src/client/printing/receipt.ts
ts
/**
 * Rendered entirely from the local sale object. Nothing here can require a
 * network call — a receipt that needs the server is a receipt that fails at
 * exactly the wrong moment.
 */
export function receiptHtml(sale: LocalSale, shop: ShopInfo): string {
  const money = (cents: number) => `$${(cents / 100).toFixed(2)}`;

  const lines = sale.lines
    .map(
      (l) => `
      <tr>
        <td>${escapeHtml(l.name)}</td>
        <td class="qty">${l.quantity}</td>
        <td class="amt">${money(l.lineTotalCents)}</td>
      </tr>`,
    )
    .join("");

  return `
    <div class="receipt">
      <h1>${escapeHtml(shop.name)}</h1>
      <p class="meta">${new Date(sale.occurredAt).toLocaleString("en-GB")}</p>
      <p class="meta">Ref ${sale.id.slice(0, 8).toUpperCase()}</p>
      <table>${lines}</table>
      <p class="total">${money(sale.totalCents)}</p>
      <p class="meta">${sale.paymentMethod.toUpperCase()}</p>
      ${
        sale.paymentState === "pending_verification"
          ? `<p class="warn">PAYMENT NOT YET VERIFIED</p>`
          : ""
      }
    </div>`;
}

The short reference is the sale's UUIDv7 truncated to eight characters. Enough for a customer to quote when they come back, and it exists offline because the till generated it.

That PAYMENT NOT YET VERIFIED line is not decoration. If a KHQR payment was taken offline and later fails to verify, this receipt is the only evidence that the shop knew it was provisional.

The outbox worker

Sync runs in the background, and its job is to be relentless without being destructive:

src/client/sync/worker.ts
ts
import { serverReachable } from "../net/reachable";
import { db } from "../db/schema";

const BATCH_SIZE = 50;
const BASE_BACKOFF_MS = 2_000;
const MAX_BACKOFF_MS = 5 * 60_000;

let running = false;

export async function scheduleSync(): Promise<void> {
  // Guard against overlapping runs — two workers pushing the same batch would
  // be safe (part 4 makes it idempotent) but it wastes a connection that is
  // already scarce.
  if (running) return;
  running = true;

  try {
    if (!(await serverReachable(API_BASE))) return;

    while (true) {
      const pending = await db.getAll("outbox", undefined, BATCH_SIZE);
      if (pending.length === 0) break;

      const sales = await Promise.all(
        pending.map((entry) => db.get("sales", entry.saleId)),
      );

      const res = await fetch(`${API_BASE}/sync/sales`, {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ sales: sales.filter(Boolean) }),
      });

      if (!res.ok) {
        await backOff(pending);
        break;
      }

      const { accepted }: { accepted: string[] } = await res.json();

      // Clear only what the server explicitly acknowledged. Anything missing
      // stays queued and is retried — never assume success from a 200 alone.
      const tx = db.transaction(["outbox", "sales"], "readwrite");
      for (const id of accepted) {
        await tx.objectStore("outbox").delete(id);
        const sale = await tx.objectStore("sales").get(id);
        if (sale) await tx.objectStore("sales").put({ ...sale, synced: 1 });
      }
      await tx.done;
    }
  } finally {
    running = false;
  }
}

Three properties to keep when you adapt this:

  • Clear on explicit acknowledgement, never on HTTP 200. A proxy can return 200 for a request the application never processed.
  • Sales are marked synced, not deleted. The till keeps its own history.
  • Back off exponentially, cap it. A till hammering a dead server every two seconds for a whole day drains battery and fills logs.
src/client/sync/backoff.ts
ts
export function nextDelay(attempts: number): number {
  const exponential = BASE_BACKOFF_MS * 2 ** attempts;
  // Jitter stops twenty tills in one shopping centre retrying in lockstep the
  // instant the internet returns, which is its own outage.
  const jitter = Math.random() * 1000;
  return Math.min(exponential + jitter, MAX_BACKOFF_MS);
}

The jitter matters more than it appears. Without it, every device that lost connection at the same moment retries at the same moment — and you have built a small, self-inflicted denial of service against your own API.

When to trigger a sync

  • After every sale (fire and forget)
  • On window.online
  • Every 60 seconds while anything is queued
  • On app launch — this one catches the crash-mid-sale case

Next

Part 4 is the server side: accepting a batch that may contain sales it has already seen, doing it in one transaction, and handling two tills that both sold the last item.


This series documents the approach behind SuiteWright POS. Talk to us about a build with these constraints.