Commit Graph

3 Commits

Author SHA1 Message Date
Suriya
ec672a3087 Add an HTTP heartbeat, and file bills under the till that rang them
Two faults found while checking whether today's live bills had landed. They
had — 17 of them, complete — but both of these were sitting in the same data.

**Health existed only over MQTT.** The consumer subscribes to the health topic
and has done since startup, but a terminal on the HTTP route has no way to
reach it. Today's terminal was on HTTP, so it reported nothing and the board
showed "online 0 of 1" while the till was demonstrably alive and selling.
POST /pos/health now takes the same payload the broker carries, into the same
Redis record, so the board cannot tell the two routes apart and does not need
to. It answers 202 and swallows failures: a till that cannot say how it is must
still sell.

**terminalid was empty on 16 of 17 bills.** The consumer backfills a missing
terminal code from the topic, but onto the batch, while the row was built from
the order — the two never met, and importPosOrder was not handed the batch's
value at all. Over HTTP there was no topic to fall back on either. So the
invoice numbers read INV-2608-T5EDD-000NN while the column they should have
matched was blank, and `byterminal` on the sales summary grouped almost
everything under "". The bill's own terminal now wins with the batch's as the
fallback, trimmed, so whitespace is not mistaken for a code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:48:24 +05:30
Suriya
fc81df14e4 Answer the catalogue as a delta when the terminal sends a revision
Every pull was a full snapshot, so a shop with a thousand products
re-sent all of them to correct one price. The response now carries a
revision the terminal stores and hands back, and a pull that supplies
one gets only what moved: the product row, its row at that outlet, or
its stock ledger. Stock is included because a shop's count drifts from a
till's on every sale rung at another counter, and a delta that ignored
it would let that drift persist until someone forced a full pull.

The dangerous part is the flag, not the filter. A response marked
is_delta:false tells the terminal to withdraw every product it does not
mention — so a filtered result carrying that label empties the shelf.
Both are now derived from one value, and there is no path through the
function that filters without also setting the flag.

Everything ambiguous resolves toward the snapshot. A revision that is
malformed, empty, or issued to another outlet yields a zero cutoff and a
complete response; the opposite would leave a terminal permanently
missing changes with nothing to show for it. The revision advances only
on the final page, so a terminal that abandons a paginated pull cannot
end up holding one that claims it saw pages it never received. And the
stamp is taken a second in the past, because a product written during
the same second the query ran would otherwise fall on the wrong side of
the next cutoff and be skipped for good.

A delta still cannot withdraw a deleted product — removing a row from
productlocations leaves no tombstone — so a periodic pull without a
revision is what collects those.

Verified against the live outlet: a full pull of 12, a delta returning
only the one product whose price had changed, and pagination that stays
exact now that productid <= 0 is excluded in SQL rather than after the
LIMIT.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 19:26:01 +05:30
Suriya
e3459a0f1c Ingest counter sales from the POS terminals, over MQTT and HTTP
A till holds every bill in its own SQLite database and keeps it for
seven days after we acknowledge it, marking one synced only when its id
comes back in an ack. Everything here follows from that.

Silence is not acceptance, so a failing ingest publishes nothing at all
and the terminal simply sends again. A duplicate is a success, because
at-least-once delivery means a lost ack legitimately re-delivers bills
we already hold, and calling those failures would strand a day of
takings on the till. Deduplication is a unique index on the terminal's
UUID plus an advisory lock held for the transaction.

Bills land in pos_orders / pos_order_items rather than orders: a counter
bill carries a cashier, a terminal, a rounding adjustment, promos,
loyalty movement and a payment split that orders has nowhere to put, and
forcing one into the other loses whatever does not fit. Stock is *not*
split — a counter sale writes the same productstocks rows an app order
does, through helpers extracted from createOrderTx so the rule that
prevents overselling has one implementation rather than two.
GetRevenueSummary and GetSalesSummary were extended to union the new
table in; any new report has to remember the same.

Terminal health goes to Redis under a 90-second TTL, sharing the
instance the express backend uses. A heartbeat is a fact with an expiry
date: a till that loses power stops refreshing and ages off the board by
itself, where a Postgres row would need ~288k writes a day and a reaper.

Proven end to end against the live estate before commit: a bill over
HTTP and one over the real Mosquitto broker, the same bill three times
producing one row and one stock movement, and a heartbeat arriving on
the health endpoint. All probe data was removed afterwards.

Four things that only surfaced against real data. An unset jsonb column
failed the very first bill. Product SKUs are unusable as barcodes — 6,245
products share 93 SKUs and "1" covers 5,794 of them — against the till's
unique index, so barcodes fall back to the product id. A taxpercent of
-1 exists and would have put negative GST in a filed slab. And a product
with id 0 exists, which can never be billed and is now skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:48:00 +05:30