Commit Graph

13 Commits

Author SHA1 Message Date
0988d39d8b check 2026-08-07 14:34:25 +05:30
Suriya
829e5a8188 Take the terminal's role from the back office, not from which tab was clicked
The role split was right; only its source was wrong. Signing in matched what
was typed against two constants compiled into the app — admin@nearle.in and
cashier@nearle.in — so which shell a person got was a property of the *build*.
A shop could not add a third person, revoke either of the two it had, or stop
anyone with the APK reading both passwords out of it.

TerminalLogin survives unchanged in shape, because the shape was the good part:
one flag the shell reads, a session that decides it, and a cashier sign-out that
takes the catalogue with it while a supervisor's leaves it behind. Every
consumer — visibleModulesProvider, resolvedModuleProvider, the sidebar, the page
header, the sign-out dialog — is untouched. What changed is that the enum is now
only constructible from a session the back office signed, so there is no path
left where the terminal grants itself a permission the server did not send.

It reads `can_manage_staff` rather than the role name or id. app_roles holds six
rows for four distinct roles, a great many accounts carry a roleid that is not
in the table at all, and the name comes back blank for most of them. Matching on
either would mean shipping a copy of the role table in the app and keeping the
two in step for ever. One boolean, decided server-side, cannot drift. It
defaults to false, which matters on the restore path: a session saved by a build
that predates the field comes back as a cashier, never silently as an admin.

This also restores the sign-in layer itself — pos_auth_api, pos_session,
session_store, the staff import and the bearer token — which an earlier commit
removed wholesale from a stale checkout. Its parent was the commit that added
them, so the deletion was a bad merge rather than a decision; the terminal has
been running on the two constants since.

The login screen loses its role tabs and its credential prefill. You do not
choose what you are on the way in.

The opener is now matched on the back office user id rather than on the first
account with a matching role, so the first bill of a shift is attributed to
whoever actually signed in.

Tests: the smoke suite pinned only the supervisor shell, and it was passing for
the wrong reason — the fake session omitted can_manage_staff, and the sidebar it
asserted on was there because the role was hardcoded. Both halves are pinned now
and the fake is parameterised. widget_test.dart was the stock Flutter counter
template, restored by the same bad merge, testing a MyApp that has never existed
in this repo.

292 tests pass; analyzer reports no errors and no warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:06:00 +05:30
eebd10da6d pos changes 2026-08-06 19:29:23 +05:30
Suriya
b5b2047bcd Sign the terminal in against the back office instead of against two constants
Sign-in compared `admin@nearle.in` / `nearle123` — a compile-time const — after
a 600ms delay standing in for a network call that was never made. Two things
followed, and the second was the serious one.

Every install of a build shared one password, and changing it meant a rebuild.
Worse: because nothing was checked with the back office, the *outlet* could not
come from the sign-in. It came from a store id typed into Settings, so the till
asserted which shop it belonged to and the server took its word. One field on
one screen moved a terminal into another tenant's books.

Now a person signs in with their own back-office account and the outlet arrives
as a consequence — sealed in a signed token, checked server-side on every
request, and not editable from this device. `DemoCredentials` is gone, along
with the prefilled fields and the "Demo account" hint that printed the password
on the login screen.

The pieces:

- `PosSession` — what the back office answers with. The token is opaque on
  purpose: the till must not parse it or reason about what it appears to say.
- `SessionStore` — the whole session to the platform keystore, not SQLite. The
  token is a bearer credential and SQLite here is a file behind a shop counter.
  An expired session reads back as absent, so no caller has to remember to
  check.
- `SyncConfig.bearerToken` — one accessor rather than the same `??` at each
  call site, because the request that forgot it would be the one silently
  sending no credentials. The session beats a static API key: the key says the
  request came from our fleet, the session says which outlet it came from, and
  only the second can stop a till reaching another tenant's books.
- Restore runs in `syncBootstrapProvider` *before* the engine starts. A drain
  that began first would upload the day's bills unauthenticated. A till trades
  all day; a reboot mid-shift must not put a login screen in front of a queue.
- An outlet picker, shown only when the account genuinely reaches several. Not
  dismissable — defaulting silently to the first outlet is how a day's takings
  end up filed against the wrong shop.

Store name, address, GSTIN and phone now come down with the session and are
written on sign-in. They were compile-time constants, and on a GST invoice
those fields are a legal requirement rather than decoration.

The smoke test signs in through a fake client and inside `runAsync`: sign-in
reaches SQLite now, and real disk I/O cannot complete on a widget test's fake
clock — pumping alone leaves it suspended for ever.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:46:59 +05:30
6c0266c9c7 added product import and billing integration 2026-08-05 18:15:21 +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
46d354ced1 Build promos for real: engine, storage, editor, and application at the till
The Promo module was a mockup. Three hardcoded rows, a toggle that changed
nothing, and no promo code anywhere in lib/domain or lib/data. A cashier
looking at it would reasonably conclude promotions were running.

