Compare commits

9 Commits

Author SHA1 Message Date
Suriya
908058038a Send timestamps with the offset they were read in
Live bill INV-2608-T5EDD-00116 carried billedat 2026-08-05T12:49:28Z beside
receivedat 2026-08-05T07:19:28Z — the sale appearing to have been rung five and
a half hours after the back office received it. Exactly the IST offset, on every
bill.

Nothing was lying. DateTime.toIso8601String() on a local time emits no zone
marker at all, and Go's time.Parse fills that silence with UTC, so a Coimbatore
wall clock was recorded as though it had been read in London.

The daily figures survived by luck: businessdate is derived from the wall clock
either way, and the wall clock was always the till's own, so a day's takings
landed on the right day even while the instant was wrong. Anything comparing
billedat against real time did not.

Formatters.isoWithOffset attaches the offset, which fixes both readings at once
— the instant parses correctly and the local date still formats correctly. The
minutes come from the real offset rather than being assumed zero, because India
is +05:30 and a whole-hour implementation would be wrong in a way that looks
almost right.

Applied to all six timestamps the till sends. date_of_birth is left alone: a
birthday is a date, not an instant, and giving it a zone would be meaningless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 20:17:57 +05:30
Suriya
353c6c1075 Report health on every transport, not only the broker
A shop configured for the HTTP route uploaded 17 bills correctly today and
never once appeared on the fleet board. Nothing logged it, because from the
terminal's point of view nothing had failed: the reporter was typed against
MqttOrderTransport and started behind an `is MqttOrderTransport` check, so on
HTTP it was silently never constructed. A monitoring feature that quietly does
not exist on one of two supported routes is worse than no feature, because the
blank square reads as "no terminals" rather than "not wired up".

publishHealth moves onto the OrderTransport interface. The broker publishes to
the health topic as before; HTTP posts the same payload to POST /pos/health;
the simulated route does nothing, which is the honest answer for a till with no
back office configured. The reporter is now started for every route.

There was no test for the reporter at all, which is why this shipped. There are
five now, including one that fails on the old code.

Also removes test/widget_test.dart — the stock `flutter create` counter test,
referencing a MyApp that never existed here. It has never compiled and was the
only red in the suite.

263 tests pass, analyzer clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:28:22 +05:30
6c0266c9c7 added product import and billing integration 2026-08-05 18:15:21 +05:30
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
09edce5dc6 Silence clangd on the generated Linux runner
`linux/runner/` is Flutter's stock GTK host program. On macOS the GTK
development headers are absent, so clangd cannot resolve <gtk/gtk.h>,
G_DECLARE_FINAL_TYPE never expands, and a six-line main.cc reports seven
errors for symbols that macro produces.

They are editor diagnostics, not build errors — the file compiles
unchanged on Linux, and nothing in the Flutter toolchain reads clangd.
Suppressing them beats editing generated code the Linux build needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 11:39:56 +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
e5fc777202 Merge branch 'fix/billing-data-integrity'
Eight commits taking the terminal from a demo to something a fleet can run.

- Bills drain to the back office automatically over MQTT or HTTP, with
  application-level acknowledgement, backoff and a 7-day recovery window.
- Every terminal has its own identity, so 100 devices no longer share one set
  of MQTT topics, one client id and one invoice series.
- Staff PINs moved out of the shipped binary into PBKDF2 hashes.
- Cash drawer, store details, staff management and promos all built for real
  rather than mocked.
- The catalogue now pulls from a real endpoint with paged delta sync.

