WFDASH · Stock & POS Architecture

How stock works today — and how it talks to the POS.

There is one source of truth (stock_ledger) and one denormalised cache (stock_items.current_qty). Every stock movement — buying from a supplier, booking a surplus, fulfilling a customer order, ringing up a sale at the till — writes a ledger row first, then mirrors the qty into the cache. The POS app never touches the cache directly; it goes through three authenticated API endpoints.

1 The single source of truth

Two PocketBase collections carry the entire stock system. The ledger is append-only and signed; the cache is denormalised for fast reads.

stock_items

The catalogue. One row per SKU. Carries all the pricing, supplier linkage, and the running quantity the UI reads.

sku
supplier's code
name / unit
display + uom
current_qty
denormalised cache
min_qty
low-stock trigger
target_qty
auto-reorder goal
cost_price
latest purchase cost
selling_price
POS shelf price
markup
POS markup %
cost_history
append-only list
supplier
FK → suppliers
is_active
soft-hide flag

stock_ledger

The audit trail. Every stock movement — in or out — is a row here with a signed quantity and a reason. The cache is rebuildable from this table alone.

stock_item
FK → stock_items
change_qty
signed (+ / −)
unit_cost
when known
reason
enum (see below)
reference_type
e.g. "order_item"
reference_id
FK to the source
notes
free text + audit tags
created_by
user FK
stock-in paths (qty > 0)
stock-out paths (qty < 0)
POS app surfaces

2 How items get INTO stock

Three entry points. All three end in stock_ledger.reason ∈ {stock_order, purchase_surplus, adjustment}.

Path A