Engine (domain/services/promo_engine.dart)
- Five campaign types: percent or flat off the bill, percent off a category
  or a product, and buy-X-get-Y.
- Conditions: date range (inclusive of the closing day), days of the week,
  minimum bill value, and a cap on what a percentage can take off — without
  one an unusually large trolley gives away more than the campaign was costed
  for.
- Stacking is conservative by default. All stackable campaigns apply together;
  of the exclusive ones only the single best does, chosen by what it is worth
  to the shopper with priority breaking ties. Two percentages compounding
  produce a discount nobody signed off, and the shop finds out at the end of
  the month.
- The total is capped at the subtotal, so no combination of campaign, tier and
  manual discount can turn a sale into a payout.
- buy-X-get-Y counts whole groups only, and prices the free unit at what is
  actually being charged — a line already carrying a manual discount must not
  refund more than it took.

Kept out of Cart deliberately: Cart owns arithmetic that must never be wrong,
this owns policy a shop changes weekly.

Storage (schema v6, plus promos_json on orders at v7)
- Campaigns persist locally, because a shop mid-promotion with a dead line
  still has to honour the price on the shelf edge.
- A bill records the campaign name and the amount given, not a link to the
  row. A campaign edited or deleted later cannot change what a past sale
  shows, and a reprinted receipt still names what the shopper was given.
- On read-back the promo amounts are subtracted from the manual discount,
  because bill_discount already contains them. Restoring both at full value
  would discount the bill twice — the same shape as the bug that used to
  overstate synced totals.

At the till
- Every cart mutation re-evaluates, so a promo cannot survive the line that
  earned it being removed.
- A resumed parked bill is re-evaluated rather than restored: a campaign that
  has since ended must not be honoured because the bill was parked while it
  was running.
- Campaigns are named individually on the billing panel and the printed
  receipt, so a shopper who came in for an advertised offer can see it applied.

Editor
- Full CRUD, admin-only, with validation for the cases that would save happily
  and then silently never fire — a targeted campaign with no target, a
  percentage over 100, an end date before the start.

Tests: 199 -> 210. Covers each campaign type, the eligibility conditions, the
stacking rules, the impossible-to-go-negative guarantee, GST recomputation
against the reduced total, round-tripping, and the double-count guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:36:50 +05:30
Suriya
e17937e8f1 Move staff PINs out of the shipped binary into hashed database rows
Three StaffUser constants carried plaintext PINs (1234/2345/3456) in
auth_controller.dart. Every build shipped every till's credentials, readable
by anyone who unzipped the APK. Across 100 deployed devices that is one
credential, not a hundred.

- Schema v5 adds a staff table. Only a PBKDF2-HMAC-SHA256 hash and a per-user
  random salt are stored; the PIN itself exists nowhere, including there.
  12,000 iterations, tuned so one sign-in is imperceptible while working
  through all 10,000 four-digit PINs against a stolen database takes ~15
  minutes per account instead of milliseconds.
- Verification is constant-time. String == returns at the first differing
  byte, and that timing leaks how much of a guess was right.
- StaffUser no longer has a pin field at all, so the credential cannot drift
  back into memory, into widgets, or into a const declaration.
- Weak PINs are refused: under four digits, non-numeric, repeated digits, and
  sequences. Two staff cannot share a PIN — the till identifies a cashier by
  PIN alone, so a shared one would attribute bills to whichever row was
  checked first.
- The last admin cannot be demoted or deactivated. A till with no admin cannot
  be administered, including to appoint one, and recovering means editing the
  database by hand.
- Staff are deactivated, never deleted, so bills already rung keep naming a
  real person.

Seed accounts are now 4821/5093/6274 rather than 1234/2345/3456 — the weak-PIN
rule refuses the old ones, and a default the rule itself would reject is not a
defensible default. All three are flagged must-change-pin so they get a shop
trading on day one without becoming permanent.

Store details are now editable data, not compile-time constants. Name,
address, GSTIN and phone persist to the database and are read back rather than
falling through to the build's constants, which would silently undo a failed
save. GSTIN is format-validated including the state code — it prints on every
invoice as a legal requirement, so a typo is a compliance problem across
hundreds of bills before anyone notices.

Tests: 141 -> 160. Includes a test that reads every column of every staff row
and asserts no seed PIN appears anywhere in the database.

Migration test now asserts v5 and that an upgraded terminal comes up with the
staff table present but empty — seeding is the store's job on first open, so
an existing shop is never handed accounts it did not create.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:00:12 +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
9891a69a5f third commit 2026-07-31 17:06:52 +05:30
d9886880cc added local db 2026-07-29 13:06:39 +05:30
d72522e737 second commit 2026-07-29 11:41:53 +05:30