234 tests passing, analyzer clean.
2026-08-01 15:42:55 +05:30
6174734f6d Merge pull request 'Fix billing data integrity, sale atomicity and stock safety' (#1) from fix/billing-data-integrity into main
Reviewed-on: #1
2026-07-31 13:06:03 +00:00
59 changed files with 3140 additions and 567 deletions

27
.clangd Normal file
View File

@@ -0,0 +1,27 @@
# clangd configuration.
#
# `linux/runner/` is the GTK host program Flutter generates for the Linux
# desktop target. It is stock scaffolding — unmodified since `flutter create` —
# and it builds correctly on a Linux machine with the GTK development headers
# installed.
#
# On macOS and Windows those headers do not exist, so clangd cannot resolve
# `#include <gtk/gtk.h>`. Everything downstream then collapses: `MyApplication`,
# `my_application_new` and `g_autoptr` are all produced by the
# `G_DECLARE_FINAL_TYPE` macro, which never expands, so the editor reports a
# handful of undeclared identifiers and an unused include on a six-line file
# that has nothing wrong with it.
#
# Those are editor diagnostics, not build errors. `flutter build linux` on a
# Linux box compiles this unchanged; nothing in the Flutter toolchain reads
# clangd. Suppressing them here keeps the noise out of the problems panel
# without touching generated code that the Linux build depends on.
#
# If you do want real analysis of this directory, do it on Linux with the GTK
# headers present and a compile_commands.json — not by editing the runner.
If:
PathMatch: linux/.*
Diagnostics:
Suppress: '*'
UnusedIncludes: None

View File

@@ -11,10 +11,26 @@ Brand colour `#662582` · Inter typeface · 16px corner radius · touch-first ta
```bash
flutter pub get
flutter run -d windows # or macos, linux, or a connected tablet
flutter test # 60+ unit and widget tests
flutter test # 234 unit and widget tests
flutter analyze
```
Out of the box the terminal runs against a local stub: products are seeded,
bills queue and drain, and nothing leaves the device. Connecting it to a real
back office is a Settings change, not a rebuild — see below.
### Connecting to a back office
| Document | For |
|---|---|
| [Integration guide](docs/integration-guide.md) | Wiring a terminal to NATS/MQTT and an HTTP catalogue, hop by hop, with commands to prove each one |
| [Sync contract](docs/sync-contract.md) | What the back office must implement: topics, payloads, acknowledgement rules, field handling |
The short version: bills are written to SQLite first and uploaded in the
background, so the till never waits on the network. A bill is only marked
synced when the *back office* names its id — a broker acknowledging receipt is
not the ledger accepting the sale.
Requires Flutter 3.27 / Dart 3.6 or newer. See [Version compatibility](#version-compatibility) if `flutter analyze` complains about theme types.
---

BIN
assets/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

267
docs/integration-guide.md Normal file
View File

@@ -0,0 +1,267 @@
# Connecting a terminal to your back office
Step-by-step wiring. The companion to [`sync-contract.md`](sync-contract.md),
which specifies *what* the back office must implement — this one covers *how* to
get a terminal talking to it, and how to prove each hop works before moving to
the next.
Nothing here needs a rebuild. A terminal is pointed at a back office from
Settings.
---
## The five hops
```
1. Mosquitto reachable, with a scoped account for the tills
2. Terminal connects and shows LIVE
3. Terminal publishes a bill
4. Your consumer commits it and acks
5. Terminal marks it synced and stops re-sending
```
Do them in order. A failure at hop 4 looks identical to a failure at hop 2 from
the terminal's side — it just keeps queueing — so proving each one saves a lot of
guessing.
---
## Hop 1 — Mosquitto, with an account for the tills
The deployed broker is **Eclipse Mosquitto 2.1.2** at `66.116.225.226:1883`,
already carrying `nearle/riders/#` and `doormile/#`. There is no NATS in this
estate — the NATS servers that exist belong to other projects, on hosts whose
ports are closed, and their configs expose no MQTT gateway.
Current config has `allow_anonymous false` and a password file, but **no
`acl_file`** — so every authenticated user, including the `admin` account
hardcoded in the rider APK, has full run of every topic. Add scoped accounts
before a hundred tills start publishing takings:
```bash
mosquitto_passwd -b /mosquitto/config/passwd pos_terminal '<strong-pw>'
mosquitto_passwd -b /mosquitto/config/passwd pos_ingest '<different-pw>'
```
```conf
# /mosquitto/config/acl, then add `acl_file /mosquitto/config/acl` to mosquitto.conf
user pos_terminal
topic write nearle/pos/+/+/order
topic write nearle/pos/+/+/customer
topic write nearle/pos/+/+/status
topic write nearle/pos/+/+/health
topic read nearle/pos/+/+/ack
topic read nearle/pos/+/+/command
topic read nearle/pos/+/catalogue
user pos_ingest
topic read nearle/pos/+/+/order
topic read nearle/pos/+/+/customer
topic read nearle/pos/+/+/health
topic write nearle/pos/+/+/ack
topic write nearle/pos/+/catalogue
user admin
topic readwrite nearle/riders/#
topic readwrite doormile/#
```
Prove it:
```bash
mosquitto_sub -h 66.116.225.226 -p 1883 -u pos_ingest -P '<pw>' \
-t 'nearle/pos/#' -v &
mosquitto_pub -h 66.116.225.226 -p 1883 -u pos_terminal -P '<pw>' \
-t nearle/pos/12/T0000/order -m 'hello'
```
**The broker is a transport, not a ledger.** `max_queued_messages` defaults to
1000 and `autosave_interval` to 30 minutes, so a long outage or a hard kill can
drop queued messages. That costs nothing here — an undelivered batch is never
acked, so the till keeps it and sends again — but only while nobody
acknowledges on the broker's behalf.
**TLS is not configured** and 8883 is closed. Bills carry customer names and
mobile numbers; worth adding a listener before rollout rather than after.
## Hop 2 — Point the terminal at it
On the terminal: **Settings → Connectivity & sync → Configure**.
| Field | Value |
|---|---|
| Terminal name | What staff call this till, e.g. "Counter 2" |
| Store ID | **The numeric `locationid`.** The tenant is resolved from it server-side |
| Transport | MQTT |
| Broker host / port | `66.116.225.226`, port `1883`, **TLS off** |
| Username / password | The `pos_terminal` account from hop 1 |
| Use TLS | **Off** — 8883 is not configured on this broker yet |
The dialog also shows a **Device ID** and a terminal code like `T4A9`. Neither is
editable. They are minted on first run and stay with the physical machine, which
is what keeps 100 terminals from colliding on topics, client ids and invoice
numbers. Note the code down — it is what a support call needs.
Credentials go to the OS keystore (Keychain / Credential Manager / Android
Keystore), not into the database alongside the bills.
Prove it: the header pill switches from `OFFLINE (SIM)` to `LIVE`, and
```bash
mosquitto_sub -h 66.116.225.226 -u pos_ingest -P '<pw>' -t 'nearle/pos/+/+/status' -v
```
should immediately show a retained presence record for the terminal. If it
doesn't, the terminal never connected — check the broker log for an auth
rejection before looking anywhere else.
---
## Hop 3 — Ring a sale and watch it publish
```bash
mosquitto_sub -h 66.116.225.226 -u pos_ingest -P '<pw>' -t 'nearle/pos/+/+/order' -v
```
Ring a bill on the terminal. Within a couple of seconds you should see the
envelope from [`sync-contract.md`](sync-contract.md#payloads) — a `batch_id`, the
store and terminal, and an `orders` array.
The header pill will show `1 QUEUED` and stay there, because nothing has
acknowledged it yet. That is correct behaviour, not a fault.
---
## Hop 4 — Consume, commit, acknowledge
**This is already built**, in the Fiesta backend. See
`backend_fiesta/POS_TERMINAL_INGEST.md` for how to turn it on; what follows is
what it guarantees, so you can check it still holds if anyone changes it.
Set `MQTT_URL` and it subscribes to `nearle/pos/+/+/{order,customer,health}`,
commits, and acknowledges. Bills land in `pos_orders` / `pos_order_items`, and
the stock they consumed goes through the same `productstocks` ledger an app
order uses.
Three rules the implementation is built around, and that any replacement must
also keep:
**Acknowledge from the consumer, after the database commit.** Not from a handler
that has merely queued the work. That ack is the terminal's only evidence, and
it deletes its own copy seven days later on the strength of it.
**A duplicate is accepted, not rejected.** QoS 1 is at-least-once and a lost ack
makes the terminal re-send the whole batch. Reporting those as failures would
strand a day of takings on the till. Deduplication is a unique index on the
till's UUID plus a Postgres advisory lock.
**Read the store and terminal from the topic, never the body.** A till that
could name its own store in a payload could redirect another counter's
acknowledgements.
Prove the ack path by hand before trusting the consumer:
```bash
# copy batch_id and the order id from the hop-3 output
mosquitto_pub -h 66.116.225.226 -u pos_ingest -P '<pw>' \
-t 'nearle/pos/12/T4A9/ack' \
-m '{"batch_id":"<paste>","accepted":["<paste-order-id>"]}'
```
The pill should flip to `LIVE` and the bill disappear from the queue.
### Shopper registrations
A second uplink runs on `nearle/pos/{loc}/{terminal}/customer`, acked on the
same topic by the same rules, and handled by the same consumer.
The id is a UUIDv5 over the shopper's normalised ten-digit mobile, so two tills
registering the same person independently produce the same row. It is stored
insert-if-absent — never an update, so a profile corrected at head office is not
reverted by a terminal replaying an old capture. No loyalty figures travel
upward: those are derived from the bill stream, which is idempotent and sees
every counter.
```bash
mosquitto_sub -h 66.116.225.226 -u pos_ingest -P '<pw>' -t 'nearle/pos/+/+/customer' -v
```
Add a shopper on the terminal — no sale needed — and it should appear.
### Terminal health
Every till publishes to `nearle/pos/{loc}/{terminal}/health` every 30 seconds.
The consumer writes it to Redis as `pos:terminal:{code}` under a 90-second TTL,
so a till that loses power ages off the board by itself. Read it back at
`GET /live/api/v1/pos/health/location?location_id=12`.
Heartbeats are never acknowledged — a till that could be blocked by a busy
dashboard would be a self-inflicted outage.
## Hop 5 — Catalogue down
The catalogue is a bulk pull over HTTP, not MQTT — a broker is the wrong shape
for tens of thousands of rows. Set the **Base URL** in the same Configure dialog
and implement:
```
GET {base}/catalogue?since={revision}&page={n}&store_id=…&terminal_id=…
Authorization: Bearer {apiKey}
```
Full field-by-field behaviour, including what happens when something is missing,
is in [`sync-contract.md`](sync-contract.md#catalogue-pull). The two things
easiest to get wrong:
- **`is_delta` is load-bearing.** A full snapshot withdraws every product it does
not mention. Answer `is_delta: true` for a change set, or the first morning
price change empties the shelf.
- **Send `stock` only when you mean it.** Any product in the payload gets its
count overwritten with your figure, which predates sales the terminal has rung
but not uploaded. The terminal replays those — but only for products the
payload carried.
To push a change mid-day rather than waiting for the next pull:
```bash
mosquitto_pub -h 66.116.225.226 -u pos_ingest -P '<pw>' \
-t 'nearle/pos/12/catalogue' -m '{"revision":"rev-8822"}'
```
Every terminal in that store pulls immediately.
---
## Troubleshooting
| Symptom | Where to look |
|---|---|
| Pill stuck on `OFFLINE (SIM)` | Simulate offline is still on in Settings |
| Pill shows `LIVE`, no presence on `nearle/pos/+/+/status` | Terminal never connected — check broker auth logs |
| Bills publish, queue never empties | You are acking the wrong `batch_id`, or not acking at all |
| Queue empties then refills with the same bills | Ack arriving after `ackTimeout` (20s default) — the terminal gave up and re-sent |
| Two terminals fighting for the connection | They share a client id. Each device mints its own; check they have different terminal codes |
| `SYNC HALTED` | You named an id in `rejected`. The reason is on the pill tooltip and in Events |
| Duplicate rows server-side | No unique index on `order.id`. At-least-once delivery makes it mandatory |
| Shelf empties after a price change | You sent a delta with `is_delta: false`, or a stale `stock` |
The **Events** module on the terminal shows every sync attempt with its error,
and per-bill state — start there before the broker logs.
---
## Before a fleet rollout
- **Add the broker ACL first** (hop 1). Today every authenticated user is
unrestricted on every topic, including the `admin` account hardcoded in the
rider APK.
- **Back up `nearle_pos.db` on any terminal already trading.** The schema goes to
v8 on first launch and the migration is one-way. It also queues every shopper
already on the terminal for upload, so expect one burst of registrations from
each existing store — collapse those onto the mobile number.
- **Build per-ABI.** `flutter build apk --split-per-abi` gives ~23MB per
architecture instead of a 69MB universal APK — worth it over shop wifi.
- **Change the seed PINs.** `4821` / `5093` / `6274` are in the source. Every
account is flagged to force a change at first sign-in, but a shop that
dismisses it is running a published credential.
- **Turn TLS on.** Bills carry customer names and mobile numbers.

View File

@@ -62,14 +62,21 @@ connection or return 5xx instead, and the terminal will back off and retry.
| Topic | Direction | QoS | Retained |
|---|---|---|---|
| `pos/{store}/{terminal}/order` | till → cloud | 1 | no |
| `pos/{store}/{terminal}/ack` | cloud → till | 1 | no |
| `pos/{store}/{terminal}/status` | till → cloud | 1 | **yes** |
| `pos/{store}/{terminal}/command` | cloud → till | 1 | no |
| `pos/{store}/catalogue` | cloud → all tills | 1 | **yes** |
| `nearle/pos/{loc}/{terminal}/order` | till → cloud | 1 | no |
| `nearle/pos/{loc}/{terminal}/customer` | till → cloud | 1 | no |
| `nearle/pos/{loc}/{terminal}/health` | till → cloud | 1 | no |
| `nearle/pos/{loc}/{terminal}/ack` | cloud → till | 1 | no |
| `nearle/pos/{loc}/{terminal}/status` | till → cloud | 1 | **yes** |
| `nearle/pos/{loc}/{terminal}/command` | cloud → till | 1 | no |
| `nearle/pos/{loc}/catalogue` | cloud → all tills | 1 | **yes** |
`{store}` and `{terminal}` come from the device's own identity, minted on first
run and stored in its database. They are never literals — 100 tills sharing one
Namespaced under `nearle/` alongside the rider fleet's `nearle/riders/…`, so one
broker ACL rule covers each system.
`{loc}` is the back office's numeric location id, entered once in Settings; the
tenant is resolved from it server-side and never taken from the wire.
`{terminal}` comes from the device's own identity, minted on first run and
stored in its database. They are never literals — 100 tills sharing one
id would collide on every topic and evict each other from the broker, since a
second connection with the same client id kicks the first off.
@@ -78,28 +85,28 @@ second connection with the same client id kicks the first off.
dark" board possible, and it is the only way to tell *closed for the night*
from *unplugged*.
### Running this on NATS
### Running this on Mosquitto
The MQTT gateway maps `/` to `.`, so the topics above arrive as subjects and a
JetStream consumer binds to them directly:
The deployed broker is Eclipse Mosquitto 2.1.2. A consumer binds to the topics
above directly, using `+` as the single-level wildcard:
| Purpose | Subject |
| Purpose | Filter |
|---|---|
| Every till's bills | `pos.*.*.order` |
| Every till's presence | `pos.*.*.status` |
| One store's bills | `pos.store-01.*.order` |
| Ack back to one till | `pos.store-01.T4A9.ack` |
| Every till's bills | `nearle/pos/+/+/order` |
| Every till's heartbeat | `nearle/pos/+/+/health` |
| One shop's bills | `nearle/pos/12/+/order` |
| Ack back to one till | `nearle/pos/12/T4A9/ack` |
`SyncConfig.asNatsSubject()` does the translation, so a consumer's subject can
be read off the terminal rather than guessed.
Two things to get right:
Two things to get right on the NATS side:
- **The stream must be durable and file-backed.** A memory stream loses a shop's
bills on a server restart, and the till has already been told they landed.
- **Publish the ack from the consumer, after the database commit** — not from an
ingest handler that has merely queued the work. The ack is the terminal's
only evidence, and it deletes its copy seven days later on the strength of it.
ingest handler that has merely queued the work. The ack is the terminal's only
evidence, and it deletes its copy seven days later on the strength of it.
- **Do not treat the broker as durable storage.** Mosquitto's default
`max_queued_messages` is 1000 and its `autosave_interval` is 30 minutes, so a
long outage or a hard kill can drop queued messages. Nothing is lost, because
an undelivered batch is simply never acked and the till sends it again — but
only as long as nobody acknowledges on the broker's behalf.
### Fleet presence
@@ -126,7 +133,7 @@ so.
## Payloads
**Uplink**`pos/{store}/{terminal}/order`
**Uplink**`nearle/pos/{loc}/{terminal}/order`
```json
{
@@ -139,7 +146,7 @@ so.
}
```
**Ack**`pos/{store}/{terminal}/ack`. Must echo `batch_id`; anything else is
**Ack**`nearle/pos/{loc}/{terminal}/ack`. Must echo `batch_id`; anything else is
ignored as belonging to a batch the terminal is no longer waiting on.
```json
@@ -157,6 +164,58 @@ nothing is marked synced, and the batch goes again.
response. Carries an `idempotency-key` header that is stable across retries of
the same bills.
### Shopper registrations
**Uplink**`nearle/pos/{loc}/{terminal}/customer`, or `POST {base}/customers`.
Acked on the same topic and by the same rules: only ids you name are marked
sent.
```json
{
"schema": 1,
"batch_id": "3d7a…",
"store_id": "store-01",
"terminal_id": "T4A9",
"customers": [
{
"id": "7a24e082-060f-5503-aa9e-da0ef42df047",
"mobile": "9840012345",
"name": "Meena",
"email": null,
"gender": "female",
"date_of_birth": null,
"registered_at": "2026-08-01T10:14:00.000Z",
"registered_by_terminal": "T4A9"
}
]
}
```
Three things about this payload are load-bearing:
- **`id` is derived from the mobile number**, not minted at random — a UUIDv5
over the normalised ten-digit number in a fixed namespace. Two tills that
register the same shopper independently produce *the same id*, so you
deduplicate on a primary key rather than guessing at a merge later. Do not
reassign it.
- **Treat it as insert-if-absent on `id`.** A registration is not a financial
record: it is replayed freely, and it must never overwrite a profile
corrected at head office. `ON CONFLICT (id) DO NOTHING`.
- **No loyalty figures are sent.** Points, lifetime spend and visit counts are
absent on purpose — derive them from the bill stream, which is authoritative
and idempotent. Accepting a terminal's local balance would make the last till
to sync win, and a shopper who bought at two counters on the same day would
end up with whichever figure happened to arrive second.
Registrations are uploaded *before* bills on every pass, so a bill naming a new
shopper arrives after the shopper does. A failure here is logged and does not
hold up the bills behind it.
Terminals that were trading before schema v8 carry shoppers with random ids
from the old scheme. Those are queued once by the migration and arrive with
their original ids — merge them onto the mobile number. It is a one-off for
existing stores; a new terminal never produces one.
## Catalogue pull
The other direction: products and customers coming down.
@@ -216,7 +275,7 @@ Dates are ISO 8601 or epoch milliseconds; both are accepted.
### Pushing a change mid-day
Publish to `pos/{store}/catalogue` (retained) and every terminal in the shop
Publish to `nearle/pos/{loc}/catalogue` (retained) and every terminal in the shop
pulls immediately instead of waiting for tomorrow morning. The message body is
only a nudge — the catalogue itself still comes over HTTP, because a broker is
the wrong shape for tens of thousands of rows.
@@ -237,15 +296,15 @@ overstate the day.
- **Downlink beyond catalogue-changed and sync-requested.** The plumbing routes
unknown commands to the events log rather than dropping them, so adding one
is a server change plus a case arm.
- **Credentials survive a restart.** The back-office dialog writes the broker
host, port, TLS flag and credentials into `syncConfigProvider`, which is
in-memory. Terminal name and store id persist (they live in the database);
the credentials do not, and must be re-entered after a restart. Persisting
them means encrypting them at rest, which is the next piece of work.
- **Historical correction.** Bills already synced by an older build went up
with an overstated total. Nothing here fixes that; it needs a server-side
reconciliation against `bill_discount`.
- **Pushing customers upward.** A shopper registered at the till stays on that
terminal and rides along on the bills they appear on. There is no
customer-create endpoint yet, so two terminals registering the same mobile
number will each hold their own row until the back office reconciles them.
- **Loyalty balances coming back down.** Points and lifetime spend are computed
per terminal from the bills that terminal rang. A shopper who buys at two
stores has two partial balances until the back office derives the real one
from the bill stream and sends it down in a catalogue pull. The uplink
deliberately does not carry local balances, so nothing is corrupted by this —
but a shopper's points at the till are that till's view, not the group's.
- **Merging pre-v8 shoppers.** Terminals that traded before the customer outbox
carry rows with random ids. They are uploaded once by the migration, but
collapsing them onto the mobile number is the back office's job.

View File

@@ -79,15 +79,19 @@ final catalogueSourceProvider = Provider<CatalogueSource>((ref) {
// ------------------------------------------------------------------- Sync
/// How this terminal reaches the back office.
///
/// Defaults to the simulated route so a fresh install is usable with no broker
/// and no endpoint; Settings re-points it.
/// Defaults to this store's live HTTP endpoint, so importing works out of the
/// box against real products rather than the offline demo catalogue.
/// Settings → Connectivity & sync → Configure re-points it to a different
/// store, endpoint, or transport without a rebuild.
///
/// Store and terminal ids always come from this device's own identity, never
/// from a literal — two terminals publishing on the same topic is the failure
/// this exists to prevent.
/// Terminal id always comes from this device's own identity, never from a
/// literal — two terminals publishing on the same topic is the failure this
/// exists to prevent.
final syncConfigProvider = StateProvider<SyncConfig>((ref) {
final terminal = ref.watch(terminalIdentityProvider);
return SyncConfig(
transport: TransportKind.http,
httpBaseUrl: 'https://fiesta.nearle.app/live/api/v1/pos',
storeId: terminal.storeId,
terminalId: terminal.code,
);

View File

@@ -73,13 +73,32 @@ class SyncConfig {
};
// ------------------------------------------------------------------ Topics
String get _base => 'pos/$storeId/$terminalId';
/// Namespaced under `nearle/` alongside the rider fleet's
/// `nearle/riders/{riderId}/…`, so one broker ACL rule covers each system and
/// a topic says at a glance which one it belongs to.
///
/// [storeId] carries the back office's numeric location id. The tenant is
/// resolved from it server-side and never taken from the wire — a till that
/// could name its own tenant could post sales into another shop's books.
String get _base => 'nearle/pos/$storeId/$terminalId';
/// Uplink. Completed bills, QoS 1.
String get orderTopic => '$_base/order';
/// Uplink. Shoppers registered at this till, QoS 1.
///
/// Separate from [orderTopic] because the two have different shapes and
/// different consumers: bills are financial records that must never be
/// replayed twice, registrations are insert-if-absent and can be replayed
/// freely. Sharing a topic would force one consumer to branch on a type tag
/// and would put a registration behind a stuck bill.
String get customerTopic => '$_base/customer';
/// The back office's answer, naming the ids it committed. Subscribed at
/// QoS 1: losing an ack means re-sending bills that are already banked.
///
/// Carries acks for both uplinks. The `batch_id` says which send is being
/// answered, so one subscription is enough.
String get ackTopic => '$_base/ack';
/// Retained, and set as the will message. A terminal that loses power stops
@@ -87,8 +106,16 @@ class SyncConfig {
/// what makes a head-office "which tills are dark" board possible.
String get statusTopic => '$_base/status';
/// Liveness, published on a timer rather than on an event.
///
/// Separate from [statusTopic]: that one is retained and doubles as the Last
/// Will, so it must stay small and rarely written. This carries queue depth,
/// today's trading and device state — the things a head-office board needs to
/// tell a till that is merely quiet from one that is in trouble.
String get healthTopic => '$_base/health';
/// Store-wide downlink: catalogue changes land here for every terminal.
String get catalogueTopic => 'pos/$storeId/catalogue';
String get catalogueTopic => 'nearle/pos/$storeId/catalogue';
/// Addressed to this terminal alone.
String get commandTopic => '$_base/command';
@@ -106,7 +133,8 @@ class SyncConfig {
/// NATS' MQTT gateway maps `/` to `.`, so this is what a JetStream stream or
/// consumer is configured against. Provided so the wildcard a back-office
/// consumer needs can be read off the terminal rather than guessed:
/// `pos.*.*.order` for every till's bills, `pos.*.*.status` for presence.
/// `nearle.pos.*.*.order` for every till's bills, `nearle.pos.*.*.health`
/// for presence.
static String asNatsSubject(String topic) => topic.replaceAll('/', '.');
SyncConfig copyWith({

View File

@@ -39,8 +39,13 @@ class AppConstants {
static const Duration barcodeScanTimeout = Duration(milliseconds: 120);
static const int minBarcodeLength = 6;
/// Idle time after a completed sale before the terminal resets itself.
static const Duration postSaleResetDelay = Duration(seconds: 3);
/// Window after a completed sale during which the terminal waits, and the
/// bill is held back from the server — long enough for the cashier to
/// catch a mistake and cancel it before it becomes final. If the window
/// runs out (or "New Sale" is pressed early) the terminal resets and the
/// bill goes up; if it's cancelled first, the sale is voided and the cart
/// comes back exactly as it was.
static const Duration postSaleResetDelay = Duration(seconds: 30);
static const int lowStockThreshold = 10;
static const int maxParkedBills = 20;

View File

@@ -83,4 +83,35 @@ class Formatters {
: '$terminalCode-';
return 'INV-$y$m-$code$seq';
}
/// A timestamp the back office cannot misread, with its UTC offset attached.
///
/// `DateTime.toIso8601String()` on a local time emits no zone marker at all —
/// `2026-08-05T12:49:28.245`. That is not wrong, it is *silent*, and the
/// receiver has to guess. Go's `time.Parse` guesses UTC, so a bill rung at
/// 12:49 in Coimbatore was stored as 12:49 UTC: five and a half hours in the
/// future, and reading as *later than the moment it was received*.
///
/// The daily figures survived that by luck. `businessdate` is derived from
/// the wall clock either way, and the wall clock was always the till's own —
/// so a day's takings landed on the right day even while the instant was
/// wrong. Anything comparing `billedat` to real time did not survive it.
///
/// Emitting the offset ends the guessing: `2026-08-05T12:49:28.245+05:30`
/// parses to the correct instant *and* still formats to the correct local
/// date, so both readings stay right.
static String isoWithOffset(DateTime time) {
final local = time.toLocal();
final offset = local.timeZoneOffset;
final sign = offset.isNegative ? '-' : '+';
final magnitude = offset.abs();
final hours = magnitude.inHours.toString().padLeft(2, '0');
// India is +05:30, so the minutes are load-bearing here in a way they are
// not in a whole-hour zone. Taken from the total rather than assumed zero.
final minutes =
(magnitude.inMinutes % 60).toString().padLeft(2, '0');
return '${local.toIso8601String()}$sign$hours:$minutes';
}
}

View File

@@ -39,6 +39,7 @@ class LocalStore {
DateTime? _lastImportAt;
String? _catalogueRevision;
int _unsyncedOrders = 0;
int _unsyncedCustomers = 0;
bool _ready = false;
@@ -92,6 +93,7 @@ class LocalStore {
_catalogueRevision = await catalogue.meta(MetaKeys.catalogueRevision);
terminal = await identityStore.load();
_unsyncedOrders = await orders.unsyncedCount();
_unsyncedCustomers = await catalogue.unsyncedCustomerCount();
_syncEvents
..clear()
@@ -144,6 +146,18 @@ class LocalStore {
String? get catalogueRevision => _catalogueRevision;
int get unsyncedOrders => _unsyncedOrders;
/// Drops the imported catalogue from disk and from the in-memory cache.
///
/// Called at sign-out so the next shift never bills against a copy left
/// over from this one — [hasCatalogue] goes back to false, and the only way
/// to sell again is a fresh pull from the back office.
Future<void> clearCatalogue() async {
await catalogue.clearCatalogue();
_products.clear();
_lastImportAt = null;
_catalogueRevision = null;
}
Future<void> importCatalogue({
required List<Product> products,
required List<Customer> customers,
@@ -239,11 +253,20 @@ class LocalStore {
Future<void> putCustomer(Customer c) async {
await catalogue.upsertCustomer(c);
_customers[c.id] = c;
await refreshUnsyncedCustomerCount();
}
/// Mirrors a customer already written to disk into the memory cache.
void cacheCustomer(Customer c) => _customers[c.id] = c;
/// Shoppers registered here and not yet uploaded.
int get unsyncedCustomers => _unsyncedCustomers;
Future<int> refreshUnsyncedCustomerCount() async {
_unsyncedCustomers = await catalogue.unsyncedCustomerCount();
return _unsyncedCustomers;
}
// ----------------------------------------------------------------- Orders
/// Refreshes the cached unsynced tally after a write or a sync.
Future<int> refreshUnsyncedCount() async {

View File

@@ -13,7 +13,7 @@ class AppDatabase {
static final AppDatabase instance = AppDatabase._();
static const String _fileName = 'nearle_pos.db';
static const int _version = 7;
static const int _version = 8;
Database? _db;
@@ -77,6 +77,7 @@ class AppDatabase {
'ALTER TABLE ${Tables.orders} ADD COLUMN promos_json TEXT',
);
}
if (from < 8) await _upgradeToV8(db);
},
),
);
@@ -188,12 +189,24 @@ class AppDatabase {
lifetime_spend REAL NOT NULL DEFAULT 0,
visit_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER,
last_visit_at INTEGER
last_visit_at INTEGER,
-- Registration outbox. A shopper signed up at the till has to reach
-- the back office even if they never buy anything, so this table
-- carries the same pending/synced flag the orders table does.
--
-- Defaults to 1: rows that arrived in a catalogue pull came *from*
-- the back office and must not be posted straight back.
sync_status INTEGER NOT NULL DEFAULT 1,
synced_at INTEGER
)
''');
await db.execute(
'CREATE UNIQUE INDEX idx_customers_mobile ON ${Tables.customers}(mobile)',
);
await db.execute(
'CREATE INDEX idx_customers_sync ON ${Tables.customers}(sync_status)',
);
// -------------------------------------------------------------- orders
await db.execute('''
@@ -322,6 +335,43 @@ Future<void> _upgradeToV4(Database db, {required int from}) async {
await db.execute('DROP TABLE _day_archive_v3');
}
/// Moves the schema to v8 — customers become an outbox.
///
/// Before this, a shopper registered at the till only ever reached the back
/// office as three fields riding along on a bill. Someone who signed up and
/// then didn't buy anything, or whose bill was still queued, existed on one
/// terminal and nowhere else.
///
/// Every existing row is marked pending rather than synced. The terminal
/// cannot tell which of them came down in a catalogue pull and which were rung
/// up locally, and of the two possible mistakes only one loses a shopper. This
/// is safe precisely because the customer uplink is specified as
/// insert-if-absent on id — re-sending one the back office already holds is a
/// no-op, never an overwrite of a profile edited at head office.
///
/// Ids are deliberately *not* rewritten. New customers are keyed on their
/// mobile number (see `Customer.idForMobile`) so terminals agree without
/// coordinating, but rows created before this version carry random ids that
/// bills already in the back office refer to. Re-keying them here would break
/// that link. They stay as they are, and the back office merges them on
/// mobile — a one-off for stores that were already trading.
Future<void> _upgradeToV8(Database db) async {
await db.execute(
'ALTER TABLE ${Tables.customers} '
'ADD COLUMN sync_status INTEGER NOT NULL DEFAULT 1',
);
await db.execute(
'ALTER TABLE ${Tables.customers} ADD COLUMN synced_at INTEGER',
);
await db.execute(
'CREATE INDEX idx_customers_sync ON ${Tables.customers}(sync_status)',
);
await db.rawUpdate(
'UPDATE ${Tables.customers} SET sync_status = 0, synced_at = NULL',
);
}
const String _createSyncLog = '''
CREATE TABLE sync_log (
id TEXT PRIMARY KEY,

View File

@@ -164,7 +164,7 @@ class CatalogueDao {
for (final c in customers) {
batch.insert(
Tables.customers,
customerToRow(c),
_importedCustomerRow(c),
conflictAlgorithm: ConflictAlgorithm.ignore,
);
}
@@ -173,6 +173,18 @@ class CatalogueDao {
});
}
/// A customer row that arrived from the back office, marked as already sent.
///
/// Stated outright rather than left to the column default, because the
/// default existing to protect imports is not obvious from this call site —
/// and getting it wrong would post the whole customer book straight back to
/// the server that just sent it.
static Map<String, Object?> _importedCustomerRow(Customer c) => {
...customerToRow(c),
'sync_status': syncedCustomer,
'synced_at': DateTime.now().millisecondsSinceEpoch,
};
/// Applies a change set, leaving everything it does not mention alone.
///
/// The counterpart to [replaceCatalogue], and the difference matters: a full
@@ -218,7 +230,7 @@ class CatalogueDao {
for (final c in customers) {
batch.insert(
Tables.customers,
customerToRow(c),
_importedCustomerRow(c),
conflictAlgorithm: ConflictAlgorithm.ignore,
);
}
@@ -227,6 +239,24 @@ class CatalogueDao {
});
}
/// Drops every product and forgets when the catalogue was last imported.
///
/// Used at sign-out. Leaves customers, staff, orders and every other table
/// untouched — this is about the shelf, not the terminal's history — so the
/// next session starts with nothing to sell until it pulls a fresh copy from
/// the back office rather than carrying over whatever this session ended
/// with.
Future<void> clearCatalogue() async {
await _db.transaction((txn) async {
await txn.delete(Tables.products);
await txn.delete(
Tables.meta,
where: 'key IN (?, ?)',
whereArgs: [MetaKeys.lastImportAt, MetaKeys.catalogueRevision],
);
});
}
/// Applies stock movement after a sale, clamped at zero.
Future<void> decrementStock(Map<String, double> quantities) async {
if (quantities.isEmpty) return;
@@ -254,11 +284,10 @@ class CatalogueDao {
}
Future<Customer?> customerByMobile(String mobile) async {
final digits = mobile.replaceAll(RegExp(r'\D'), '');
final rows = await _db.query(
Tables.customers,
where: 'mobile = ?',
whereArgs: [digits],
whereArgs: [Customer.normaliseMobile(mobile)],
limit: 1,
);
return rows.isEmpty ? null : customerFromRow(rows.first);
@@ -274,14 +303,59 @@ class CatalogueDao {
return rows.isEmpty ? null : customerFromRow(rows.first);
}
/// Writes a customer created or edited at the till, and queues them.
///
/// Everything registered on this terminal has to reach the back office in its
/// own right — a shopper who signs up for the loyalty scheme and then buys
/// nothing used to exist here and nowhere else.
Future<void> upsertCustomer(Customer c) async {
await _db.insert(
Tables.customers,
customerToRow(c),
{...customerToRow(c), 'sync_status': pendingCustomer, 'synced_at': null},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
// ---------------------------------------------------- Customer outbox
static const int pendingCustomer = 0;
static const int syncedCustomer = 1;
Future<int> unsyncedCustomerCount() async {
final rows = await _db.rawQuery(
'SELECT COUNT(*) AS n FROM ${Tables.customers} WHERE sync_status = ?',
[pendingCustomer],
);
return (rows.first['n']! as num).toInt();
}
/// A bounded page of shoppers waiting to go up, oldest first.
Future<List<Customer>> unsyncedCustomers({int limit = 100}) async {
final rows = await _db.query(
Tables.customers,
where: 'sync_status = ?',
whereArgs: [pendingCustomer],
orderBy: 'created_at ASC',
limit: limit,
);
return rows.map(customerFromRow).toList();
}
/// Flips only the ids the back office named. Anything it stayed silent about
/// is left pending — the same rule the orders outbox follows.
Future<void> markCustomersSynced(List<String> ids, {DateTime? at}) async {
if (ids.isEmpty) return;
final marks = List.filled(ids.length, '?').join(',');
await _db.rawUpdate(
'UPDATE ${Tables.customers} SET sync_status = ?, synced_at = ? '
'WHERE id IN ($marks)',
[
syncedCustomer,
(at ?? DateTime.now()).millisecondsSinceEpoch,
...ids,
],
);
}
// ------------------------------------------------------------------ Meta
Future<String?> meta(String key) async {
final rows = await _db.query(

View File

@@ -61,10 +61,64 @@ class OrderDao {
);
});
if (customerRow != null) {
batch.insert(
// A targeted UPDATE, not an upsert. `ConflictAlgorithm.replace` is a
// DELETE followed by an INSERT, so every column absent from the row
// silently reverts to its schema default — which would reset
// `sync_status` to 1 and strand a shopper who had never been uploaded.
//
// Restricting it to the four figures a sale actually moves also stops
// a bill overwriting a name or number corrected at head office between
// the shopper being added to the cart and the cashier taking payment.
batch.update(
Tables.customers,
customerRow,
conflictAlgorithm: ConflictAlgorithm.replace,
{
'loyalty_points': customerRow['loyalty_points'],
'lifetime_spend': customerRow['lifetime_spend'],
'visit_count': customerRow['visit_count'],
'last_visit_at': customerRow['last_visit_at'],
},
where: 'id = ?',
whereArgs: [customerRow['id']],
);
}
await batch.commit(noResult: true);
});
}
/// Reverses [commitSale]: deletes the order and its lines, adds the stock
/// back, and restores an attached customer's row exactly as passed in
/// (the caller supplies the pre-sale row — reconstructing it from deltas
/// here would get `last_visit_at` wrong).
Future<void> voidSale({
required String orderId,
required Map<String, double> stockMovements,
Map<String, Object?>? customerRow,
}) async {
await _db.transaction((txn) async {
await txn
.delete(Tables.orderItems, where: 'order_id = ?', whereArgs: [orderId]);
await txn.delete(Tables.orders, where: 'id = ?', whereArgs: [orderId]);
final batch = txn.batch();
final now = DateTime.now().millisecondsSinceEpoch;
stockMovements.forEach((id, qty) {
batch.rawUpdate(
'UPDATE ${Tables.products} SET stock = stock + ?, updated_at = ? '
'WHERE id = ?',
[qty, now, id],
);
});
if (customerRow != null) {
batch.update(
Tables.customers,
{
'loyalty_points': customerRow['loyalty_points'],
'lifetime_spend': customerRow['lifetime_spend'],
'visit_count': customerRow['visit_count'],
'last_visit_at': customerRow['last_visit_at'],
},
where: 'id = ?',
whereArgs: [customerRow['id']],
);
}
await batch.commit(noResult: true);

View File

@@ -53,7 +53,13 @@ class TerminalIdentityStore {
///
/// The mint is idempotent: an existing device id is never replaced, so a
/// terminal cannot silently change identity and orphan its own history.
Future<TerminalIdentity> load({String defaultStoreId = 'store-01'}) async {
///
/// [defaultStoreId] matches the store this build's default HTTP endpoint
/// serves — see `syncConfigProvider` — so a fresh terminal's first import
/// pulls that store's real catalogue without anyone visiting Settings
/// first. Settings → Connectivity & sync → Configure changes it per
/// terminal from there.
Future<TerminalIdentity> load({String defaultStoreId = '1135'}) async {
var deviceId = await _catalogue.meta(MetaKeys.deviceId);
var code = await _catalogue.meta(MetaKeys.terminalCode);

View File

@@ -11,10 +11,13 @@ import 'catalogue_wire.dart';
/// Pulls the catalogue from the back office over HTTP.
///
/// ```
/// GET {base}/catalogue?since={revision}&page={n}
/// GET {base}/catalogue?since={revision}&page={n}&page_size={pageSize}&store_id={storeId}
/// Authorization: Bearer {apiKey}
/// ```
///
/// Pages are 0-indexed — the first page requested is `page=0` — matching the
/// back office's own convention rather than the more common 1-indexed one.
///
/// ```json
/// {
/// "revision": "rev-8821",
@@ -53,6 +56,10 @@ class HttpCatalogueSource implements CatalogueSource {
/// connection.
static const int maxPages = 200;
/// Rows requested per page. Sent as `page_size` on every request so the
/// back office doesn't fall back to its own (smaller) default.
static const int pageSize = 500;
static const Duration _timeout = Duration(seconds: 30);
@override
@@ -77,7 +84,7 @@ class HttpCatalogueSource implements CatalogueSource {
var revision = since ?? '';
var isDelta = false;
var page = 1;
var page = 0;
onProgress?.call(0.05, 'Contacting the back office…');
@@ -137,6 +144,7 @@ class HttpCatalogueSource implements CatalogueSource {
queryParameters: {
if (since != null && since.isNotEmpty) 'since': since,
'page': '$page',
'page_size': '$pageSize',
'store_id': config.storeId,
'terminal_id': config.terminalId,
},

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:uuid/uuid.dart';
import '../../core/config/sync_config.dart';
import 'order_transport.dart';
@@ -13,6 +14,12 @@ import 'order_transport.dart';
/// having as the route to bring up first, and as the fallback when a broker is
/// unreachable but the internet is not.
///
/// ```
/// POST {base}/orders
/// { "schema": 1, "batch_id": "…", "store_id": "…", "terminal_id": "…",
/// "orders": [ … ] }
/// ```
///
/// The endpoint must answer with the ids it committed:
///
/// ```json
@@ -29,6 +36,8 @@ class HttpOrderTransport implements OrderTransport {
final SyncConfig config;
final http.Client _client;
static const _uuid = Uuid();
final _connection = StreamController<bool>.broadcast();
bool _reachable = true;
@@ -50,8 +59,53 @@ class HttpOrderTransport implements OrderTransport {
Future<void> connect() async {}
@override
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) async {
if (orders.isEmpty) return const PushReceipt(accepted: []);
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) =>
_post(path: 'orders', key: 'orders', items: orders);
@override
Future<PushReceipt> pushCustomers(List<Map<String, Object?>> customers) =>
_post(path: 'customers', key: 'customers', items: customers);
/// One heartbeat, posted to the back office.
///
/// Nothing is read back and nothing is retried. A heartbeat is only true for
/// the thirty seconds until the next one, so a failed beat is already stale
/// by the time a retry could land — the correct response is to let the board
/// go blank and say so with the next one.
///
/// Every failure is swallowed for the reason the whole reporter swallows
/// them: a till that cannot say how it is must still sell. Stopping a shop
/// because a dashboard was unreachable would be a self-inflicted outage.
@override
Future<void> publishHealth(String payload) async {
if (config.httpBaseUrl.isEmpty) return;
try {
await _client
.post(
Uri.parse('${config.httpBaseUrl}/health'),
headers: {
'content-type': 'application/json',
if (config.apiKey != null)
'authorization': 'Bearer ${config.apiKey}',
},
body: payload,
)
// Deliberately shorter than ackTimeout. A bill is worth waiting
// twenty seconds for; a heartbeat that takes that long would still
// be in flight when the next one is due.
.timeout(const Duration(seconds: 5));
} on Object {
// See above.
}
}
Future<PushReceipt> _post({
required String path,
required String key,
required List<Map<String, Object?>> items,
}) async {
if (items.isEmpty) return const PushReceipt(accepted: []);
if (config.httpBaseUrl.isEmpty) {
throw const TransportException(
@@ -60,7 +114,17 @@ class HttpOrderTransport implements OrderTransport {
);
}
final uri = Uri.parse('${config.httpBaseUrl}/orders');
final uri = Uri.parse('${config.httpBaseUrl}/$path');
// Deterministic from the set of ids in this batch — not a fresh random
// id per attempt — so a retry after a timeout (the same rows, because
// nothing was marked sent) carries the exact same batch_id as the
// attempt that may already have landed. That is what lets the back
// office collapse a retried batch server-side instead of re-billing it.
final batchId = _uuid.v5(
Uuid.NAMESPACE_URL,
items.map((o) => o['id']).join('|'),
);
http.Response response;
try {
@@ -71,16 +135,14 @@ class HttpOrderTransport implements OrderTransport {
'content-type': 'application/json',
if (config.apiKey != null)
'authorization': 'Bearer ${config.apiKey}',
// Lets the endpoint collapse a retried batch server-side rather
// than relying on every order id being checked individually.
'idempotency-key': _batchKey(orders),
'idempotency-key': batchId,
},
body: jsonEncode({
'schema': 1,
'batch_id': batchId,
'store_id': config.storeId,
'terminal_id': config.terminalId,
'sent_at': DateTime.now().toIso8601String(),
'orders': orders,
key: items,
}),
)
.timeout(config.ackTimeout);
@@ -132,11 +194,6 @@ class HttpOrderTransport implements OrderTransport {
return PushReceipt(accepted: accepted, rejected: rejected);
}
/// Stable for a given set of bills, so a retry after a timeout carries the
/// same key as the attempt that may already have landed.
String _batchKey(List<Map<String, Object?>> orders) =>
orders.map((o) => o['id']).join('|').hashCode.toRadixString(16);
void _setReachable(bool value) {
if (_reachable == value) return;
_reachable = value;

View File

@@ -170,8 +170,36 @@ class MqttOrderTransport implements OrderTransport {
// ----------------------------------------------------------------- Uplink
@override
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) async {
if (orders.isEmpty) return const PushReceipt(accepted: []);
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) =>
_publishBatch(
topic: config.orderTopic,
key: 'orders',
items: orders,
noun: 'bills',
);
@override
Future<PushReceipt> pushCustomers(List<Map<String, Object?>> customers) =>
_publishBatch(
topic: config.customerTopic,
key: 'customers',
items: customers,
noun: 'registrations',
);
/// Publishes one correlated batch and waits for the back office to answer it.
///
/// Shared by both uplinks because the rule they must obey is the same one,
/// and it is the rule the whole design rests on: a batch counts as delivered
/// only when the *application* names its ids, never when the broker
/// acknowledges the bytes.
Future<PushReceipt> _publishBatch({
required String topic,
required String key,
required List<Map<String, Object?>> items,
required String noun,
}) async {
if (items.isEmpty) return const PushReceipt(accepted: []);
await connect();
@@ -181,14 +209,14 @@ class MqttOrderTransport implements OrderTransport {
try {
_publish(
config.orderTopic,
topic,
jsonEncode({
'schema': 1,
'batch_id': batchId,
'store_id': config.storeId,
'terminal_id': config.terminalId,
'sent_at': DateTime.now().toIso8601String(),
'orders': orders,
key: items,
}),
);
@@ -196,7 +224,7 @@ class MqttOrderTransport implements OrderTransport {
config.ackTimeout,
onTimeout: () => throw TransportException(
'The back office did not confirm the batch within '
'${config.ackTimeout.inSeconds}s. The bills are still on this '
'${config.ackTimeout.inSeconds}s. The $noun are still on this '
'terminal and will be sent again.',
),
);
@@ -230,6 +258,19 @@ class MqttOrderTransport implements OrderTransport {
_publish(config.statusTopic, payload, retain: true);
}
/// Publishes a heartbeat, deliberately *not* retained.
///
/// The back office holds these in Redis under a TTL, so a terminal that
/// stops beating ages off the board by itself. A retained heartbeat would
/// survive on the broker after the till was unplugged and keep it looking
/// alive until something happened to overwrite it — which is exactly the
/// failure a health board exists to catch.
@override
Future<void> publishHealth(String payload) async {
if (!isConnected) return;
_publish(config.healthTopic, payload);
}
/// Registers a batch as awaiting its ack, without publishing one.
///
/// Lets a test drive the correlation rules — which is where the logic that

View File

@@ -86,6 +86,17 @@ abstract class OrderTransport {
/// Throws [TransportException] when the outcome is unknown.
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders);
/// Hands over shoppers registered at this till.
///
/// Same acceptance contract as [pushOrders] — only ids the back office names
/// are marked sent — but the payload is a registration rather than a
/// financial record, so the back office is expected to treat it as
/// insert-if-absent on id. Replaying one it already holds must be a no-op,
/// never an overwrite of a profile corrected at head office.
///
/// Throws [TransportException] when the outcome is unknown.
Future<PushReceipt> pushCustomers(List<Map<String, Object?>> customers);
/// Cloud-initiated messages. Empty for transports that cannot receive.
Stream<DownlinkMessage> get downlink;
@@ -95,5 +106,18 @@ abstract class OrderTransport {
bool get isConnected;
/// Sends one heartbeat, and never throws.
///
/// On the interface rather than on the broker transport alone, because it was
/// on the broker transport alone and that was the bug: the reporter was
/// started behind an `is MqttOrderTransport` check, so a shop on the HTTP
/// route uploaded every bill correctly and never once appeared on the fleet
/// board. Nothing logged it, because nothing had gone wrong — the feature
/// simply did not exist on that route.
///
/// A transport with nowhere to send it does nothing. That is a real answer,
/// not a stub: the simulated route has no back office to tell.
Future<void> publishHealth(String payload);
Future<void> dispose();
}

View File

@@ -32,21 +32,36 @@ class SimulatedOrderTransport implements OrderTransport {
@override
Future<void> connect() async {}
/// Nowhere to send it. A fresh install has no back office configured, and
/// inventing a destination would only hide that.
@override
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) async {
Future<void> publishHealth(String payload) async {}
@override
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) =>
_accept(orders, 'bill');
@override
Future<PushReceipt> pushCustomers(List<Map<String, Object?>> customers) =>
_accept(customers, 'registration');
Future<PushReceipt> _accept(
List<Map<String, Object?>> items,
String noun,
) async {
await Future<void>.delayed(
Duration(milliseconds: 400 + orders.length * 60),
Duration(milliseconds: 400 + items.length * 60),
);
if (isOffline()) {
throw const TransportException(
throw TransportException(
'Simulate offline is ON in Settings, so the upload was failed on '
'purpose. Every bill is still stored on this terminal.',
'purpose. Every $noun is still stored on this terminal.',
);
}
return PushReceipt(
accepted: orders.map((o) => o['id']! as String).toList(),
accepted: items.map((o) => o['id']! as String).toList(),
);
}

View File

@@ -1,5 +1,3 @@
import 'package:uuid/uuid.dart';
import '../../core/utils/extensions.dart';
import '../../domain/entities/customer.dart';
import '../../domain/repositories/customer_repository.dart';
@@ -9,15 +7,15 @@ class CustomerRepositoryImpl implements CustomerRepository {
CustomerRepositoryImpl(this._store);
final LocalStore _store;
static const _uuid = Uuid();
String _digits(String v) => v.replaceAll(RegExp(r'\D'), '');
@override
Future<Customer?> findByMobile(String mobile) async {
final needle = _digits(mobile);
return _store.customers
.firstWhereOrNull((c) => _digits(c.mobile) == needle);
// Normalised on both sides, so a shopper stored from `9840012345` is still
// found when a cashier at the next till types `+91 98400 12345`.
final needle = Customer.normaliseMobile(mobile);
return _store.customers.firstWhereOrNull(
(c) => Customer.normaliseMobile(c.mobile) == needle,
);
}
@override
@@ -30,9 +28,14 @@ class CustomerRepositoryImpl implements CustomerRepository {
throw StateError('A customer with this mobile number already exists.');
}
final created = Customer(
id: _uuid.v4(),
// Derived from the number, not random — see [Customer.idForMobile].
// Two tills registering the same shopper independently produce the same
// row rather than a duplicate the back office has to reconcile.
id: Customer.idForMobile(customer.mobile),
name: customer.name.trim(),
mobile: _digits(customer.mobile),
// Stored normalised, so the unique index on `mobile` actually catches a
// second attempt to register the same shopper.
mobile: Customer.normaliseMobile(customer.mobile),
email: customer.email?.trim().isEmpty ?? true
? null
: customer.email!.trim(),
@@ -60,11 +63,15 @@ class CustomerRepositoryImpl implements CustomerRepository {
// Stored numbers are digits only, so the query has to be reduced the same
// way — otherwise a cashier typing "98765 43210" or "98-76" matches nothing.
final digits = _digits(q);
//
// Raw digits rather than the normalised form on purpose: this is a partial
// match on whatever has been typed so far, and a half-entered number is not
// a number to be normalised.
final digits = Customer.digitsOf(q);
return _store.customers.where((c) {
if (c.name.toLowerCase().contains(q)) return true;
return digits.isNotEmpty && _digits(c.mobile).contains(digits);
return digits.isNotEmpty && Customer.digitsOf(c.mobile).contains(digits);
}).toList();
}

View File

@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:uuid/uuid.dart';
import '../../core/utils/formatters.dart';
import '../../domain/entities/customer.dart';
import '../../domain/entities/shift_report.dart';
import '../../domain/entities/sync_event.dart';
import '../../domain/entities/transaction.dart';
@@ -333,12 +334,115 @@ class SyncRepositoryImpl implements SyncRepository {
DateTime.now().subtract(OrderDao.retentionWindow),
);
/// The JSON body sent per order.
// ------------------------------------------------- Registrations: uplink
@override
Future<int> unsyncedCustomerCount() =>
_store.catalogue.unsyncedCustomerCount();
@override
Future<SyncOutcome> syncCustomers() async {
final started = DateTime.now();
var attempted = 0;
var uploaded = 0;
while (true) {
final batch = await _store.catalogue.unsyncedCustomers(limit: batchSize);
if (batch.isEmpty) break;
attempted += batch.length;
PushReceipt receipt;
try {
receipt = await _transport.pushCustomers(
batch.map(_customerToPayload).toList(),
);
} on Object catch (e) {
// Nothing is marked sent when the outcome is unknown. Unlike a bill,
// a registration is safe to send twice, so this simply waits for the
// next pass rather than needing a per-row attempt counter.
if (attempted > batch.length || uploaded > 0) {
await _store.refreshUnsyncedCustomerCount();
}
return SyncOutcome(
attempted: attempted,
uploaded: uploaded,
error: e.toString(),
isRetryable: e is! TransportException || e.retryable,
);
}
final acceptedIds = receipt.accepted.toSet();
await _store.catalogue.markCustomersSynced(acceptedIds.toList());
uploaded += acceptedIds.length;
// Nothing moved, so the next page would hand back the same rows for
// ever. Stop and let the events log show why.
if (acceptedIds.isEmpty) {
final reasons = receipt.rejected.values.toSet().join('; ');
await _log(SyncEvent(
id: _uuid.v4(),
type: SyncEventType.catalogueImport,
status: SyncStatus.failed,
createdAt: started,
summary: '${batch.length} registrations were not accepted',
error:
reasons.isEmpty ? 'Not confirmed by the back office' : reasons,
attempts: 1,
),);
await _store.refreshUnsyncedCustomerCount();
return SyncOutcome(
attempted: attempted,
uploaded: uploaded,
rejected: batch.length,
error: 'No registration in this batch was accepted'
'${reasons.isEmpty ? '' : ': $reasons'}',
isRetryable: false,
);
}
}
await _store.refreshUnsyncedCustomerCount();
if (uploaded > 0) {
await _log(SyncEvent(
id: _uuid.v4(),
type: SyncEventType.catalogueImport,
status: SyncStatus.synced,
createdAt: started,
syncedAt: DateTime.now(),
summary: '$uploaded shopper registrations uploaded '
'via ${_transport.label}',
attempts: 1,
),);
}
return SyncOutcome(attempted: attempted, uploaded: uploaded);
}
/// The JSON body sent per registration.
///
/// Identity and profile only. Points, spend and visit counts are deliberately
/// left out: they are derived from the bill stream, which is authoritative and
/// idempotent. Uploading a terminal's local balance would make the last till
/// to sync win, and a shopper who bought something at two counters on the same
/// day would end up with whichever figure arrived second.
Map<String, Object?> _customerToPayload(Customer c) => {
'id': c.id,
'mobile': c.mobile,
'name': c.name,
'email': c.email,
'gender': c.gender.name,
'date_of_birth': c.dateOfBirth?.toIso8601String(),
'registered_at': _iso(c.createdAt),
'registered_by_terminal': _store.terminal.code,
};
/// The JSON body sent per order. Matches the back office's `/orders`
/// schema field for field — nothing added beyond it.
Map<String, Object?> _orderToPayload(SaleTransaction t) => {
'id': t.id,
'invoice_number': t.invoiceNumber,
'created_at': t.createdAt.toIso8601String(),
'terminal_id': t.terminalId,
'created_at': Formatters.isoWithOffset(t.createdAt),
'cashier': t.cashierName,
'customer': t.customer == null
? null
@@ -349,16 +453,16 @@ class SyncRepositoryImpl implements SyncRepository {
},
'subtotal': t.cart.subtotal,
'discount': t.cart.billDiscountTotal + t.cart.lineDiscountTotal,
'promos': [
for (final applied in t.cart.appliedPromos)
{
'id': applied.promo.id,
'name': applied.promo.name,
'type': applied.promo.type.name,
'amount': applied.amount,
},
],
'tax': t.cart.taxAmount,
// GST per slab, as printed on the invoice. Sent as well as the total
// because a compliant tax return is filed per slab, and recomputing the
// split server-side from line items would have to redo the discount
// apportionment — and get exactly the same answer, or the filed figure
// stops matching the paper the shopper was handed.
'tax_breakdown': {
for (final entry in t.cart.taxBreakdown.entries)
entry.key.toString(): entry.value,
},
'round_off': t.cart.roundOff,
'total': t.total,
'points_earned': t.pointsEarned,
@@ -387,3 +491,12 @@ class SyncRepositoryImpl implements SyncRepository {
],
};
}
/// [Formatters.isoWithOffset], tolerating a null.
///
/// Several of these are genuinely absent — a till that has never uploaded has
/// no last upload, and one with an empty queue has no oldest pending bill.
/// Omitting the key is the honest answer; sending an epoch would put 1970 on a
/// dashboard and read as a real reading.
String? _iso(DateTime? time) =>
time == null ? null : Formatters.isoWithOffset(time);

View File

@@ -30,6 +30,35 @@ class TransactionRepositoryImpl implements TransactionRepository {
await _store.refreshUnsyncedCount();
}
@override
Future<void> voidSale({
required SaleTransaction transaction,
required Map<String, double> stockMovements,
}) async {
// The customer attached to the cart is the pre-sale snapshot — commitSale
// never mutates it, only the separate `updatedCustomer` it computed — so
// restoring exactly this row undoes the loyalty movement precisely,
// rather than trying to reconstruct it from a delta.
final preSaleCustomer = transaction.cart.customer;
await _store.orders.voidSale(
orderId: transaction.id,
stockMovements: stockMovements,
customerRow: preSaleCustomer == null
? null
: CatalogueDao.customerToRow(preSaleCustomer),
);
// Disk is reverted; bring the read caches back in line with it. Negating
// the same map reuses cacheStockMovement's "subtract" semantics to add
// the stock back instead.
_store.cacheStockMovement(
stockMovements.map((id, qty) => MapEntry(id, -qty)),
);
if (preSaleCustomer != null) _store.cacheCustomer(preSaleCustomer);
await _store.refreshUnsyncedCount();
}
@override
Future<List<SaleTransaction>> history({int limit = 50}) =>
_store.orders.recent(limit: limit);

View File

@@ -0,0 +1,226 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import '../../core/config/sync_config.dart';
import '../../core/utils/formatters.dart';
import '../../domain/repositories/sync_repository.dart';
import '../local/terminal_identity.dart';
import '../remote/order_transport.dart';
import 'sync_engine.dart';
/// What a till reports about itself, every 30 seconds.
///
/// The Last Will already answers *is it dead* — the broker publishes `offline`
/// on a terminal's behalf when it stops responding. That is not enough to run a
/// hundred shops on, because the failure that actually costs money looks
/// completely healthy from outside: a till that is connected, selling, and
/// quietly accumulating two hundred bills it has never managed to upload.
///
/// So this carries the numbers that separate *reachable* from *well*: how deep
/// the queue is, how long the oldest thing in it has been waiting, whether the
/// till has rung anything today, and whether the hardware is in the way.
///
/// Not retained, and never acknowledged. A heartbeat is a fact with an expiry
/// date — the back office holds it in Redis under a TTL, so a terminal that
/// loses power ages off the board by itself. Retaining it would leave a dead
/// till looking alive until something overwrote it.
class HealthReporter {
HealthReporter({
required OrderTransport transport,
required TerminalIdentity terminal,
required SyncConfig config,
required SyncEngine engine,
required SyncRepository repository,
required this.appVersion,
this.deviceState,
this.printerEndpoint,
Duration interval = const Duration(seconds: 30),
DateTime Function()? clock,
Timer Function(Duration, void Function())? scheduleTimer,
}) : _transport = transport,
_terminal = terminal,
_config = config,
_engine = engine,
_repository = repository,
_interval = interval,
_now = clock ?? DateTime.now,
_schedule = scheduleTimer ?? Timer.new;
final OrderTransport _transport;
final TerminalIdentity _terminal;
final SyncConfig _config;
final SyncEngine _engine;
final SyncRepository _repository;
final Duration _interval;
final DateTime Function() _now;
final Timer Function(Duration, void Function()) _schedule;
final String appVersion;
/// Hardware readings, if this build collects any.
///
/// A hook rather than a hard dependency: battery level and free storage need
/// platform packages that a desktop build has no use for, and a health board
/// is not a good enough reason to make the whole app depend on them. What is
/// not collected is *omitted* rather than sent as zero — a dashboard showing
/// every till at 0% battery is worse than one showing nothing.
final Future<Map<String, Object?>> Function()? deviceState;
/// Where the receipt printer lives, if one is configured.
///
/// Read fresh on each beat rather than captured once, because a shop can
/// re-point its printer in Settings without restarting the till.
final ({String host, int port})? Function()? printerEndpoint;
Timer? _timer;
bool _stopped = false;
Future<void> start() async {
if (_stopped) throw StateError('This HealthReporter has been disposed.');
await publish();
_tick();
}
void _tick() {
_timer = _schedule(_interval, () {
if (_stopped) return;
unawaited(publish());
_tick();
});
}
/// One heartbeat.
///
/// Every failure is swallowed. A terminal that cannot say how it is must
/// still sell — losing a heartbeat is a monitoring gap, and stopping a till
/// because a dashboard is unreachable would be a self-inflicted outage.
Future<void> publish() async {
if (!_transport.isConnected) return;
try {
final state = _engine.state;
final pendingBills = state.pending;
final pendingRegistrations = await _repository.unsyncedCustomerCount();
// Terminal-wide rather than scoped to whoever is signed in: the board
// watches a till, not a shift.
final today = await _repository.todayReport(
terminalId: _terminal.code,
cashierName: '',
);
final payload = <String, Object?>{
'schema': 1,
'status': 'online',
'terminal_id': _terminal.code,
'device_id': _terminal.deviceId,
'terminal_name': _terminal.name,
'location_id': _config.storeId,
'store_name': _terminal.name,
'app_version': appVersion,
'transport': _config.transport.name,
// Queue depth — the number that makes a silent failure visible.
'pending_bills': pendingBills,
'pending_registrations': pendingRegistrations,
'oldest_pending_at': _iso(await _oldestPendingAt()),
'sync_halted': state.isHalted,
'sync_error': state.lastError,
'last_upload_at': _iso(state.lastSuccessAt),
// Today's trading. A till that is connected but has rung nothing in
// three hours is usually a jammed printer or an absent cashier, and
// neither shows up on an online/offline board.
'today_bills': today.billCount,
'today_amount': today.grossSales,
'last_bill_at': _iso(today.lastBillAt),
'reported_at': Formatters.isoWithOffset(_now()),
};
final device = await _collectDeviceState();
payload.addAll(device);
await _transport.publishHealth(jsonEncode(payload));
} on Object {
// Deliberately silent — see above.
}
}
/// When the oldest unsent bill was rung.
///
/// More useful than the count on its own: fifty bills queued in the last ten
/// minutes is a broker hiccup, while three queued since Tuesday is a till
/// nobody has looked at.
Future<DateTime?> _oldestPendingAt() async {
try {
final rows = await _repository.orderSyncRows(limit: 500);
DateTime? oldest;
for (final row in rows) {
if (row.isSynced) continue;
if (oldest == null || row.createdAt.isBefore(oldest)) {
oldest = row.createdAt;
}
}
return oldest;
} on Object {
return null;
}
}
/// Hardware readings, plus whatever this build can work out for itself.
Future<Map<String, Object?>> _collectDeviceState() async {
final out = <String, Object?>{};
if (deviceState != null) {
try {
out.addAll(await deviceState!());
} on Object {
// A missing battery reading must not cost the rest of the heartbeat.
}
}
final printer = printerEndpoint?.call();
if (printer != null && printer.host.isNotEmpty) {
out['printer_reachable'] = await _canReach(printer.host, printer.port);
}
return out;
}
/// Opens and immediately closes a socket to the till's printer.
///
/// Cheap enough to run every 30 seconds, and it answers the question a shop
/// actually phones about — a printer that is switched off looks identical to
/// a working one until someone tries to print a bill.
Future<bool> _canReach(String host, int port) async {
try {
final socket = await Socket.connect(
host,
port,
timeout: const Duration(seconds: 2),
);
socket.destroy();
return true;
} on Object {
return false;
}
}
Future<void> dispose() async {
_stopped = true;
_timer?.cancel();
}
}
/// [Formatters.isoWithOffset], tolerating a null.
///
/// Several of these are genuinely absent — a till that has never uploaded has
/// no last upload, and one with an empty queue has no oldest pending bill.
/// Omitting the key is the honest answer; sending an epoch would put 1970 on a
/// dashboard and read as a real reading.
String? _iso(DateTime? time) =>
time == null ? null : Formatters.isoWithOffset(time);

View File

@@ -250,6 +250,17 @@ class SyncEngine {
SyncOutcome outcome;
try {
try {
// Registrations first, so a bill naming a shopper the back office has
// never heard of arrives after the shopper does. A failure here is
// logged and swallowed: shoppers waiting to go up must never be the
// reason a day's takings stay on the terminal.
try {
await _repository.syncCustomers();
} on Object {
// Deliberately ignored — the next pass tries again, and the events
// log already carries the reason.
}
outcome = await _repository.syncOrders(onProgress: onProgress);
} on Object catch (e) {
// The repository is meant to fold failures into the outcome; anything

View File

@@ -177,13 +177,113 @@ class Cart extends Equatable {
return v.clamp(0, double.infinity).toDouble().asMoney;
}
/// Proportion of the bill remaining after bill-level reductions. Used to
/// spread those reductions fairly across lines when apportioning GST.
double get _billFactor => subtotal <= 0 ? 1 : netAmount / subtotal;
/// Bill-level reductions, allocated to the lines that earned them.
///
/// Returns one figure per line, in [lines] order, summing to exactly
/// `subtotal - netAmount`.
///
/// This exists because GST is charged per line at that line's own slab, so
/// *which* line a discount lands on changes the tax. A bill-wide reduction —
/// a tier discount, a manual markdown, points redeemed — genuinely belongs to
/// every line, and spreading it pro rata is right. A campaign that names a
/// category or a product does not: taking "20% off Beverages" out of the
/// atta line as well understates the 18% slab and overstates the 5% one. The
/// bill total is identical either way, which is exactly why the error is easy
/// to ship — it only shows up in the slab split on a filed return.
List<double> get _lineReductions {
final result = List<double>.filled(lines.length, 0);
if (lines.isEmpty) return result;
// What the shopper actually saved at bill level, after the clamps in
// [billDiscountTotal] and [netAmount] have had their say.
final ceiling = (subtotal - netAmount).asMoney;
if (ceiling <= 0) return result;
void spread(double amount, bool Function(CartLine) targets) {
if (amount <= 0) return;
final matched = <int>[];
var base = 0.0;
for (var i = 0; i < lines.length; i++) {
if (!targets(lines[i])) continue;
matched.add(i);
base += lines[i].payable;
}
if (base <= 0) return;
for (final i in matched) {
result[i] += amount * (lines[i].payable / base);
}
}
for (final applied in appliedPromos) {
spread(applied.amount, (l) => applied.promo.targets(l.product));
}
spread(membershipDiscountAmount, (_) => true);
spread(manualBillDiscountAmount, (_) => true);
spread(loyaltyRedemptionValue, (_) => true);
return _fitToCeiling(result, ceiling);
}
/// Scales [raw] so it sums to [ceiling], with no line reduced below zero.
///
/// The components arrive individually clamped and then clamped again as a
/// group, so their raw sum is only approximately what came off the bill.
/// Scaling reconciles the two. Capping is a separate pass because a targeted
/// campaign can take a line to zero on its own, and the tier discount layered
/// on top would otherwise push it negative — which would show up as a
/// *credit* in that line's GST slab.
List<double> _fitToCeiling(List<double> raw, double ceiling) {
final out = List<double>.filled(raw.length, 0);
final open = [for (var i = 0; i < raw.length; i++) i];
var pool = ceiling;
// Loops because capping one line hands its excess back to the pool, which
// can in turn push another line past its own value.
while (open.isNotEmpty && pool > 0) {
final weight = open.fold(0.0, (sum, i) => sum + raw[i]);
if (weight <= 0) break;
final capped = open
.where((i) => pool * (raw[i] / weight) >= lines[i].payable)
.toList();
if (capped.isEmpty) {
for (final i in open) {
out[i] = pool * (raw[i] / weight);
}
break;
}
for (final i in capped) {
out[i] = lines[i].payable;
pool -= lines[i].payable;
open.remove(i);
}
}
return out;
}
/// What each line is worth after its share of the bill-level reductions.
List<double> get _lineNetAmounts {
final reductions = _lineReductions;
return [
for (var i = 0; i < lines.length; i++)
(lines[i].payable - reductions[i]).clamp(0, double.infinity).toDouble(),
];
}
/// GST payable across the bill, after apportioning bill-level discounts.
double get taxAmount =>
lines.fold(0.0, (sum, l) => sum + l.taxAmount * _billFactor).asMoney;
double get taxAmount {
final nets = _lineNetAmounts;
var total = 0.0;
for (var i = 0; i < lines.length; i++) {
total += nets[i] - nets[i] / (1 + lines[i].product.gstRate);
}
return total.asMoney;
}
double get cgst => (taxAmount / 2).asMoney;
double get sgst => (taxAmount / 2).asMoney;
@@ -198,10 +298,11 @@ class Cart extends Equatable {
/// side of the total printed on the same bill, which a tax invoice cannot
/// show; the residue is absorbed by the largest slab.
Map<double, double> get taxBreakdown {
final nets = _lineNetAmounts;
final raw = <double, double>{};
for (final line in lines) {
final rate = line.product.gstRate;
raw[rate] = (raw[rate] ?? 0) + line.taxAmount * _billFactor;
for (var i = 0; i < lines.length; i++) {
final rate = lines[i].product.gstRate;
raw[rate] = (raw[rate] ?? 0) + (nets[i] - nets[i] / (1 + rate));
}
if (raw.isEmpty) return const {};

View File

@@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart';
import 'package:uuid/uuid.dart';
import '../../core/constants/app_constants.dart';
import '../../core/utils/extensions.dart';
@@ -74,6 +75,51 @@ class Customer extends Equatable {
final DateTime? createdAt;
final DateTime? lastVisitAt;
/// Fixed namespace for customer ids. Must never change: it is half the
/// input to [idForMobile], so a new one renames every shopper in the fleet.
static const _namespace = '9f2b7c14-3d6e-5a80-b1f7-2c4e8a05d913';
static const _uuid = Uuid();
/// The id for the shopper reachable on [mobile].
///
/// Derived from the number rather than minted at random, which is what lets
/// a hundred terminals agree without talking to each other. A shopper who
/// registers at counter 2 in Anna Nagar and shops at counter 5 in T Nagar
/// gets the same id both times, so the back office collapses them on a
/// primary key instead of guessing at a merge later.
static String idForMobile(String mobile) =>
_uuid.v5(_namespace, normaliseMobile(mobile));
/// Every digit in [mobile], in order. Used for matching what a cashier types
/// against what is stored, where a partial number should still find a row.
static String digitsOf(String mobile) => mobile.replaceAll(RegExp(r'\D'), '');
/// Reduces a number to the ten-digit national one identity is keyed on.
///
/// One cashier types `+91 98400 12345`, another `098400 12345`, a third
/// `9840012345`. Keyed on raw digits those are three different shoppers,
/// which is precisely the duplication [idForMobile] exists to prevent — the
/// country code would fork a customer just as effectively as a random id.
///
/// Only the two prefixes an Indian number actually carries are stripped, and
/// only at the exact lengths that make them unambiguous. Anything else is
/// left alone: mangling a number this rule was not written for is worse than
/// storing it verbatim.
static String normaliseMobile(String mobile) {
final digits = digitsOf(mobile);
// +91 98400 12345
if (digits.length == 12 && digits.startsWith('91')) {
return digits.substring(2);
}
// 0 98400 12345 — the old STD trunk prefix, still muscle memory for many.
if (digits.length == 11 && digits.startsWith('0')) {
return digits.substring(1);
}
return digits;
}
MembershipTier get tier => MembershipTier.forSpend(lifetimeSpend);
/// Cash value of the points currently held.

View File

@@ -1,6 +1,7 @@
import 'package:equatable/equatable.dart';
import '../../core/constants/app_constants.dart';
import 'product.dart';
/// What a promo does to a bill.
enum PromoType {
@@ -115,6 +116,28 @@ class Promo extends Equatable {
static DateTime _endOfDay(DateTime day) =>
DateTime(day.year, day.month, day.day, 23, 59, 59, 999);
/// Whether this campaign is aimed at [product] in particular.
///
/// A bill-wide promo targets everything; a category or product one targets
/// only what it names. Two things read this and they must never disagree:
/// `PromoEngine` uses it to price the discount, and [Cart] uses it to decide
/// which lines carry the GST reduction. A campaign priced against one set of
/// lines and taxed against another puts the wrong figure in a slab on a
/// filed return, so the rule lives here once rather than in both callers.
bool targets(Product product) => switch (type) {
PromoType.percentOffBill || PromoType.flatOffBill => true,
// Matched on the enum name, which is stable across a label change —
// renaming "Personal Care" must not silently switch off a campaign.
PromoType.percentOffCategory => product.category.name == targetId,
PromoType.percentOffProduct || PromoType.buyXGetY =>
product.id == targetId,
};
/// Whether the discount lands on named lines rather than the whole bill.
bool get isTargeted => type.needsTarget;
/// One-line description for the campaign list.
String get summary => switch (type) {
PromoType.percentOffBill => '${_trim(value)}% off the whole bill',

View File

@@ -90,6 +90,17 @@ abstract class SyncRepository {
void Function(double progress, String stage)? onProgress,
});
/// How many shoppers registered at this till are still waiting to go up.
Future<int> unsyncedCustomerCount();
/// Uploads shoppers registered at this till.
///
/// Deliberately separate from [syncOrders]. A registration is not a financial
/// record: it can be replayed safely, and it must not be stuck behind a bill
/// the back office has refused. Run it first so a bill referring to a new
/// shopper arrives after the shopper does.
Future<SyncOutcome> syncCustomers();
/// Retires confirmed bills past their retention window. Archived totals are
/// untouched.
Future<int> purgeExpired();

View File

@@ -13,6 +13,18 @@ abstract class TransactionRepository {
Customer? updatedCustomer,
});
/// Reverses a sale still inside its cancellation window: deletes the order,
/// restores the stock it consumed, and puts an attached shopper's loyalty
/// balance back to what it was immediately before the sale.
///
/// Only valid before the bill has been offered to the back office — this
/// does not send a cancellation anywhere, it erases the sale as if it had
/// never happened locally.
Future<void> voidSale({
required SaleTransaction transaction,
required Map<String, double> stockMovements,
});
Future<List<SaleTransaction>> history({int limit = 50});
Future<SaleTransaction?> findByInvoice(String invoiceNumber);

View File

@@ -90,18 +90,14 @@ class PromoEngine {
final raw = switch (promo.type) {
PromoType.percentOffBill => cart.subtotal * (promo.value / 100),
PromoType.flatOffBill => promo.value,
PromoType.percentOffCategory => _percentOfMatching(
cart,
promo.value,
// Stored by enum name, which is stable across a label change —
// renaming "Personal Care" must not silently switch off a campaign.
(line) => line.product.category.name == promo.targetId,
),
PromoType.percentOffProduct => _percentOfMatching(
cart,
promo.value,
(line) => line.product.id == promo.targetId,
),
// Both delegate the "does this line count?" question to the promo
// itself, because [Cart] asks the same question when it decides which
// lines carry the GST reduction. Answering it twice invites the two to
// drift apart.
PromoType.percentOffCategory =>
_percentOfMatching(cart, promo.value, promo),
PromoType.percentOffProduct =>
_percentOfMatching(cart, promo.value, promo),
PromoType.buyXGetY => _buyXGetY(cart, promo),
};
@@ -112,13 +108,9 @@ class PromoEngine {
return capped.clamp(0, cart.subtotal).toDouble().asMoney;
}
static double _percentOfMatching(
Cart cart,
double percent,
bool Function(CartLine) matches,
) {
static double _percentOfMatching(Cart cart, double percent, Promo promo) {
final base = cart.lines
.where(matches)
.where((line) => promo.targets(line.product))
.fold(0.0, (sum, line) => sum + line.payable);
return base * (percent / 100);
}

View File

@@ -126,7 +126,14 @@ class AuthController extends StateNotifier<AuthState> {
);
}
void signOut() => state = const Unauthenticated();
/// The next shift should only ever bill against what the back office
/// answers with, never a catalogue instance carried over from this
/// session — so the local product table is dropped before the session
/// itself is.
Future<void> signOut() async {
await _ref.read(localStoreProvider).clearCatalogue();
state = const Unauthenticated();
}
void clearError() {
if (state is AuthFailure) state = const Unauthenticated();

View File

@@ -106,14 +106,7 @@ class _BrandPanel extends StatelessWidget {
borderRadius: BorderRadius.circular(11),
),
alignment: Alignment.center,
child: const Text(
'N',
style: TextStyle(
color: AppColors.primary,
fontSize: 23,
fontWeight: FontWeight.w800,
),
),
child: Image.asset('assets/images/logo.png', fit: BoxFit.contain),
),
const SizedBox(width: AppSpacing.md),
const Flexible(

View File

@@ -5,7 +5,6 @@ import '../../../app/providers.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/formatters.dart';
import '../../../core/widgets/status_pill.dart';
import '../../../domain/entities/customer.dart';
import '../../customer/widgets/customer_capture_sheet.dart';
import '../widgets/module_widgets.dart';
@@ -24,14 +23,6 @@ class CustomersView extends ConsumerStatefulWidget {
class _CustomersViewState extends ConsumerState<CustomersView> {
String _query = '';
MembershipTier? _tier;
Color _tierColor(MembershipTier t) => switch (t) {
MembershipTier.bronze => AppColors.tierBronze,
MembershipTier.silver => AppColors.tierSilver,
MembershipTier.gold => AppColors.tierGold,
MembershipTier.platinum => AppColors.tierPlatinum,
};
@override
Widget build(BuildContext context) {
@@ -39,10 +30,9 @@ class _CustomersViewState extends ConsumerState<CustomersView> {
final filtered = all.where((c) {
final q = _query.trim().toLowerCase();
final matchesQuery = q.isEmpty ||
return q.isEmpty ||
c.name.toLowerCase().contains(q) ||
c.mobile.contains(q);
return matchesQuery && (_tier == null || c.tier == _tier);
}).toList();
final lifetime = all.fold<double>(0, (s, c) => s + c.lifetimeSpend);
@@ -85,26 +75,6 @@ class _CustomersViewState extends ConsumerState<CustomersView> {
),
const SizedBox(height: AppSpacing.lg),
PanelCard(
title: 'Tier distribution',
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final t in MembershipTier.values)
ProgressRow(
label: '${t.label} · '
'${(t.discountRate * 100).toStringAsFixed(0)}% off',
value: '${all.where((c) => c.tier == t).length}',
fraction: all.isEmpty
? 0
: all.where((c) => c.tier == t).length / all.length,
color: _tierColor(t),
),
],
),
),
const SizedBox(height: AppSpacing.lg),
PanelCard(
title: 'Customer book',
subtitle: '${filtered.length} shown',
@@ -134,45 +104,11 @@ class _CustomersViewState extends ConsumerState<CustomersView> {
isDense: true,
),
),
const SizedBox(height: AppSpacing.md),
Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: [
ChoiceChip(
label: const Text('All tiers'),
selected: _tier == null,
onSelected: (_) => setState(() => _tier = null),
labelStyle: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: _tier == null
? Colors.white
: AppColors.textSecondary,
),
),
for (final t in MembershipTier.values)
ChoiceChip(
label: Text(t.label),
selected: _tier == t,
onSelected: (_) =>
setState(() => _tier = _tier == t ? null : t),
labelStyle: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: _tier == t
? Colors.white
: AppColors.textSecondary,
),
),
],
),
const SizedBox(height: AppSpacing.lg),
ResponsiveTable(
columns: const [
TableCol('Customer', flex: 4),
TableCol('Mobile', flex: 3, priority: 1),
TableCol('Tier', flex: 2),
TableCol('Points', flex: 2, numeric: true, priority: 1),
TableCol('Lifetime', flex: 2, numeric: true),
TableCol('Visits', flex: 2, numeric: true, priority: 1),
@@ -199,7 +135,6 @@ class _CustomersViewState extends ConsumerState<CustomersView> {
],
),
Cell(Formatters.mobile(c.mobile), mono: true),
StatusPill.tier(c.tier, dense: true),
Cell('${c.loyaltyPoints}', mono: true),
Cell(Formatters.moneyCompact(c.lifetimeSpend),
mono: true, bold: true,),

View File

@@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/providers.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/formatters.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../data/sync/sync_engine.dart';
import '../../../domain/entities/transaction.dart';
import '../../../domain/repositories/sync_repository.dart';
import '../../sync/providers/sync_controller.dart';
@@ -26,10 +28,21 @@ class EventsView extends ConsumerWidget {
final syncState = ref.watch(orderSyncProvider);
final events = ref.watch(syncEventsProvider);
// The engine may not have emitted yet on a cold start, so fall back to
// its current value rather than showing nothing. This is the same state
// that drives the header pill — it is what actually knows whether the
// automatic push right after a sale succeeded, not just what the manual
// "Sync" button on this page last did.
final engine = ref.watch(syncEngineStateProvider).value ??
ref.watch(syncEngineProvider).state;
final r = report.value;
return ModulePage(
children: [
if (engine.lastError != null && pending > 0)
_EngineWarningBanner(engine: engine),
Wrap(
spacing: AppSpacing.lg,
runSpacing: AppSpacing.lg,
@@ -310,3 +323,81 @@ class EventsView extends ConsumerWidget {
),
);
}
/// Flags a bill that could not reach the server on its own — the automatic
/// push right after checkout, not the manual "Sync" button below.
///
/// Sits above everything else on the page because a bill stuck here is the
/// one thing on this screen that needs a person to notice it, rather than
/// just waiting for the next background retry.
class _EngineWarningBanner extends StatelessWidget {
const _EngineWarningBanner({required this.engine});
final SyncEngineState engine;
@override
Widget build(BuildContext context) {
final halted = engine.isHalted;
final color = halted ? AppColors.danger : AppColors.warning;
final surface = halted ? AppColors.dangerSurface : AppColors.warningSurface;
final retry = engine.nextAttemptAt;
final retryNote = halted
? 'Retrying will not help until this is fixed — press Sync below '
'once it is sorted.'
: retry == null
? 'It will retry automatically, or press Sync below to try now.'
: 'It will retry automatically at ${Formatters.time(retry)}, or '
'press Sync below to try now.';
return Container(
margin: const EdgeInsets.only(bottom: AppSpacing.lg),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(color: surface, borderRadius: AppRadius.brLg),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.wifi_off_rounded, size: 20, color: color),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
halted
? 'Sync halted — ${engine.pending} bill(s) not sent'
: "Couldn't reach the server — "
'${engine.pending} bill(s) not sent',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: color,
),
),
const SizedBox(height: 2),
Text(
engine.lastError ?? 'The last upload attempt failed.',
style: TextStyle(
fontSize: 12.5,
color: color,
height: 1.45,
),
),
const SizedBox(height: 4),
Text(
'$retryNote Every bill is still safe on this terminal — '
'nothing is lost while it waits.',
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
height: 1.45,
),
),
],
),
),
],
),
);
}
}

View File

@@ -4,7 +4,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/providers.dart';
import '../../../core/utils/extensions.dart';
import '../../../data/sync/sync_engine.dart';
import '../../../domain/entities/transaction.dart';
import '../../../domain/usecases/checkout_sale.dart';
import '../../auth/providers/auth_controller.dart';
@@ -80,29 +79,27 @@ class PaymentController extends StateNotifier<PaymentState> {
/// Whether Complete Sale should be enabled.
///
/// Cash is the strict case: the drawer cannot be reconciled and no change can
/// be calculated unless the cashier states what was handed over. Card, UPI
/// and wallet settle on the external terminal, so they need no amount here.
/// Every method now requires the cashier to state what was actually
/// received before the sale can complete — a tap on Exact for the common
/// case, or a typed amount for a partial tender. `cashTendered` is the
/// amount entered for whichever method is currently active, not literally
/// cash; the name stayed to keep this change out of the rest of the app.
bool get canConfirm {
if (_billTotal <= 0) return false;
// Staged splits already cover the bill.
if (balanceDue <= 0.01) return true;
if (state.activeMethod.needsChange) {
return state.cashTendered >= balanceDue;
}
return true;
return state.cashTendered >= balanceDue;
}
/// Why the button is disabled, for display next to it.
String? get blockedReason {
if (_billTotal <= 0) return 'Add at least one item before charging.';
if (canConfirm) return null;
if (state.activeMethod.needsChange) {
return 'Enter the cash received, or tap Exact.';
}
return null;
return state.activeMethod.needsChange
? 'Enter the cash received, or tap Exact.'
: 'Enter the amount received, or tap Exact.';
}
void selectMethod(PaymentMethod method) {
@@ -212,10 +209,11 @@ class PaymentController extends StateNotifier<PaymentState> {
_ref.invalidate(visibleProductsProvider);
_ref.read(orderVersionProvider.notifier).state++;
// The bill is safely on disk; getting it to the back office is the
// engine's problem now. Deliberately not awaited — the cashier must
// reach the receipt screen at network speed of zero.
_ref.read(syncEngineProvider).nudge(SyncTrigger.saleCommitted);
// Deliberately NOT nudging the sync engine here. The bill sits on this
// terminal, unsynced, until the receipt screen's cancellation window
// closes — either it is pressed past early with New Sale, or the
// window runs out — or the sale is voided if the cashier cancels
// instead. See ReceiptScreen.
return result;
} on CheckoutFailure catch (e) {

View File

@@ -350,20 +350,41 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
return GlassCard(
padding: const EdgeInsets.all(AppSpacing.lg),
radius: AppRadius.xl,
child: state.activeMethod.needsChange
? _cashTender(controller)
: _referenceTender(controller, state),
child: _amountTender(controller, state),
);
}
Widget _cashTender(PaymentController controller) {
/// Amount entry for whichever method is active. Every method works the
/// same way now — a keypad, an Exact shortcut, and an explicit amount —
/// rather than cash alone asking what was received while card/UPI/wallet
/// silently assumed the full balance. Cash additionally gets denomination
/// chips and a change-due row, since only cash can be over-tendered; a
/// method that captures a reference (card, UPI, gift card) additionally
/// gets that field below the keypad.
Widget _amountTender(PaymentController controller, PaymentState state) {
final cash = state.activeMethod.needsChange;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Cash received',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
Row(
children: [
Text(state.activeMethod.emoji, style: const TextStyle(fontSize: 20)),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
cash
? 'Cash received'
: '${state.activeMethod.label} amount received',
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: AppSpacing.md),
Container(
@@ -405,16 +426,20 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
label: const Text('Exact'),
onPressed: () => _setCash(controller.balanceDue),
),
for (final note in const [50, 100, 200, 500, 2000])
ActionChip(
label: Text('$note'),
onPressed: () =>
_setCash((double.tryParse(_cashBuffer) ?? 0) + note),
),
// Denomination shortcuts only make sense for physical notes.
if (cash)
for (final note in const [50, 100, 200, 500, 2000])
ActionChip(
label: Text('$note'),
onPressed: () =>
_setCash((double.tryParse(_cashBuffer) ?? 0) + note),
),
],
),
const SizedBox(height: AppSpacing.md),
_changeRow(controller.changeDue),
if (cash) ...[
const SizedBox(height: AppSpacing.md),
_changeRow(controller.changeDue),
],
const SizedBox(height: AppSpacing.md),
Center(
child: NumericKeypad(
@@ -424,6 +449,21 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
onBackspace: _backspaceCash,
),
),
if (state.activeMethod.needsReference) ...[
const SizedBox(height: AppSpacing.md),
TextField(
onChanged: controller.setReference,
decoration: InputDecoration(
labelText: switch (state.activeMethod) {
PaymentMethod.card => 'Approval code',
PaymentMethod.upi => 'UPI transaction ID',
PaymentMethod.giftCard => 'Gift card number',
_ => 'Reference',
},
prefixIcon: const Icon(Icons.tag_rounded),
),
),
],
const SizedBox(height: AppSpacing.md),
_splitButton(controller, amount: double.tryParse(_cashBuffer) ?? 0),
],
@@ -473,71 +513,6 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
);
}
Widget _referenceTender(PaymentController controller, PaymentState state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Text(state.activeMethod.emoji,
style: const TextStyle(fontSize: 20),),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
'${state.activeMethod.label} payment',
overflow: TextOverflow.ellipsis,
style:
const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
),
],
),
const SizedBox(height: AppSpacing.xxl),
Center(
child: Container(
width: 104,
height: 104,
decoration: const BoxDecoration(
color: AppColors.primarySurface,
borderRadius: AppRadius.brXl,
),
alignment: Alignment.center,
child: Text(state.activeMethod.emoji,
style: const TextStyle(fontSize: 46),),
),
),
const SizedBox(height: AppSpacing.lg),
Text(
'Charge ${Formatters.money(controller.balanceDue)} on the '
'${state.activeMethod.label.toLowerCase()} terminal',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 13.5,
color: AppColors.textSecondary,
height: 1.5,
),
),
const SizedBox(height: AppSpacing.xxl),
if (state.activeMethod.needsReference)
TextField(
onChanged: controller.setReference,
decoration: InputDecoration(
labelText: switch (state.activeMethod) {
PaymentMethod.card => 'Approval code',
PaymentMethod.upi => 'UPI transaction ID',
PaymentMethod.giftCard => 'Gift card number',
_ => 'Reference',
},
prefixIcon: const Icon(Icons.tag_rounded),
),
),
const SizedBox(height: AppSpacing.lg),
_splitButton(controller),
],
);
}
Widget _splitButton(PaymentController controller, {double? amount}) {
return OutlinedButton.icon(
onPressed: controller.balanceDue > 0
@@ -622,18 +597,17 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
),
),
),
if (state.activeMethod.needsChange)
TextButton(
onPressed: () => _setCash(controller.balanceDue),
style: TextButton.styleFrom(
minimumSize: const Size(0, 30),
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
),
TextButton(
onPressed: () => _setCash(controller.balanceDue),
style: TextButton.styleFrom(
minimumSize: const Size(0, 30),
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
),
child: const Text('Exact',
style: TextStyle(fontSize: 12.5),),
),
child: const Text('Exact',
style: TextStyle(fontSize: 12.5),),
),
],
),
),

View File

@@ -302,6 +302,15 @@ class CartController extends StateNotifier<Cart> {
}
Future<void> resume(ParkedBill bill) async {
// Resuming a bill on top of an already-active cart would otherwise
// silently overwrite whatever was already there — picking a second
// parked bill while the first one's items are still sitting in the
// cart, unsaved. Same hole startNewSale closes for the header button;
// this closes it here by parking what's active first.
if (state.isNotEmpty) {
await park(label: 'Auto-parked — replaced by resuming another bill');
}
await _transactions.removeParked(bill.id);
_undoStack.clear();
// Re-evaluated rather than restored: a campaign that has since ended must
@@ -309,6 +318,32 @@ class CartController extends StateNotifier<Cart> {
_commit(bill.cart);
}
/// What the header's "New Sale" button actually calls.
///
/// A cashier can always start over — that's the whole point of the escape
/// hatch — but silently wiping a non-empty cart here would be the exact
/// same hole the removal PIN closes: scan an item, then abandon the cart
/// instead of removing that one line, and it disappears just the same,
/// with nobody having approved anything. So a non-empty cart is parked,
/// not discarded — visible and resumable from Parked bills — and only an
/// already-empty cart takes the plain reset path.
Future<void> startNewSale() async {
if (state.isNotEmpty) {
await park(label: 'Auto-parked — new sale started with items still in cart');
return;
}
reset();
}
/// Puts a cart back exactly as it was just before a sale that has since
/// been voided, so cancelling a completed bill doesn't make the cashier
/// re-scan everything. Promos are re-evaluated for the same reason
/// [resume] re-evaluates them, not restored verbatim.
void restore(Cart cart) {
_undoStack.clear();
_commit(cart);
}
List<CartLine> _replace(CartLine updated) => [
for (final l in state.lines)
if (l.product.id == updated.product.id) updated else l,

View File

@@ -41,8 +41,8 @@ class PosView extends ConsumerWidget {
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const CustomerBar(),
const Divider(height: 1),
Padding(
padding:
EdgeInsets.fromLTRB(pad, AppSpacing.lg, pad, AppSpacing.md),

View File

@@ -0,0 +1,197 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/providers.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/numeric_keypad.dart';
import '../../../domain/entities/store_account.dart';
/// Prompts for an admin PIN before a theft-sensitive cart action — taking a
/// scanned item back out of the bill, or clearing it — and resolves `true`
/// only once a PIN belonging to an [StaffRole.admin] account is verified.
///
/// A cashier can always start a brand new sale; what this exists to stop is
/// quietly taking something back out of a bill a customer has already been
/// shown, after it was rung up.
Future<bool> requireAdminPin(
BuildContext context,
WidgetRef ref, {
required String reason,
}) async {
final ok = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (_) => _AdminPinDialog(reason: reason),
);
return ok ?? false;
}
class _AdminPinDialog extends ConsumerStatefulWidget {
const _AdminPinDialog({required this.reason});
final String reason;
@override
ConsumerState<_AdminPinDialog> createState() => _AdminPinDialogState();
}
class _AdminPinDialogState extends ConsumerState<_AdminPinDialog> {
String _pin = '';
String? _error;
bool _checking = false;
void _key(String digit) {
if (_checking || _pin.length >= 8) return;
setState(() {
_pin += digit;
_error = null;
});
}
void _backspace() {
if (_checking || _pin.isEmpty) return;
setState(() => _pin = _pin.substring(0, _pin.length - 1));
}
void _clear() {
if (_checking) return;
setState(() => _pin = '');
}
Future<void> _submit() async {
if (_pin.isEmpty || _checking) return;
setState(() {
_checking = true;
_error = null;
});
final store = ref.read(localStoreProvider);
final user = await store.staff.authenticate(_pin);
if (!mounted) return;
if (user == null) {
setState(() {
_checking = false;
_error = 'Incorrect PIN.';
_pin = '';
});
return;
}
if (user.role != StaffRole.admin) {
setState(() {
_checking = false;
_error = "${user.name}'s PIN is ${user.role.label.toLowerCase()}"
'this needs an admin.';
_pin = '';
});
return;
}
Navigator.of(context).pop(true);
}
@override
Widget build(BuildContext context) {
return Dialog(
shape: const RoundedRectangleBorder(borderRadius: AppRadius.brLg),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.xl),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Icon(Icons.lock_outline_rounded,
color: AppColors.danger, size: 20,),
const SizedBox(width: AppSpacing.sm),
const Expanded(
child: Text(
'Admin PIN required',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
),
InkWell(
onTap: () => Navigator.of(context).pop(false),
borderRadius: AppRadius.brSm,
child: const Padding(
padding: EdgeInsets.all(4),
child: Icon(Icons.close_rounded,
size: 20, color: AppColors.textSecondary,),
),
),
],
),
const SizedBox(height: AppSpacing.xs),
Text(
widget.reason,
style: const TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
height: 1.4,
),
),
const SizedBox(height: AppSpacing.lg),
SizedBox(
height: 20,
child: _pin.isEmpty
? const Center(
child: Text(
'Enter PIN',
style: TextStyle(
fontSize: 13,
color: AppColors.textTertiary,
),
),
)
: Wrap(
alignment: WrapAlignment.center,
spacing: 10,
children: [
for (var i = 0; i < _pin.length; i++)
Container(
width: 12,
height: 12,
decoration: const BoxDecoration(
color: AppColors.primary,
shape: BoxShape.circle,
),
),
],
),
),
if (_error != null) ...[
const SizedBox(height: AppSpacing.sm),
Text(
_error!,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 12.5,
color: AppColors.danger,
fontWeight: FontWeight.w600,
),
),
],
const SizedBox(height: AppSpacing.lg),
NumericKeypad(
onKey: _key,
onBackspace: _backspace,
onClear: _clear,
onSubmit: _checking ? null : _submit,
submitLabel: _checking ? 'Checking…' : 'Approve',
),
],
),
),
),
);
}
}

View File

@@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart';
import '../../../core/router/app_router.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_layout.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/utils/extensions.dart';
import '../../../core/utils/formatters.dart';
@@ -15,6 +16,7 @@ import '../../../core/widgets/primary_button.dart';
import '../../../domain/entities/cart.dart';
import '../../customer/widgets/customer_capture_sheet.dart';
import '../providers/cart_controller.dart';
import 'admin_pin_dialog.dart';
import 'cart_line_tile.dart';
import 'discount_sheet.dart';
@@ -58,7 +60,12 @@ class BillingPanel extends ConsumerWidget {
line: line,
onIncrement: () => controller.increment(line.product.id),
onDecrement: () => controller.decrement(line.product.id),
onRemove: () => controller.removeLine(line.product.id),
onRemove: () => _removeLine(
context,
ref,
controller,
line.product.id,
),
onDiscount: () =>
showLineDiscountSheet(context, ref, line),
);
@@ -72,6 +79,37 @@ class BillingPanel extends ConsumerWidget {
}
}
/// Once an item is on the bill, taking it back off needs an admin's
/// approval — a cashier can always start an entirely new sale instead. This
/// is the one gate both removal paths (a single line, or the whole cart) go
/// through, so they can never drift out of sync with each other.
Future<void> _removeLine(
BuildContext context,
WidgetRef ref,
CartController controller,
String productId,
) async {
final ok = await requireAdminPin(
context,
ref,
reason: 'Removing a scanned item from the bill needs admin approval.',
);
if (ok) controller.removeLine(productId);
}
Future<void> _clearCart(
BuildContext context,
WidgetRef ref,
CartController controller,
) async {
final ok = await requireAdminPin(
context,
ref,
reason: 'Clearing the whole bill needs admin approval.',
);
if (ok) controller.clear();
}
class _Header extends ConsumerWidget {
const _Header({required this.cart, required this.inSheet});
@@ -81,15 +119,15 @@ class _Header extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = ref.read(cartControllerProvider.notifier);
// Same fixed height as the page header on the left, so the two bars
// line up on one visual line instead of the cart title floating lower.
final contentPadding = PosLayout.of(context).contentPadding;
return Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.lg,
AppSpacing.md,
AppSpacing.sm,
AppSpacing.md,
),
return Container(
constraints: const BoxConstraints(minHeight: AppSizes.headerHeight),
padding: EdgeInsets.symmetric(horizontal: contentPadding),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
@@ -99,9 +137,9 @@ class _Header extends ConsumerWidget {
),
),
if (cart.isNotEmpty) ...[
const SizedBox(width: AppSpacing.sm),
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: const BoxDecoration(
color: AppColors.primarySurface,
borderRadius: AppRadius.brPill,
@@ -116,40 +154,52 @@ class _Header extends ConsumerWidget {
),
),
],
const Spacer(),
SizedBox(),
SizedBox(),
Spacer(),
// Icon-only actions: labelled buttons overflowed the 380px panel.
if (controller.canUndo)
_IconAction(
icon: Icons.undo_rounded,
tooltip: 'Undo (F8)',
color: AppColors.textSecondary,
onTap: controller.undo,
),
if (cart.isNotEmpty) ...[
_IconAction(
icon: Icons.pause_circle_outline_rounded,
tooltip: 'Park bill',
color: AppColors.warning,
onTap: () async {
await controller.park();
ref.invalidate(parkedBillsProvider);
if (context.mounted) context.showSnack('Bill parked');
},
),
_IconAction(
icon: Icons.delete_outline_rounded,
tooltip: 'Clear bill',
color: AppColors.danger,
onTap: controller.clear,
),
],
if (inSheet)
_IconAction(
icon: Icons.close_rounded,
tooltip: 'Close',
color: AppColors.textSecondary,
onTap: () => Navigator.of(context).pop(),
),
// Grouped tight with even spacing, flush against the same right
// edge the header buttons on the left use.
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (controller.canUndo)
_IconAction(
icon: Icons.undo_rounded,
tooltip: 'Undo (F8)',
color: AppColors.textSecondary,
onTap: controller.undo,
),
if (cart.isNotEmpty) ...[
_IconAction(
icon: Icons.pause_circle_outline_rounded,
tooltip: 'Park bill',
color: AppColors.warning,
onTap: () async {
await controller.park();
ref.invalidate(parkedBillsProvider);
if (context.mounted) context.showSnack('Bill parked');
},
),
_IconAction(
icon: Icons.delete_outline_rounded,
tooltip: 'Clear bill',
color: AppColors.danger,
onTap: () => _clearCart(context, ref, controller),
),
],
if (inSheet)
_IconAction(
icon: Icons.close_rounded,
tooltip: 'Close',
color: AppColors.textSecondary,
onTap: () => Navigator.of(context).pop(),
),
],
),
],
),
);
@@ -360,7 +410,9 @@ class _Row extends StatelessWidget {
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs + 2),
child: Row(children: [
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
label,
@@ -390,7 +442,7 @@ class _Row extends StatelessWidget {
),
),
if (trailingIcon != null) ...[
const SizedBox(width: AppSpacing.xs),
Icon(trailingIcon, size: 14, color: AppColors.textTertiary),
],
],),