Receive a stock purchase (the accountant's flow)

Warehouse marks the PO received (stock_purchases.status = "received"). Accountant then hits "Book into stock" on the invoicing page.

stock_purchase.received → bookStockFromInvoice() → stock_ledger +qty → stock_items.current_qty + qty → booked_at stamped
src/lib/server/stock-booking-service.ts · /api/v1/invoicing/book

Partial lines supported — each line carries its own quantity_received accumulator.

Path B

Book a surplus from a customer collection

When the supplier delivers more than the customer ordered (e.g. MOQ), the driver records qty_collected on the order item. Operator splits the difference into stock.

order_item.collected → findMatchingStockItem() → stock_ledger +surplus → stock_items.current_qty + surplus
src/lib/server/stock-surplus.ts · /api/v1/stock/surplus-book

Match by SKU → by supplier + normalised description. Operator can override.

Path C

Manual stocktake adjustment

Operator counts the shelf, finds a discrepancy vs. the cache, and posts a signed correction.

/stock/[id] → stock_items.adjust → stock_ledger ±qty (reason: adjustment) → current_qty patched
/api/v1/stock/items/[id]/adjust

Compensation tagging kicks in if the cache write fails after the ledger row lands.

3 How items get OUT of stock

Three exit points. All three write stock_ledger.reason = "customer_fulfillment" with a negative quantity (or "adjustment" for write-offs).

Path D

Fulfil a customer order from stock

Operator on /orders/new ticks "fulfil from stock" for a line. Same route used on /orders/[id]/process to redirect an existing line.

order_item create → item_status = "stock_booked" → stock_ledger −qty → current_qty − qty
src/app/api/v1/stock/fulfill/route.ts

`stock_booked_from` back-link written on the order_item for audit + drill-down.

Path E

POS sale (the till)

External WFSALES POS app (Flutter, separate repo) calls a Bearer POS_API_KEY-protected endpoint, commits a sale, and decrements stock in one transaction.

till checkout → pos_sales + pos_sale_items → stock_ledger −qty (ref: pos_sale) → current_qty − qty
src/lib/server/pos-service.ts · /api/v1/pos/checkout

Idempotent on client_ref — offline till retries don't double-decrement.

Path F

Write-off / manual shrink

Damaged in the warehouse, expired, lost, given to a sample kit. Same /stock/[id]/adjust route as Path C, just a negative qty.

/stock/[id] → stock_items.adjust (−n) → stock_ledger reason: adjustment
/api/v1/stock/items/[id]/adjust

One route handles both directions — the sign of the qty is the difference.

4 How the POS app links in

The WFSALES POS is a separate app (Flutter) that runs on the office till. It only ever talks to WFDASH through three authenticated endpoints under /api/v1/pos/*. The auth is a device-scoped bearer token (POS_API_KEY) — distinct from the agent's key, because the till only needs catalog + checkout + history.

Catalog pull — at till boot, the POS calls the catalog endpoint and caches the active stock list locally. This is what makes the offline queue possible.

GET/api/v1/pos/catalog

Checkout — when the cashier hits "ring up", a single POST creates the sale + line items + the negative ledger rows + decrements the cache. Idempotent on client_ref so a retry of a lost-response sale doesn't double-decrement stock.

POST/api/v1/pos/checkout

History + cashup — the till pulls today's sales for cashup reconciliation and the cashier's shift close.

GET/api/v1/pos/sales?from=…&to=…

Categories in the catalog come from server-side matching (match_brands → match_supplier_tags → match_keywords on the categories collection), not from a relation on the stock row. Tills can read the category icon straight off the response.

5 When do prices get set?

Three pricing fields on a stock row — cost_price, selling_price, markup. Each is set by a different trigger.

Trigger 1 · Receive a stock purchase

cost_price (auto)

When the accountant books a stock purchase, the line's cost_price overwrites the stock row's cost_price and appends an immutable entry to cost_history.

Set by: buildStockReceiptCostPatch() in stock-cost-service.ts
Trigger 2 · Manual edit on /stock

selling_price + markup (manual)

Operator edits the shelf price or markup % directly on the stock detail page. If cost changes, a zero-qty ledger row is written as an audit record (reason: adjustment, ref: manual).

Set by: updateStockItemsPricing() in stock-pricing-service.ts
Trigger 3 · AI / Hermes agent

selling_price (autonomous)

External agent (Hermes) keeps the catalogue in sync with supplier price lists — e.g. when a price list PDF re-baselines, it PATCHes the selling_price field through the agent API.

Set by: updateStockItem() in stock-items-service.ts (PATCH /api/v1/stock/items)

6 The whole flow, in one picture

Green = stock-in. Orange = stock-out. Purple = POS surfaces. Everything terminates in the same ledger.

flowchart TB
  classDef in fill:#e8f1ed,stroke:#2f6f5e,color:#14171e
  classDef out fill:#faeede,stroke:#c97b3f,color:#14171e
  classDef pos fill:#ede9f8,stroke:#5a4ec9,color:#14171e
  classDef cache fill:#ffffff,stroke:#7c7a72,color:#14171e,stroke-dasharray: 4 3
  classDef truth fill:#f4f1ea,stroke:#14171e,color:#14171e

  subgraph SOURCES["Stock-in sources"]
    direction TB
    SP["stock_purchase
(accountant books)"]:::in SU["customer order
(supplier over-delivers)"]:::in ADJ_IN["manual adjustment
(stocktake correction)"]:::in end subgraph OUTS["Stock-out destinations"] direction TB CO["customer order
(fulfil from stock)"]:::out PSALE["POS sale
(WFSALES till)"]:::pos ADJ_OUT["manual write-off
(damage / loss)"]:::out end LEDGER[("stock_ledger
(append-only, signed qty)")]:::truth CACHE[("stock_items.current_qty
(denormalised cache)")]:::cache POS_APP["WFSALES POS app
(Flutter, separate repo)"]:::pos POS_APP -- GET /api/v1/pos/catalog --> CACHE POS_APP -- POST /api/v1/pos/checkout --> PSALE SP -- "bookStockFromInvoice
reason: stock_order" --> LEDGER SU -- "bookSurplus
reason: purchase_surplus" --> LEDGER ADJ_IN -- "stock_items.adjust
reason: adjustment" --> LEDGER CO -- "stock/fulfill
reason: customer_fulfillment" --> LEDGER PSALE -- "checkoutSale
reason: customer_fulfillment
ref: pos_sale" --> LEDGER ADJ_OUT -- "stock_items.adjust
reason: adjustment" --> LEDGER LEDGER -. "update current_qty" .-> CACHE WFDASH_UI["WFDASH /stock, /stock-orders,
/orders/[id] (operator UI)"] WFDASH_UI -- "reads" --> CACHE WFDASH_UI -- "drills into" --> LEDGER
Stock-in to ledger
Stock-out to ledger
POS-app surfaces
Derived cache (rebuildable from ledger)