Commit Graph

7 Commits

Author SHA1 Message Date
Suriya
33b4337933 Publish under nearle/pos, add a health heartbeat, send the GST slab split
Three changes, all driven by what the back office turned out to need.

The broker is shared with the rider fleet on nearle/riders/…, so topics
move under nearle/pos/{locationid}/{terminal}/… — one ACL rule per
system, and it is obvious from a topic which one owns it. Store ID now
carries the back office's numeric location id; the tenant is resolved
from it server-side and never taken from the wire.

A till publishes a heartbeat every 30 seconds on its own topic. The Last
Will already answers "is it dead", which is not enough to run a hundred
shops on: the failure that costs money is a terminal that is connected,
selling, and quietly holding two hundred bills it has never uploaded. So
the beat carries queue depth, the age of the oldest thing waiting,
today's trading, and printer reachability. Not retained — the back
office holds it under a TTL, and a retained beat would leave an
unplugged till looking alive until something overwrote it.

Bills now carry tax_breakdown, the GST slab split the cart already
computes. A tax return is filed per slab, and recomputing the split
server-side would mean redoing the discount apportionment and getting
exactly the same answer — or else the filed figure stops matching the
paper the shopper was handed.

Docs rewritten against the real deployment: Eclipse Mosquitto 2.1.2, no
NATS anywhere reachable, no TLS, and a broker whose queue and autosave
defaults mean it must not be treated as durable storage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:47:24 +05:30
Suriya
fe428931ec Upload shopper registrations, and charge GST to the lines that earned it
Two defects that share a shape: a figure landing on the wrong record.

Bill-level discounts were apportioned across every line by a single
factor, so "20% off Beverages" pulled tax out of the atta line as well.
The bill total was right either way, which is what made it easy to ship
— only the slab split on a filed return was wrong. Targeted campaigns
now reduce the lines they name, and bill-wide reductions still spread
pro rata, so the arithmetic is unchanged wherever it was already right.

Shoppers registered at a till only ever reached the back office as three
fields riding along on a bill. Somebody who signed up and bought nothing
existed on one terminal and nowhere else, and two tills registering the
same mobile each minted their own row. Customers are now an outbox of
their own on pos/{store}/{terminal}/customer, and the id is a UUIDv5
over the normalised mobile number — so a hundred terminals agree on who
a shopper is without talking to each other.

Registrations go up before bills, and a failure there cannot strand a
day's takings. No loyalty figures are sent: they belong to the bill
stream, which is idempotent and knows about every counter.

Two things found while building it. Numbers were keyed on raw digits, so
a cashier typing +91 forked a shopper as effectively as a random id
would. And the sale path wrote the customer with ConflictAlgorithm
.replace, which is a DELETE and an INSERT — every column absent from the
row reverts to its schema default, so the new sync flag would have been
cleared by the shopper's next purchase.

Schema v8. Existing customers are queued rather than assumed sent: the
terminal cannot tell an imported row from a locally registered one, and
only one of those mistakes loses somebody.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 11:34:27 +05:30
Suriya
467d5eee75 Add an integration guide for wiring a terminal to a back office
sync-contract.md specifies what the back office must implement. It does not
say how to get a terminal talking to one, which is the question anyone
actually deploying this hits first.

Five hops, in the order they should be proved:
1. NATS with the MQTT gateway on — config snippet, narrow per-topic
   permissions, and a file-backed JetStream stream, because a memory stream
   loses a shop's bills on restart after the terminal has been told they
   landed.
2. Pointing the terminal at it from Settings.
3. Watching a bill publish.
4. Consuming, committing, and acknowledging — with the table schema, an
   idempotent insert, and the rule that the ack comes from the consumer after
   the commit rather than from an ingest handler that merely queued the work.
5. The catalogue pull, and the mid-day push that triggers it.

Each hop has a command that proves it works, because a failure at hop 4 looks
identical to a failure at hop 2 from the terminal's side — it just keeps
queueing.

Plus a troubleshooting table mapping symptoms to causes (queue refilling with
the same bills means the ack arrived after the 20s timeout; two terminals
fighting for the connection means they share a client id), and a pre-rollout
checklist: back up the database before the one-way v7 migration, build
per-ABI to cut 69MB to ~23MB, change the seed PINs, turn TLS on.

Every topic, timeout, query parameter and pill label in the guide was checked
against the code rather than written from memory.