View File

@@ -1,110 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/extensions.dart';
import '../../../core/utils/formatters.dart';
import '../../../core/widgets/status_pill.dart';
import '../../customer/widgets/customer_capture_sheet.dart';
import '../providers/cart_controller.dart';
/// Strip above the product grid showing who the sale belongs to.
class CustomerBar extends ConsumerWidget {
const CustomerBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final customer = ref.watch(
cartControllerProvider.select((cart) => cart.customer),
);
return Container(
// A hard height clipped the subtitle once it wrapped. Minimum height
// keeps the strip its usual size but lets it grow if it must.
constraints: const BoxConstraints(
minHeight: AppSizes.customerBarHeight,
),
color: AppColors.surface,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xl,
vertical: AppSpacing.sm,
),
child: Row(children: [
CircleAvatar(
radius: 20,
backgroundColor: customer == null
? AppColors.border
: AppColors.primarySurface,
child: customer == null
? const Icon(Icons.directions_walk_rounded,
size: 20, color: AppColors.textSecondary,)
: Text(
Formatters.initials(customer.name),
style: const TextStyle(
color: AppColors.primary,
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: AppSpacing.md),
Flexible(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(mainAxisSize: MainAxisSize.min, children: [
Flexible(
child: Text(
customer?.name ?? 'Walk-in Customer',
style: context.text.titleMedium,
overflow: TextOverflow.ellipsis,
),
),
if (customer != null) ...[
const SizedBox(width: AppSpacing.sm),
StatusPill.tier(customer.tier, dense: true),
],
],),
if (customer != null)
Text(
'${Formatters.mobile(customer.mobile)} · '
'${customer.loyaltyPoints} pts',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.text.bodySmall,
)
else
Text(
'No loyalty tracking for this sale',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.text.bodySmall,
),
],
),
),
const Spacer(),
if (customer != null)
TextButton.icon(
onPressed: () =>
ref.read(cartControllerProvider.notifier).attachCustomer(null),
icon: const Icon(Icons.person_off_outlined, size: 17),
label: const Text('Detach'),
style: TextButton.styleFrom(
foregroundColor: AppColors.textSecondary,),
),
const SizedBox(width: AppSpacing.sm),
OutlinedButton.icon(
onPressed: () => showCustomerCaptureSheet(context),
icon: const Icon(Icons.sync_alt_rounded, size: 17),
label: Text(customer == null ? 'Add Customer' : 'Change'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 44),
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
),
),
],),
);
}
}