README now points at both documents and states the default: the terminal runs
against a local stub until it is configured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 16:06:06 +05:30
Suriya
fbfc02d140 Pull the catalogue from a real endpoint, with delta sync
Replaces the last simulation on the inbound side. RemoteCatalogueSource
returned SeedData after a fake progress bar; there was no wire format, no
endpoint, and no way to receive an update short of reinstalling.

Wire format (data/remote/catalogue_wire.dart)
- Tolerant where it should be: a catalogue of 4,000 products must not fail to
  import over one absent emoji, so optional fields take defaults and an
  unrecognised category files under Grocery — the item still scans, prices and
  bills.
- Strict where it matters: no id, name, barcode or price and the import fails.
  A silently dropped product is a shelf item that scans to nothing, discovered
  with a queue waiting.
- GST accepts 18 or 0.18 and reads both the same. Back offices disagree about
  which they mean, and getting it wrong silently changes the tax on every line.

HTTP source with paging and deltas
- GET {base}/catalogue?since={revision}&page={n}. Paged because a supermarket
  catalogue is tens of thousands of rows: one response times out on a shop's
  line and stalls the UI decoding it. Capped at 200 pages so a bad deployment
  cannot become an infinite request loop against a shop's connection.
- `since` carries the revision already held, so a normal morning fetches a
  handful of price changes rather than the whole book. A server that cannot do
  deltas ignores it and answers is_delta:false — the terminal reads the flag
  rather than assuming, so both work.
- A bad credential is non-retryable and says so, leaving the working catalogue
  in place so billing continues.

Applying deltas without losing local state
- A full snapshot withdraws what it omits; a delta must not. Read as a
  snapshot, the first morning price change would empty the shelf.
- Retired products are marked inactive, not deleted — order lines already
  recorded point at them, and a hard delete would orphan a bill's history.
- Locally registered shoppers survive a pull, as before.
- The unsynced-stock replay is now scoped to the products the pull actually
  overwrote. It exists because a server count predates local sales; running it
  over a delta that never carried that product would subtract those units a
  second time and quietly empty a shelf that is full. Both halves of that rule
  are tested.

MQTT stays the nudge, not the transport: a catalogue push on
pos/{store}/catalogue makes every terminal pull immediately, but the rows come
over HTTP, because a broker is the wrong shape for tens of thousands of them.

Tests: 210 -> 234. docs/sync-contract.md now covers both directions, including
a field-by-field table of what happens when something is missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 15:37:42 +05:30
Suriya
33467e6963 Add a back-office setup dialog, and clear the last lints
Settings > Connectivity > Configure now points a terminal at a back office.
Until this existed a store was wired up by editing syncConfigProvider and
rebuilding, which made every terminal in a fleet its own build.

- Transport picker (offline demo / HTTP / MQTT) with only the relevant fields
  shown, validated: an MQTT route with no host is refused rather than silently
  saved, because a terminal pointed at nothing looks exactly like one that is
  merely offline.
- Terminal name and store id are editable and persist to the database. The
  device id and terminal code are shown but not editable, with a copy button —
  they are what a support call needs, and re-coding a till must not orphan the
  bills already written under the old code.
- TLS defaults on, with a note that bills carry customer names and numbers.
- About card now shows the real terminal, device id and store instead of the
  literal TERM-01, and the dead "Check for updates" button is now the entry
  point to this dialog.

Lints cleared, analyzer now reports zero issues:
- SoundService wrapped a plain bool in a getter and setter that did nothing.
- Two post-await guards used context.mounted inside a State, which the
  analyzer cannot relate to the State's own lifetime. Both are now `mounted`.

Tests: 140 -> 141. The new widget test drives the dialog end to end and asserts
that saving an MQTT route with no host keeps the dialog open with the error
visible. Suite run three times clean.

Known gap, documented in docs/sync-contract.md: broker credentials live in
memory and must be re-entered after a restart. Persisting them means
encrypting at rest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 11:47:11 +05:30
Suriya
30d6d1080f Give every terminal its own identity, and report fleet presence
Answers "which of my 100 tills are alive and healthy", and fixes three things
that were fine on one device and broken on a hundred.

Terminal identity (lib/data/local/terminal_identity.dart)
- Every device mints a UUID on first run, stored in its own database, plus a
  short code (T4A9) derived from it. Renaming keeps the device id, so history
  keeps pointing at the same physical till.
- Replaces the literal 'TERM-01', which was hardcoded in five places. The whole
  fleet reported as one terminal: shift reports merged, MQTT topics collided,
  and a second connection with the same client id evicts the first from the
  broker — so two tills would have knocked each other offline in a loop.

Invoice numbers now carry the terminal code
- INV-2608-T4A9-00042. The sequence counter lives in each till's own database
  and starts at 1, so without this every terminal in the fleet minted
  INV-2608-00001 for its first sale of the month. The order UUID kept the data
  distinct; the number a customer quotes on a receipt was not.

SQLite pragmas
- WAL, so the product grid refreshing does not block the sale being written,
  and the file is never left mid-rewrite by a power cut.
- busy_timeout 5s, so a contended lock waits instead of throwing "database is
  locked" — which at checkout is a failed sale with a customer standing there.
- synchronous NORMAL, the right trade under WAL for a till.

Fleet presence (lib/data/sync/presence_reporter.dart)
- Retained status record on connect and once a minute: device id, code, name,
  app version, pending bill count, last upload, catalogue revision, sync halt
  state. Retained so a dashboard connecting at noon gets all 100 terminals
  immediately rather than a blank board.
- The Last Will already said "reachable". A till can be connected and still be
  holding 200 unsent bills or running last month's prices; only pending_bills
  and catalogue_revision say so.

NATS
- The MQTT gateway maps / to . so the existing transport works unchanged.
  SyncConfig.asNatsSubject() exposes the translation, and the contract doc
  gives the JetStream subjects (pos.*.*.order, pos.*.*.status) plus the two
  server-side requirements: a file-backed stream, and the ack published by the
  consumer after commit rather than by the ingest handler.

Tests: 129 -> 140. New coverage for identity minting and stability, per-device
invoice uniqueness, topic and client-id separation, NATS subject mapping, and
the two pragmas. Suite run three times clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 11:17:23 +05:30
Suriya
0a49323858 Drain bills to the back office automatically, over MQTT or HTTP
Turns the orders table into a queue that empties itself. Bills were only
uploaded when a cashier pressed Sync at end of day; a till that was never
pressed held a day's takings indefinitely.

Drain engine (lib/data/sync/sync_engine.dart)
- Triggers on sale committed, network regained, 5-minute poll, head-office
  request, and the manual button.
- Single flight: a busy till firing a trigger per sale would otherwise have
  several passes reading the same pending rows and send every bill twice.
  A trigger arriving mid-drain is queued and replayed, so nothing is dropped.
- Exponential backoff with +/-20% jitter to a 5-minute ceiling. The jitter
  matters: a store's terminals all fail at the same instant when the line
  drops, and would retry in lockstep without it.
- Halts rather than loops on a failure retrying cannot fix (bad credential,
  refused batch). Pressing Sync clears the halt.

Transports (lib/data/remote/)
- OrderTransport interface; MQTT, HTTP and simulated implementations. The
  repository does not know which is in use.
- MQTT: QoS 1 uplink, application-level ACK correlated by batch_id on a return
  topic, retained Last Will for terminal-offline detection, downlink for
  catalogue pushes and remote sync requests.
- A broker PUBACK is never treated as acceptance. It means the broker holds
  the bytes, not that the ledger took the sale. Only ids the back office names
  are marked synced; silence leaves a bill pending.
- HTTP carries a stable idempotency key across retries of the same bills.

Retention
- Accepted bills are kept 7 days instead of deleted, so a batch the back
  office later loses can be re-sent in full. Purged after that; archived
  totals stay forever.
- forBusinessDate now reads pending rows only. A retained bill exists in both
  the orders table and day_archive, and summing both would overstate the day.

Fixes found while building this
- SyncEngine._refreshPending wrote state.copyWith(pending: await ...). Dart
  evaluates the receiver before the awaited argument, so a connectivity drop
  during the wait was silently overwritten by the stale snapshot. Caught by
  the first run of the new engine tests.
- PrinterSettingsController wrote state after four awaits with no mounted
  check, throwing "used after dispose" when Settings was left mid-load. This
  was pre-existing and reached the cashier as a red screen.

Also
- Header pill now reports real sync state: LIVE / n QUEUED / SYNCING /
  SYNC HALTED, with an explanation of where the bills are.
- Settings shows the route, last upload, next retry and retention window.
- docs/sync-contract.md states what the back office must implement, including
  the idempotency requirement that at-least-once delivery makes mandatory.

Tests: 90 -> 129 passing. New coverage for backoff shape and jitter band,
single flight, halting, ACK correlation and partial acceptance, at-least-once
duplicate handling, retention and purge, and no double-counting after a sync.
Suite run six times clean.

Not addressed: bills already synced by an older build went up overstated and
still need server-side reconciliation. Broker credentials have no Settings
editor yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 10:57:29 +05:30