View File

@@ -68,28 +68,7 @@ class PageHeader extends ConsumerWidget {
const SizedBox(width: AppSpacing.xs),
],
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
module.title,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 19,
fontWeight: FontWeight.w600,
letterSpacing: -0.4,
color: AppColors.textPrimary,
height: 1.2,
),
),
if (!compact) _Breadcrumb(module: module),
],
),
),
const Spacer(),
if (showStatus) ...[
_LivePill(offline: ref.watch(simulateOfflineProvider)),
@@ -117,42 +96,6 @@ class PageHeader extends ConsumerWidget {
}
}
class _Breadcrumb extends StatelessWidget {
const _Breadcrumb({required this.module});
final PosModule module;
@override
Widget build(BuildContext context) {
const style = TextStyle(fontSize: 12, color: AppColors.textTertiary);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Home', style: style),
const Padding(
padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs + 2),
child: Icon(Icons.chevron_right_rounded,
size: 13, color: AppColors.textTertiary,),
),
Text(module.section.label, style: style),
const Padding(
padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs + 2),
child: Icon(Icons.chevron_right_rounded,
size: 13, color: AppColors.textTertiary,),
),
Text(
module.label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
],
);
}
}
/// What the pill is saying, in the order it takes precedence.
enum _Liveness { offlineSim, halted, syncing, queued, live }
@@ -342,11 +285,24 @@ class _ParkedBillsButton extends ConsumerWidget {
'${Formatters.time(bill.parkedAt)}',
),
onTap: () async {
final hadItems =
ref.read(cartControllerProvider).isNotEmpty;
await ref
.read(cartControllerProvider.notifier)
.resume(bill);
ref.invalidate(parkedBillsProvider);
if (context.mounted) Navigator.of(context).pop();
if (!context.mounted) return;
Navigator.of(context).pop();
if (hadItems) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(const SnackBar(
content: Text(
'The cart you were on was saved back to Parked '
'bills.',
),
),);
}
},
);
},
@@ -370,12 +326,22 @@ class _NewSaleButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
void start() {
ref.read(cartControllerProvider.notifier).reset();
Future<void> start() async {
final hadItems = ref.read(cartControllerProvider).isNotEmpty;
await ref.read(cartControllerProvider.notifier).startNewSale();
if (hadItems) ref.invalidate(parkedBillsProvider);
ref.read(activeModuleProvider.notifier).state = PosModule.pos;
if (!context.mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(const SnackBar(content: Text('Started a new sale.')));
..showSnackBar(SnackBar(
content: Text(
hadItems
? 'Previous cart saved to Parked bills. Started a new sale.'
: 'Started a new sale.',
),
),);
}
if (compact) {

View File

@@ -84,9 +84,10 @@ class _ProductCardState extends State<ProductCard> {
children: [
Opacity(
opacity: disabled ? 0.4 : 1,
child: Text(
p.emoji,
style: TextStyle(fontSize: emoji),
child: _ProductVisual(
imageUrl: p.imageUrl,
emoji: p.emoji,
size: emoji,
),
),
SizedBox(height: tight ? 2 : AppSpacing.sm),
@@ -252,3 +253,62 @@ class _ProductCardState extends State<ProductCard> {
);
}
}
/// Shows the catalogue's product photo when the imported record has one,
/// otherwise falls back to the emoji.
///
/// Today's imports don't carry `image_url` yet, so the emoji path is still
/// the common case — this quietly takes over per product once the back
/// office starts sending photos, with nothing else on the card changing.
class _ProductVisual extends StatelessWidget {
const _ProductVisual({
required this.imageUrl,
required this.emoji,
required this.size,
});
final String? imageUrl;
final String emoji;
/// Matches the emoji font size the caller computed for this tile, so the
/// two are visually interchangeable.
final double size;
@override
Widget build(BuildContext context) {
final url = imageUrl;
if (url == null || url.isEmpty) {
return Text(emoji, style: TextStyle(fontSize: size));
}
final box = size * 1.7;
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
url,
width: box,
height: box,
fit: BoxFit.cover,
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return SizedBox(
width: box,
height: box,
child: const Center(
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
},
// A missing or unreachable photo falls back to the emoji rather than
// Flutter's default broken-image icon, so one bad URL in a catalogue
// of thousands never leaves a tile looking broken.
errorBuilder: (context, error, stackTrace) =>
Text(emoji, style: TextStyle(fontSize: size)),
),
);
}
}

View File

@@ -15,13 +15,22 @@ import '../../../core/utils/extensions.dart';
import '../../../core/utils/formatters.dart';
import '../../../core/widgets/glass_card.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../data/sync/sync_engine.dart';
import '../../../domain/entities/transaction.dart';
import '../../modules/providers/printer_settings.dart';
import '../../pos/providers/cart_controller.dart';
import '../../pos/providers/catalog_providers.dart';
import '../../sync/providers/sync_controller.dart';
import '../widgets/receipt_preview.dart';
/// Confirmation screen. Counts down and starts the next sale on its own so an
/// unattended terminal never sits on a finished bill.
/// Confirmation screen.
///
/// The bill is written to SQLite the instant checkout completes, but is
/// deliberately held back from the server for [AppConstants.postSaleResetDelay]
/// — long enough to catch a mistake. Counts down and starts the next sale (and
/// releases the bill to the sync engine) on its own so an unattended terminal
/// never sits on a finished bill forever; "Cancel sale" inside the window
/// voids it instead and hands the cart straight back.
class ReceiptScreen extends ConsumerStatefulWidget {
const ReceiptScreen({super.key, required this.transaction});
@@ -32,9 +41,13 @@ class ReceiptScreen extends ConsumerStatefulWidget {
}
class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
late int _seconds = AppConstants.postSaleResetDelay.inSeconds + 5;
late int _seconds = AppConstants.postSaleResetDelay.inSeconds;
Timer? _timer;
/// True while a cancellation is being written to disk — guards against a
/// second tap voiding a sale that is already half-reversed.
bool _voiding = false;
@override
void initState() {
super.initState();
@@ -82,13 +95,36 @@ class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
void _newSale() {
_timer?.cancel();
// This is the one moment the bill is actually released to the server —
// whether the window ran out on its own or New Sale was pressed early,
// the cashier has let this bill stand.
ref.read(syncEngineProvider).nudge(SyncTrigger.saleCommitted);
ref.read(cartControllerProvider.notifier).reset();
if (mounted) context.go(AppRoutes.pos);
}
void _continueBilling() {
/// Undoes the sale: deletes the order, puts the stock back, restores an
/// attached shopper's loyalty balance, and hands the exact same cart back
/// to the billing screen. Never touches the sync engine — this bill must
/// never reach the server.
Future<void> _cancelSale() async {
if (_voiding) return;
setState(() => _voiding = true);
_timer?.cancel();
ref.read(cartControllerProvider.notifier).reset();
final txn = widget.transaction;
await ref.read(transactionRepositoryProvider).voidSale(
transaction: txn,
stockMovements: {
for (final line in txn.cart.lines) line.product.id: line.quantity,
},
);
ref.invalidate(allProductsProvider);
ref.invalidate(visibleProductsProvider);
ref.read(orderVersionProvider.notifier).state++;
ref.read(cartControllerProvider.notifier).restore(txn.cart);
if (mounted) context.go(AppRoutes.pos);
}
@@ -293,12 +329,23 @@ class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
: 'New Sale',
icon: Icons.add_shopping_cart_rounded,
large: true,
onPressed: _newSale,
onPressed: _voiding ? null : _newSale,
),
const SizedBox(height: AppSpacing.sm),
TextButton(
onPressed: _continueBilling,
child: const Text('Back to billing screen'),
TextButton.icon(
onPressed: _voiding ? null : _cancelSale,
icon: _voiding
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.danger,
),
)
: const Icon(Icons.undo_rounded, size: 16),
label: Text(_voiding ? 'Cancelling…' : 'Cancel sale'),
style: TextButton.styleFrom(foregroundColor: AppColors.danger),
),
],
),

View File

@@ -3,7 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/providers.dart';
import '../../../core/constants/app_constants.dart';
import '../../../data/remote/mqtt_order_transport.dart';
import '../../../data/sync/health_reporter.dart';
import '../../../data/sync/presence_reporter.dart';
import '../../modules/providers/printer_settings.dart';
import '../../../domain/entities/shift_report.dart';
import '../../../domain/entities/sync_event.dart';
import '../../../domain/repositories/sync_repository.dart';
@@ -237,7 +239,9 @@ final syncBootstrapProvider = FutureProvider<void>((ref) async {
await engine.start();
// Fleet presence only exists on a transport that can carry it.
// The retained presence record needs a Last Will to pair with, so it genuinely
// only exists on the broker. The heartbeat below does not, and is started for
// every route.
final transport = ref.read(orderTransportProvider);
if (transport is MqttOrderTransport) {
final reporter = PresenceReporter(
@@ -252,4 +256,35 @@ final syncBootstrapProvider = FutureProvider<void>((ref) async {
ref.onDispose(reporter.dispose);
await reporter.start();
}
// The 30-second heartbeat the head-office board reads. Separate from the
// retained presence record above: that one is paired with the Last Will and
// answers "is this till alive", while this carries queue depth, today's
// trading and hardware state — what tells a till that is merely quiet from
// one that has stopped uploading.
//
// Outside the MQTT check on purpose. It used to be inside, which meant a shop
// on the HTTP route uploaded every bill correctly and never appeared on the
// board at all — with nothing logged, because nothing had failed. Both routes
// can carry a heartbeat now, and the transport decides how.
{
final health = HealthReporter(
transport: transport,
terminal: ref.read(terminalIdentityProvider),
config: ref.read(syncConfigProvider),
engine: engine,
repository: ref.read(syncRepositoryProvider),
appVersion: AppConstants.appVersion,
// Read on each beat, so re-pointing the printer in Settings takes effect
// without a restart.
printerEndpoint: () {
final printer = ref.read(printerSettingsProvider);
final host = printer.drawerHost;
if (host == null || host.isEmpty) return null;
return (host: host, port: printer.drawerPort);
},
);
ref.onDispose(health.dispose);
await health.start();
}
});

View File

@@ -9,6 +9,7 @@ import '../../../core/utils/formatters.dart';
import '../../../core/widgets/primary_button.dart';
import '../../auth/providers/auth_controller.dart';
import '../../pos/providers/cart_controller.dart';
import '../../pos/providers/catalog_providers.dart';
import '../../../domain/repositories/sync_repository.dart';
import '../providers/sync_controller.dart';
@@ -35,9 +36,21 @@ class _SignOutDialog extends ConsumerStatefulWidget {
class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
SyncOutcome? _result;
void _finish() {
Future<void> _finish() async {
ref.read(cartControllerProvider.notifier).reset();
ref.read(authControllerProvider.notifier).signOut();
await ref.read(authControllerProvider.notifier).signOut();
// Mirrors what a successful import does on the way in: bump the version
// so catalogueReadyProvider re-reads hasCatalogue as false, and drop the
// cached product lists so the next session's grid doesn't flash this
// session's now-cleared data before it re-fetches.
ref.read(catalogueVersionProvider.notifier).state++;
ref.invalidate(allProductsProvider);
ref.invalidate(visibleProductsProvider);
ref.invalidate(categoryCountsProvider);
ref.invalidate(lowStockProductsProvider);
if (!mounted) return;
Navigator.of(context).pop();
context.go(AppRoutes.login);
}

View File

@@ -2,11 +2,15 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.print</key>
<true/>
</dict>
</plist>
</plist>

View File

@@ -4,5 +4,7 @@
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.print</key>
<true/>
</dict>
</plist>
</plist>

View File

@@ -282,10 +282,10 @@ packages:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: "06686df417f34fe9f963ed217b7fdce250e2f637ddd6c374d7eb054549770633"
sha256: "788060052712555182aba55ecb5f8b6e5cb9cfe8f776c83249a61fe3ce877db4"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
version: "2.0.3"
flutter_secure_storage_web:
dependency: transitive
description:
@@ -729,10 +729,10 @@ packages:
dependency: transitive
description:
name: sqlite3
sha256: c73fd75df1332d76a6257f4823ae4df9c791f522b97e4a60cbcad214de1becf4
sha256: "64b2c63c8232dd20d14b34105a81ebfd74320442e8451f836179ec89986aa478"
url: "https://pub.dev"
source: hosted
version: "3.5.0"
version: "3.5.1"
stack_trace:
dependency: transitive
description:
@@ -897,10 +897,10 @@ packages:
dependency: transitive
description:
name: win32
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d
url: "https://pub.dev"
source: hosted
version: "6.3.0"
version: "6.4.0"
xdg_directories:
dependency: transitive
description:

View File

@@ -54,3 +54,4 @@ flutter:
uses-material-design: true
assets:
- assets/sounds/
- assets/images/

View File

@@ -0,0 +1,325 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:nearle_pos/data/datasources/local_store.dart';
import 'package:nearle_pos/data/datasources/seed_data.dart';
import 'package:nearle_pos/data/local/app_database.dart';
import 'package:nearle_pos/data/remote/order_transport.dart';
import 'package:nearle_pos/data/remote/simulated_catalogue_source.dart';
import 'package:nearle_pos/data/repositories/customer_repository_impl.dart';
import 'package:nearle_pos/data/repositories/product_repository_impl.dart';
import 'package:nearle_pos/data/repositories/sync_repository_impl.dart';
import 'package:nearle_pos/data/repositories/transaction_repository_impl.dart';
import 'package:nearle_pos/domain/entities/cart.dart';
import 'package:nearle_pos/domain/entities/customer.dart';
import 'package:nearle_pos/domain/entities/transaction.dart';
import 'package:nearle_pos/domain/usecases/checkout_sale.dart';
/// A shopper who signs up at the till has to reach the back office in their
/// own right. Before the customer outbox they only ever travelled as three
/// fields riding along on a bill — so somebody who registered and then bought
/// nothing, or whose bill was still queued, existed on one terminal and
/// nowhere else.
void main() {
late LocalStore store;
late CustomerRepositoryImpl customers;
late CheckoutSale checkout;
setUpAll(() {
LocalStore.registerSeed(
products: SeedData.products,
customers: SeedData.customers,
);
});
setUp(() async {
store = LocalStore.instance;
await store.reset(withCatalogue: true);
customers = CustomerRepositoryImpl(store);
checkout = CheckoutSale(
productRepository: ProductRepositoryImpl(store),
customerRepository: customers,
transactionRepository: TransactionRepositoryImpl(store),
);
});
tearDownAll(() => AppDatabase.instance.close());
SyncRepositoryImpl syncWith(OrderTransport transport) => SyncRepositoryImpl(
store,
SimulatedCatalogueSource(isOffline: () => false),
transport,
);
Future<Customer> register(String mobile, {String name = 'Meena'}) =>
customers.create(Customer(id: '', name: name, mobile: mobile));
group('identity', () {
test('the same mobile produces the same id on any terminal', () {
// The whole point. A hundred tills mint ids without talking to each
// other, so the id has to be a function of the shopper, not of chance.
expect(
Customer.idForMobile('9840012345'),
Customer.idForMobile('9840012345'),
);
});
test('formatting does not create a second shopper', () {
final plain = Customer.idForMobile('9840012345');
expect(Customer.idForMobile('+91 98400 12345'), plain);
expect(Customer.idForMobile('98400-12345'), plain);
});
test('the country code and the trunk prefix are stripped', () {
expect(Customer.normaliseMobile('+91 98400 12345'), '9840012345');
expect(Customer.normaliseMobile('098400 12345'), '9840012345');
expect(Customer.normaliseMobile('9840012345'), '9840012345');
});
test('a number the rule was not written for is left alone', () {
// Mangling something unrecognised is worse than storing it verbatim: a
// wrongly-trimmed number silently merges two different shoppers.
expect(Customer.normaliseMobile('4155550123'), '4155550123');
expect(Customer.normaliseMobile('12345'), '12345');
// Twelve digits that do not start with the Indian country code.
expect(Customer.normaliseMobile('442071234567'), '442071234567');
});
test('different shoppers get different ids', () {
expect(
Customer.idForMobile('9840012345'),
isNot(Customer.idForMobile('9840012346')),
);
});
test('a registration is keyed on the number, not on chance', () async {
final created = await register('+91 98400 12345');
expect(created.id, Customer.idForMobile('9840012345'));
expect(created.mobile, '9840012345');
});
});
group('the outbox', () {
test('a shopper registered at the till is queued', () async {
final before = await store.catalogue.unsyncedCustomerCount();
await register('9840012345');
expect(await store.catalogue.unsyncedCustomerCount(), before + 1);
});
test('a shopper who buys nothing still goes up', () async {
// The case that used to be lost entirely: no bill, so nothing to ride.
final created = await register('9840012345');
final transport = _RecordingTransport();
final outcome = await syncWith(transport).syncCustomers();
expect(outcome.uploaded, greaterThanOrEqualTo(1));
expect(transport.sentIds, contains(created.id));
});
test('shoppers that came from the back office are not posted back',
() async {
// The seed catalogue arrives as an import. Sending it straight back
// would be a round trip telling the server what it just told us.
final imported = store.customers.map((c) => c.id).toSet();
expect(imported, isNotEmpty, reason: 'the fixture needs seeded shoppers');
final pending = await store.catalogue.unsyncedCustomers(limit: 500);
expect(
pending.map((c) => c.id).toSet().intersection(imported),
isEmpty,
);
});
test('only the ids the back office names are marked sent', () async {
final kept = await register('9840012345', name: 'Meena');
final dropped = await register('9840099999', name: 'Ravi');
// Silence about a row is not acceptance of it.
final transport = _RecordingTransport(
accept: (ids) => ids.where((id) => id == kept.id).toList(),
);
await syncWith(transport).syncCustomers();
final stillPending =
(await store.catalogue.unsyncedCustomers(limit: 500))
.map((c) => c.id)
.toSet();
expect(stillPending, contains(dropped.id));
expect(stillPending, isNot(contains(kept.id)));
});
test('a batch nobody accepts stops rather than looping for ever',
() async {
await register('9840012345');
final transport = _RecordingTransport(accept: (_) => const []);
final outcome = await syncWith(transport).syncCustomers();
expect(outcome.isSuccess, isFalse);
expect(outcome.isRetryable, isFalse);
expect(transport.calls, 1, reason: 'the same page must not be re-read');
});
test('an unreachable back office leaves everyone pending', () async {
final created = await register('9840012345');
final outcome = await syncWith(_FailingTransport()).syncCustomers();
expect(outcome.isSuccess, isFalse);
expect(outcome.uploaded, 0);
expect(
(await store.catalogue.unsyncedCustomers(limit: 500))
.map((c) => c.id),
contains(created.id),
);
});
});
group('a sale does not disturb the outbox', () {
/// Rings a bill for [customer] so loyalty movement is written.
Future<void> ringSaleFor(Customer customer) async {
final product = store.products.first;
final cart = Cart(
lines: [CartLine(product: product, quantity: 1)],
customer: customer,
);
await checkout(
cart: cart,
payments: [
PaymentSplit(method: PaymentMethod.cash, amount: cart.grandTotal),
],
cashierName: 'Suriya',
terminalId: 'T4A9',
);
}
test('a sale does not re-queue a shopper already sent', () async {
final created = await register('9840012345');
await syncWith(_RecordingTransport()).syncCustomers();
expect(await store.catalogue.unsyncedCustomerCount(), 0);
await ringSaleFor(created);
// A sale writes the shopper's new points and spend. Done as an upsert
// that replaces the row, every column absent from it — sync_status
// included — would silently revert to its schema default.
expect(
await store.catalogue.unsyncedCustomerCount(),
0,
reason: 'loyalty movement is not a registration change',
);
});
test('a sale still moves the loyalty figures', () async {
// Guards the fix above from being "achieved" by not writing at all.
final created = await register('9840012345');
await ringSaleFor(created);
final after = await store.catalogue.customerById(created.id);
expect(after!.visitCount, 1);
expect(after.lifetimeSpend, greaterThan(0));
expect(after.lastVisitAt, isNotNull);
});
test('a sale does not overwrite a profile', () async {
final created = await register('9840012345', name: 'Meena');
await ringSaleFor(created);
final after = await store.catalogue.customerById(created.id);
expect(after!.name, 'Meena');
expect(after.mobile, '9840012345');
});
});
}
/// Accepts what it is told to and remembers what it saw.
class _RecordingTransport implements OrderTransport {
/// Heartbeats are irrelevant to what these tests assert; recorded only so the
/// fake satisfies the interface.
@override
Future<void> publishHealth(String payload) async {
healthBeats.add(payload);
}
final List<String> healthBeats = [];
_RecordingTransport({List<String> Function(List<String> ids)? accept})
: accept = accept ?? ((ids) => ids);
final List<String> Function(List<String> ids) accept;
final sentIds = <String>[];
int calls = 0;
@override
String get label => 'Recording';
@override
bool get isConnected => true;
@override
Stream<DownlinkMessage> get downlink => const Stream.empty();
@override
Stream<bool> get connectionState => const Stream.empty();
@override
Future<void> connect() async {}
@override
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) async =>
PushReceipt(accepted: orders.map((o) => o['id']! as String).toList());
@override
Future<PushReceipt> pushCustomers(
List<Map<String, Object?>> customers,
) async {
calls++;
final ids = customers.map((c) => c['id']! as String).toList();
sentIds.addAll(ids);
return PushReceipt(accepted: accept(ids));
}
@override
Future<void> dispose() async {}
}
class _FailingTransport implements OrderTransport {
/// Heartbeats are irrelevant to what these tests assert; recorded only so the
/// fake satisfies the interface.
@override
Future<void> publishHealth(String payload) async {
healthBeats.add(payload);
}
final List<String> healthBeats = [];
@override
String get label => 'Failing';
@override
bool get isConnected => false;
@override
Stream<DownlinkMessage> get downlink => const Stream.empty();
@override
Stream<bool> get connectionState => const Stream.empty();
@override
Future<void> connect() async {}
@override
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) async =>
throw const TransportException('unreachable');
@override
Future<PushReceipt> pushCustomers(
List<Map<String, Object?>> customers,
) async =>
throw const TransportException('unreachable');
@override
Future<void> dispose() async {}
}

View File

@@ -143,11 +143,15 @@ void main() {
const config = SyncConfig(storeId: 'store-01', terminalId: 'TA1B2');
expect(
SyncConfig.asNatsSubject(config.orderTopic),
'pos.store-01.TA1B2.order',
'nearle.pos.store-01.TA1B2.order',
);
expect(
SyncConfig.asNatsSubject(config.statusTopic),
'pos.store-01.TA1B2.status',
'nearle.pos.store-01.TA1B2.status',
);
expect(
SyncConfig.asNatsSubject(config.healthTopic),
'nearle.pos.store-01.TA1B2.health',
);
});
});

View File

@@ -0,0 +1,194 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:nearle_pos/core/config/sync_config.dart';
import 'package:nearle_pos/data/local/terminal_identity.dart';
import 'package:nearle_pos/data/remote/order_transport.dart';
import 'package:nearle_pos/data/sync/health_reporter.dart';
import 'package:nearle_pos/data/sync/sync_engine.dart';
import 'package:nearle_pos/domain/entities/shift_report.dart';
import 'package:nearle_pos/domain/repositories/sync_repository.dart';
/// The failure these cover reached production and stayed invisible for a day.
///
/// The reporter was typed against the broker transport and started behind an
/// `is MqttOrderTransport` check, so a shop configured for the HTTP route
/// uploaded 17 bills perfectly and never once appeared on the fleet board.
/// Nothing logged it, because from the terminal's point of view nothing had
/// failed — the feature simply did not exist on that route.
///
/// There was no test here at all. That is why it shipped.
void main() {
group('a heartbeat is sent on any route that can carry one', () {
test('publishes over a transport that is not the broker', () async {
final transport = _RecordingTransport();
final reporter = _reporter(transport);
await reporter.publish();
expect(
transport.beats,
hasLength(1),
reason: 'an HTTP terminal must still report its health',
);
final beat = jsonDecode(transport.beats.single) as Map<String, Object?>;
expect(beat['terminal_id'], 'T5EDD');
expect(beat['location_id'], '1135');
expect(beat['status'], 'online');
reporter.dispose();
});
test('carries the queue depth that makes a silent failure visible',
() async {
// The number the board exists for. A till that is connected, selling and
// quietly accumulating unsent bills looks completely healthy from the
// shop floor.
final transport = _RecordingTransport();
final reporter = _reporter(transport, pending: 213);
await reporter.publish();
final beat = jsonDecode(transport.beats.single) as Map<String, Object?>;
expect(beat['pending_bills'], 213);
expect(beat['today_bills'], 17);
expect(beat['today_amount'], 2510.0);
reporter.dispose();
});
test('names the route it is using', () async {
// So a support call can tell an HTTP shop from a broker one without
// asking anybody to read a settings screen aloud.
final transport = _RecordingTransport();
final reporter = _reporter(transport, transportKind: TransportKind.http);
await reporter.publish();
final beat = jsonDecode(transport.beats.single) as Map<String, Object?>;
expect(beat['transport'], 'http');
reporter.dispose();
});
test('says nothing while the route is down', () async {
// Not an error — there is nowhere to send it. The board ages the till off
// by itself when the beats stop.
final transport = _RecordingTransport(connected: false);
final reporter = _reporter(transport);
await reporter.publish();
expect(transport.beats, isEmpty);
reporter.dispose();
});
test('a transport that throws does not stop the till', () async {
// The whole reason every failure here is swallowed. Halting a shop
// because a dashboard was unreachable would be a self-inflicted outage.
final transport = _ThrowingTransport();
final reporter = _reporter(transport);
await expectLater(reporter.publish(), completes);
reporter.dispose();
});
});
}
HealthReporter _reporter(
OrderTransport transport, {
int pending = 0,
TransportKind transportKind = TransportKind.http,
}) =>
HealthReporter(
transport: transport,
terminal: const TerminalIdentity(
deviceId: 'device-1',
code: 'T5EDD',
name: 'Counter 1',
storeId: '1135',
),
config: SyncConfig(
transport: transportKind,
storeId: '1135',
terminalId: 'T5EDD',
httpBaseUrl: 'https://example.invalid/api/v1/pos',
),
engine: _StubEngine(pending: pending),
repository: _StubRepository(),
appVersion: '1.1.0',
);
/// A [SyncEngine] whose state the test dictates.
class _StubEngine implements SyncEngine {
_StubEngine({required this.pending});
final int pending;
@override
SyncEngineState get state => SyncEngineState(pending: pending);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _StubRepository implements SyncRepository {
@override
Future<int> unsyncedCustomerCount() async => 0;
@override
Future<List<OrderSyncRow>> orderSyncRows({int limit = 200}) async => const [];
@override
Future<ShiftReport> todayReport({
required String terminalId,
required String cashierName,
bool scopeToCashier = false,
}) async =>
ShiftReport(
businessDate: DateTime(2026, 8, 5),
terminalId: terminalId,
cashierName: cashierName,
billCount: 17,
itemCount: 21,
grossSales: 2510,
taxCollected: 265.06,
discountGiven: 0,
roundOff: 0,
paymentBreakdown: const {},
loyaltyPointsIssued: 0,
loyaltyPointsRedeemed: 0,
);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _RecordingTransport implements OrderTransport {
_RecordingTransport({this.connected = true});
final bool connected;
final List<String> beats = [];
@override
bool get isConnected => connected;
@override
Future<void> publishHealth(String payload) async => beats.add(payload);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _ThrowingTransport implements OrderTransport {
@override
bool get isConnected => true;
@override
Future<void> publishHealth(String payload) async =>
throw Exception('the back office is down');
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}

View File

@@ -130,6 +130,19 @@ void main() {
'synced_bills': 12,
});
await db.insert('app_meta', {'key': 'invoice_sequence', 'value': '12'});
// A shopper registered before the customer outbox existed. Whether they
// reach the back office at all depends on what v8 does with this row.
await db.insert('customers', {
'id': 'legacy-customer-1',
'name': 'Meena',
'mobile': '9840011111',
'loyalty_points': 40,
'lifetime_spend': 2400.0,
'visit_count': 3,
'created_at': 1000,
});
await db.close();
}
@@ -139,7 +152,7 @@ void main() {
await AppDatabase.instance.open(overridePath: dbPath);
final db = AppDatabase.instance.db;
expect(await db.getVersion(), 7);
expect(await db.getVersion(), 8);
final rows = await db.query('day_archive');
expect(rows, hasLength(1));
@@ -159,6 +172,19 @@ void main() {
// not handed promotions it never created.
final promos = await db.query('promos');
expect(promos, isEmpty);
// v8 turns customers into an outbox. Every existing shopper is queued
// rather than assumed sent: the terminal cannot tell which rows came down
// in a catalogue pull and which were registered at the till, and only one
// of those two mistakes loses somebody. Re-sending is safe because the
// uplink is insert-if-absent on id.
final customer = (await db.query('customers')).single;
expect(customer['sync_status'], 0);
expect(customer['synced_at'], isNull);
// …and their loyalty standing survives the migration untouched.
expect(customer['loyalty_points'], 40);
expect(customer['lifetime_spend'], 2400.0);
expect(row['bill_count'], 12);
expect(row['gross_sales'], 8450.0);
expect(row['tax_collected'], 620.5);

View File

@@ -16,6 +16,15 @@ import 'package:nearle_pos/domain/usecases/checkout_sale.dart';
/// A transport whose answer each call is dictated by the test.
class _ScriptedTransport implements OrderTransport {
/// Heartbeats are irrelevant to what these tests assert; recorded only so the
/// fake satisfies the interface.
@override
Future<void> publishHealth(String payload) async {
healthBeats.add(payload);
}
final List<String> healthBeats = [];
_ScriptedTransport(this.answer);
/// Given the ids in a batch, returns what the back office says about them.
@@ -44,6 +53,16 @@ class _ScriptedTransport implements OrderTransport {
return answer(orders.map((o) => o['id']! as String).toList());
}
/// Registrations are not what these tests are about; accepting them keeps
/// the drain from stalling before it reaches the bills.
@override
Future<PushReceipt> pushCustomers(
List<Map<String, Object?>> customers,
) async =>
PushReceipt(
accepted: customers.map((c) => c['id']! as String).toList(),
);
@override
Future<void> dispose() async {}
}

View File

@@ -39,6 +39,23 @@ class _StubRepository implements SyncRepository {
@override
Future<int> unsyncedCount() async => pending;
/// Counted so a test can prove registrations go up before bills do.
int customerSyncs = 0;
/// Set to make the registration pass throw, proving it cannot hold up the
/// bills behind it.
bool customerSyncThrows = false;
@override
Future<int> unsyncedCustomerCount() async => 0;
@override
Future<SyncOutcome> syncCustomers() async {
customerSyncs++;
if (customerSyncThrows) throw Exception('registration upload failed');
return const SyncOutcome(attempted: 0, uploaded: 0);
}
@override
Future<int> purgeExpired() async => 0;
@@ -308,6 +325,10 @@ void main() {
final engine = build(connectivity: connectivity.stream);
await engine.start();
// start() fires a drain of its own. Let it finish before taking the
// baseline, so this measures what connectivity did rather than how many
// awaits happen to sit in front of the repository call.
await _settle();
final atStart = repo.calls;
connectivity.add(false);
@@ -385,6 +406,34 @@ void main() {
});
});
group('registrations', () {
test('go up before the bills that might refer to them', () async {
final engine = build();
engine.nudge(SyncTrigger.saleCommitted);
await _settle();
expect(repo.customerSyncs, 1);
expect(repo.calls, 1);
});
test('failing to upload one cannot strand a day of takings', () async {
// A shopper waiting to go up must never be the reason money stays on the
// terminal. The registration pass is allowed to fail on its own.
repo.customerSyncThrows = true;
final engine = build();
engine.nudge(SyncTrigger.saleCommitted);
await _settle();
expect(repo.calls, 1, reason: 'the bills still went');
expect(engine.state.consecutiveFailures, 0);
expect(engine.state.isHalted, isFalse);
await engine.dispose();
});
});
test('a repository that throws is treated as a retryable failure, not a crash',
() async {
// Anything escaping the repository is a defect. The engine must still empty

View File

@@ -0,0 +1,219 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:nearle_pos/domain/entities/cart.dart';
import 'package:nearle_pos/domain/entities/customer.dart';
import 'package:nearle_pos/domain/entities/product.dart';
import 'package:nearle_pos/domain/entities/promo.dart';
/// Where a discount lands decides which GST slab it comes out of.
///
/// The bill total is the same either way, which is what makes getting this
/// wrong so easy to ship: the shopper pays the right money, the receipt looks
/// right, and only the slab split on a filed return is off.
void main() {
/// 5% slab — the everyday grocery rate.
Product atta({double price = 100}) => Product(
id: 'atta',
name: 'Atta 5kg',
barcode: 'bc-atta',
sku: 'sku-atta',
category: ProductCategory.grocery,
price: price,
stock: 100,
gstRate: 0.05,
);
/// 18% slab.
Product cola({double price = 100}) => Product(
id: 'cola',
name: 'Cola 2L',
barcode: 'bc-cola',
sku: 'sku-cola',
category: ProductCategory.beverages,
price: price,
stock: 100,
gstRate: 0.18,
);
Cart cartOf(
List<({Product product, double qty})> items, {
List<AppliedPromo> promos = const [],
Discount billDiscount = Discount.none,
Customer? customer,
int pointsRedeemed = 0,
}) =>
Cart(
lines: [
for (final i in items) CartLine(product: i.product, quantity: i.qty),
],
appliedPromos: promos,
billDiscount: billDiscount,
customer: customer,
pointsRedeemed: pointsRedeemed,
);
/// GST inside [amount] at [rate].
double taxInside(double amount, double rate) => amount - amount / (1 + rate);
/// Slabs are reconciled against the bill total before being returned, so the
/// largest one absorbs up to a paisa of rounding residue. That is deliberate
/// — a tax invoice cannot show parts that miss their own total — so slab
/// assertions allow it, and the exact reconciliation is asserted separately.
Matcher isPaise(double expected) => closeTo(expected, 0.011);
group('a targeted campaign only reduces the lines it names', () {
test('a category promo leaves the other slab untouched', () {
// ₹100 of atta at 5% and ₹100 of cola at 18%. "50% off Beverages" takes
// ₹50, and every rupee of it must come out of the cola line.
final cart = cartOf(
[(product: atta(), qty: 1), (product: cola(), qty: 1)],
promos: const [
AppliedPromo(
promo: Promo(
id: 'bev',
name: 'Half off beverages',
type: PromoType.percentOffCategory,
value: 50,
targetId: 'beverages',
),
amount: 50,
),
],
);
expect(cart.netAmount, 150);
final slabs = cart.taxBreakdown;
// Atta was not discounted, so its slab is exactly what it always was.
expect(slabs[0.05], isPaise(taxInside(100, 0.05)));
// Cola carried the whole ₹50.
expect(slabs[0.18], isPaise(taxInside(50, 0.18)));
// The old pro-rata split would have moved tax off the atta line to
// subsidise a campaign it never qualified for.
expect(slabs[0.05], isNot(isPaise(taxInside(75, 0.05))));
});
test('a product promo behaves the same way', () {
final cart = cartOf(
[(product: atta(), qty: 1), (product: cola(), qty: 1)],
promos: const [
AppliedPromo(
promo: Promo(
id: 'cola-20',
name: '20% off cola',
type: PromoType.percentOffProduct,
value: 20,
targetId: 'cola',
),
amount: 20,
),
],
);
expect(cart.taxBreakdown[0.05], isPaise(taxInside(100, 0.05)));
expect(cart.taxBreakdown[0.18], isPaise(taxInside(80, 0.18)));
});
});
group('a bill-wide reduction still spreads across everything', () {
test('a manual discount is shared pro rata', () {
// Nothing here names a line, so both slabs give up the same proportion.
// This is the behaviour that was already right and must stay right.
final cart = cartOf(
[(product: atta(), qty: 1), (product: cola(), qty: 1)],
billDiscount: const Discount(type: DiscountType.percentage, value: 10),
);
expect(cart.netAmount, 180);
expect(cart.taxBreakdown[0.05], isPaise(taxInside(90, 0.05)));
expect(cart.taxBreakdown[0.18], isPaise(taxInside(90, 0.18)));
});
test('points redeemed come off every line', () {
final cart = cartOf(
[(product: atta(), qty: 1), (product: cola(), qty: 1)],
customer: const Customer(id: 'c1', name: 'A', mobile: '9840000000'),
pointsRedeemed: 0,
);
// Baseline with no reduction at all: each line keeps its own tax.
expect(cart.taxBreakdown[0.05], isPaise(taxInside(100, 0.05)));
expect(cart.taxBreakdown[0.18], isPaise(taxInside(100, 0.18)));
});
});
group('the parts always add up to the whole', () {
test('slabs reconcile to the bill tax with a targeted promo', () {
final cart = cartOf(
[(product: atta(price: 137), qty: 3), (product: cola(price: 89), qty: 2)],
promos: const [
AppliedPromo(
promo: Promo(
id: 'bev',
name: '15% off beverages',
type: PromoType.percentOffCategory,
value: 15,
targetId: 'beverages',
),
amount: 26.7,
),
],
billDiscount: const Discount(type: DiscountType.flat, value: 40),
);
final slabSum = cart.taxBreakdown.values.fold(0.0, (a, b) => a + b);
expect(slabSum.toStringAsFixed(2), cart.taxAmount.toStringAsFixed(2));
// And the tax still sits inside the money actually collected.
expect(
(cart.taxableAmount + cart.taxAmount).toStringAsFixed(2),
cart.netAmount.toStringAsFixed(2),
);
});
test('a campaign that clears a line cannot drive its tax negative', () {
// 100% off beverages, then a bill discount on top. The cola line has
// nothing left to give, so the manual discount has to fall entirely on
// the atta line rather than pushing cola below zero.
final cart = cartOf(
[(product: atta(), qty: 1), (product: cola(), qty: 1)],
promos: const [
AppliedPromo(
promo: Promo(
id: 'free',
name: 'Free beverages',
type: PromoType.percentOffCategory,
value: 100,
targetId: 'beverages',
),
amount: 100,
),
],
billDiscount: const Discount(type: DiscountType.flat, value: 50),
);
expect(cart.netAmount, 50);
for (final slab in cart.taxBreakdown.values) {
expect(slab, greaterThanOrEqualTo(0));
}
expect(cart.taxAmount, greaterThanOrEqualTo(0));
// Everything left on the bill is atta, so all the tax is at 5%.
expect(cart.taxBreakdown[0.05], isPaise(taxInside(50, 0.05)));
expect(cart.taxBreakdown[0.18] ?? 0, 0);
});
test('discounts exceeding the bill leave nothing taxable', () {
final cart = cartOf(
[(product: cola(), qty: 1)],
billDiscount: const Discount(type: DiscountType.flat, value: 500),
);
expect(cart.netAmount, 0);
expect(cart.taxAmount, 0);
expect(cart.taxableAmount, 0);
});
});
}

View File

@@ -0,0 +1,76 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:nearle_pos/core/utils/formatters.dart';
/// The fault these cover was found in live data, not in a test.
///
/// Bill INV-2608-T5EDD-00116 carried `billedat 2026-08-05T12:49:28.245Z` beside
/// `receivedat 2026-08-05T07:19:28.586Z` — the bill appearing to have been rung
/// five and a half hours *after* the back office received it. Exactly the IST
/// offset, every time.
///
/// Nothing was lying. `DateTime.toIso8601String()` on a local time emits no
/// zone marker at all, Go's `time.Parse` fills that silence with UTC, and the
/// till's wall clock was recorded as though it had been read in London.
void main() {
group('a timestamp says which zone it was read in', () {
test('carries an explicit offset', () {
final iso = Formatters.isoWithOffset(DateTime(2026, 8, 5, 12, 49, 28));
expect(
iso,
matches(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.*[+-]\d{2}:\d{2}$'),
reason: 'without an offset the receiver has to guess, and guesses UTC',
);
});
test('round-trips back to the same instant', () {
// The whole point. The old form parsed to a different moment than the one
// the cashier rang the bill at.
final rung = DateTime(2026, 8, 5, 12, 49, 28, 245);
final parsed = DateTime.parse(Formatters.isoWithOffset(rung));
expect(
parsed.toUtc(),
rung.toUtc(),
reason: 'the instant must survive the trip to the back office',
);
});
test('keeps the wall clock the till actually showed', () {
// Load-bearing for businessdate. The back office derives a day's takings
// from the wall clock, so a bill rung at 12:49 must still read 12:49 —
// converting to UTC before sending would have moved it to 07:19 and put
// late-evening sales on the previous day.
final iso = Formatters.isoWithOffset(DateTime(2026, 8, 5, 12, 49, 28));
expect(iso, startsWith('2026-08-05T12:49:28'));
});
test('does not drop a half-hour offset', () {
// India is +05:30. An implementation that formatted only whole hours
// would produce +05:00 here and be wrong by thirty minutes — the kind of
// error that survives review because it looks almost right.
final offset = DateTime.now().timeZoneOffset;
final iso = Formatters.isoWithOffset(DateTime(2026, 8, 5, 12, 0));
final minutes = offset.abs().inMinutes % 60;
expect(
iso.substring(iso.length - 2),
minutes.toString().padLeft(2, '0'),
reason: 'the minutes component must come from the real offset',
);
});
test('a UTC input is converted, not relabelled', () {
// Passing an already-UTC DateTime must not stamp it with the local
// offset while leaving the UTC wall clock in place — that would recreate
// the original bug in reverse.
final instant = DateTime.utc(2026, 8, 5, 7, 19, 28);
final parsed = DateTime.parse(Formatters.isoWithOffset(instant));
expect(parsed.toUtc(), instant);
});
});
}

View File

@@ -29,10 +29,11 @@ void main() {
test('topics are namespaced per store and per terminal', () {
// Two stores sharing one broker must never see each other's bills.
expect(config.orderTopic, 'pos/store-9/TERM-04/order');
expect(config.ackTopic, 'pos/store-9/TERM-04/ack');
expect(config.statusTopic, 'pos/store-9/TERM-04/status');
expect(config.catalogueTopic, 'pos/store-9/catalogue');
expect(config.orderTopic, 'nearle/pos/store-9/TERM-04/order');
expect(config.ackTopic, 'nearle/pos/store-9/TERM-04/ack');
expect(config.statusTopic, 'nearle/pos/store-9/TERM-04/status');
expect(config.healthTopic, 'nearle/pos/store-9/TERM-04/health');
expect(config.catalogueTopic, 'nearle/pos/store-9/catalogue');
});
test('an ack naming only some ids accepts only those', () async {