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>
Replaces the last simulation on the inbound side. RemoteCatalogueSource
returned SeedData after a fake progress bar; there was no wire format, no
endpoint, and no way to receive an update short of reinstalling.
Wire format (data/remote/catalogue_wire.dart)
- Tolerant where it should be: a catalogue of 4,000 products must not fail to
import over one absent emoji, so optional fields take defaults and an
unrecognised category files under Grocery — the item still scans, prices and
bills.
- Strict where it matters: no id, name, barcode or price and the import fails.
A silently dropped product is a shelf item that scans to nothing, discovered
with a queue waiting.
- GST accepts 18 or 0.18 and reads both the same. Back offices disagree about
which they mean, and getting it wrong silently changes the tax on every line.
HTTP source with paging and deltas
- GET {base}/catalogue?since={revision}&page={n}. Paged because a supermarket
catalogue is tens of thousands of rows: one response times out on a shop's
line and stalls the UI decoding it. Capped at 200 pages so a bad deployment
cannot become an infinite request loop against a shop's connection.
- `since` carries the revision already held, so a normal morning fetches a
handful of price changes rather than the whole book. A server that cannot do
deltas ignores it and answers is_delta:false — the terminal reads the flag
rather than assuming, so both work.
- A bad credential is non-retryable and says so, leaving the working catalogue
in place so billing continues.
Applying deltas without losing local state
- A full snapshot withdraws what it omits; a delta must not. Read as a
snapshot, the first morning price change would empty the shelf.
- Retired products are marked inactive, not deleted — order lines already
recorded point at them, and a hard delete would orphan a bill's history.
- Locally registered shoppers survive a pull, as before.
- The unsynced-stock replay is now scoped to the products the pull actually
overwrote. It exists because a server count predates local sales; running it
over a delta that never carried that product would subtract those units a
second time and quietly empty a shelf that is full. Both halves of that rule
are tested.
MQTT stays the nudge, not the transport: a catalogue push on
pos/{store}/catalogue makes every terminal pull immediately, but the rows come
over HTTP, because a broker is the wrong shape for tens of thousands of them.
Tests: 210 -> 234. docs/sync-contract.md now covers both directions, including
a field-by-field table of what happens when something is missing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Promo module was a mockup. Three hardcoded rows, a toggle that changed
nothing, and no promo code anywhere in lib/domain or lib/data. A cashier
looking at it would reasonably conclude promotions were running.
Engine (domain/services/promo_engine.dart)
- Five campaign types: percent or flat off the bill, percent off a category
or a product, and buy-X-get-Y.
- Conditions: date range (inclusive of the closing day), days of the week,
minimum bill value, and a cap on what a percentage can take off — without
one an unusually large trolley gives away more than the campaign was costed
for.
- Stacking is conservative by default. All stackable campaigns apply together;
of the exclusive ones only the single best does, chosen by what it is worth
to the shopper with priority breaking ties. Two percentages compounding
produce a discount nobody signed off, and the shop finds out at the end of
the month.
- The total is capped at the subtotal, so no combination of campaign, tier and
manual discount can turn a sale into a payout.
- buy-X-get-Y counts whole groups only, and prices the free unit at what is
actually being charged — a line already carrying a manual discount must not
refund more than it took.
Kept out of Cart deliberately: Cart owns arithmetic that must never be wrong,
this owns policy a shop changes weekly.
Storage (schema v6, plus promos_json on orders at v7)
- Campaigns persist locally, because a shop mid-promotion with a dead line
still has to honour the price on the shelf edge.
- A bill records the campaign name and the amount given, not a link to the
row. A campaign edited or deleted later cannot change what a past sale
shows, and a reprinted receipt still names what the shopper was given.
- On read-back the promo amounts are subtracted from the manual discount,
because bill_discount already contains them. Restoring both at full value
would discount the bill twice — the same shape as the bug that used to
overstate synced totals.
At the till
- Every cart mutation re-evaluates, so a promo cannot survive the line that
earned it being removed.
- A resumed parked bill is re-evaluated rather than restored: a campaign that
has since ended must not be honoured because the bill was parked while it
was running.
- Campaigns are named individually on the billing panel and the printed
receipt, so a shopper who came in for an advertised offer can see it applied.
Editor
- Full CRUD, admin-only, with validation for the cases that would save happily
and then silently never fire — a targeted campaign with no target, a
percentage over 100, an end date before the start.
Tests: 199 -> 210. Covers each campaign type, the eligibility conditions, the
stacking rules, the impossible-to-go-negative guarantee, GST recomputation
against the reduced total, round-tripping, and the double-count guard.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cash drawer
- openCashDrawer was a debugPrint. The drawer never opened.
- It cannot go through the PDF pipeline: a PDF is rendered by the platform
driver, which will not pass raw ESC/POS bytes to the device. So it goes over
a socket instead — nearly every network thermal printer listens on 9100 and
forwards whatever arrives straight to the print head, which makes the whole
protocol five bytes.
- Printer IP and port are configurable in Settings with a Test button that
saves and fires immediately, because a drawer that does not open is
indistinguishable from one that is not wired up.
- Every failure explains itself: unreachable, refused, or simply not
configured — which is the honest state for a USB printer, since there is no
raw path to one from Flutter.
- Now fires only on a cash tender. A card-only sale that pops the drawer is a
shrinkage risk, and it is the first thing a shop notices.
Back-office route
- Host, port, TLS and transport persist to the database; username, password
and API key go to the platform keystore (Keychain / Credential Manager /
Android Keystore). Writing credentials into SQLite would put them in the
same file as the bills, on a machine behind a shop counter.
- Loaded at startup. Previously the dialog wrote settings that were silently
ignored on the next launch, which reads exactly like they never saved — and
credentials retyped every morning end up on a sticky note instead.
- A saved route never overwrites the terminal's store or terminal id. Those
belong to the device, and re-pointing a till at a different broker must not
change who it is, or its bills and presence records stop lining up.
Tests: 168 -> 176. The drawer test stands up a real socket server and asserts
the exact bytes arrive. The config test asserts no credential appears anywhere
in the meta table while the non-secret settings do.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three StaffUser constants carried plaintext PINs (1234/2345/3456) in
auth_controller.dart. Every build shipped every till's credentials, readable
by anyone who unzipped the APK. Across 100 deployed devices that is one
credential, not a hundred.
- Schema v5 adds a staff table. Only a PBKDF2-HMAC-SHA256 hash and a per-user
random salt are stored; the PIN itself exists nowhere, including there.
12,000 iterations, tuned so one sign-in is imperceptible while working
through all 10,000 four-digit PINs against a stolen database takes ~15
minutes per account instead of milliseconds.
- Verification is constant-time. String == returns at the first differing
byte, and that timing leaks how much of a guess was right.
- StaffUser no longer has a pin field at all, so the credential cannot drift
back into memory, into widgets, or into a const declaration.
- Weak PINs are refused: under four digits, non-numeric, repeated digits, and
sequences. Two staff cannot share a PIN — the till identifies a cashier by
PIN alone, so a shared one would attribute bills to whichever row was
checked first.
- The last admin cannot be demoted or deactivated. A till with no admin cannot
be administered, including to appoint one, and recovering means editing the
database by hand.
- Staff are deactivated, never deleted, so bills already rung keep naming a
real person.
Seed accounts are now 4821/5093/6274 rather than 1234/2345/3456 — the weak-PIN
rule refuses the old ones, and a default the rule itself would reject is not a
defensible default. All three are flagged must-change-pin so they get a shop
trading on day one without becoming permanent.
Store details are now editable data, not compile-time constants. Name,
address, GSTIN and phone persist to the database and are read back rather than
falling through to the build's constants, which would silently undo a failed
save. GSTIN is format-validated including the state code — it prints on every
invoice as a legal requirement, so a typo is a compliance problem across
hundreds of bills before anyone notices.
Tests: 141 -> 160. Includes a test that reads every column of every staff row
and asserts no seed PIN appears anywhere in the database.
Migration test now asserts v5 and that an upgraded terminal comes up with the
staff table present but empty — seeding is the store's job on first open, so
an existing shop is never handed accounts it did not create.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answers "which of my 100 tills are alive and healthy", and fixes three things
that were fine on one device and broken on a hundred.
Terminal identity (lib/data/local/terminal_identity.dart)
- Every device mints a UUID on first run, stored in its own database, plus a
short code (T4A9) derived from it. Renaming keeps the device id, so history
keeps pointing at the same physical till.
- Replaces the literal 'TERM-01', which was hardcoded in five places. The whole
fleet reported as one terminal: shift reports merged, MQTT topics collided,
and a second connection with the same client id evicts the first from the
broker — so two tills would have knocked each other offline in a loop.
Invoice numbers now carry the terminal code
- INV-2608-T4A9-00042. The sequence counter lives in each till's own database
and starts at 1, so without this every terminal in the fleet minted
INV-2608-00001 for its first sale of the month. The order UUID kept the data
distinct; the number a customer quotes on a receipt was not.
SQLite pragmas
- WAL, so the product grid refreshing does not block the sale being written,
and the file is never left mid-rewrite by a power cut.
- busy_timeout 5s, so a contended lock waits instead of throwing "database is
locked" — which at checkout is a failed sale with a customer standing there.
- synchronous NORMAL, the right trade under WAL for a till.
Fleet presence (lib/data/sync/presence_reporter.dart)
- Retained status record on connect and once a minute: device id, code, name,
app version, pending bill count, last upload, catalogue revision, sync halt
state. Retained so a dashboard connecting at noon gets all 100 terminals
immediately rather than a blank board.
- The Last Will already said "reachable". A till can be connected and still be
holding 200 unsent bills or running last month's prices; only pending_bills
and catalogue_revision say so.
NATS
- The MQTT gateway maps / to . so the existing transport works unchanged.
SyncConfig.asNatsSubject() exposes the translation, and the contract doc
gives the JetStream subjects (pos.*.*.order, pos.*.*.status) plus the two
server-side requirements: a file-backed stream, and the ack published by the
consumer after commit rather than by the ingest handler.
Tests: 129 -> 140. New coverage for identity minting and stability, per-device
invoice uniqueness, topic and client-id separation, NATS subject mapping, and
the two pragmas. Suite run three times clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bills were persisted correctly but read back wrong. The read path rebuilt a
cart from its lines alone, dropping bill-level discounts and loyalty, so every
figure derived from a stored bill was overstated: the upload payload, the day
archive and the shift report. A discounted 529 bill read back as 620.
Money and data integrity
- order_dao: restore bill_discount and points_redeemed when rebuilding a cart;
keep the reconstruction tier-less so the membership discount is not applied
twice. Trust the recorded total and points via SaleTransaction.storedTotal.
- checkout_sale + order_dao.commitSale: write the bill, its stock movement and
the loyalty update in one transaction. Previously a failure part-way through
left a persisted bill the cashier believed had failed, inviting a duplicate.
- checkout_sale: re-check every line against live stock. A parked bill resumed
after its stock was sold passed validation and oversold.
- catalogue_dao: allocate the invoice sequence in one transaction; the previous
read-modify-write could hand two sales the same number and fail UNIQUE.
- local_store: replay unsynced sales after a catalogue import, so a mid-shift
re-import cannot restore stock that has already been sold.
- payment_controller: stamp the signed-in operator on the bill instead of the
hardcoded seed session, and pass the terminal id through.
- cart: reconcile per-slab GST against the bill total so the parts sum to the
whole on a tax invoice.
Sync and reporting
- sync_repository: drain unsynced bills in a loop rather than silently capping
at one page; stop on rejection so rejected rows cannot loop forever.
- sync_log_dao (new): persist the sync history to the sync_log table, which the
schema already defined but nothing used. It was in memory, so the only record
that bills had been uploaded died at restart.
- Scope shift reports by cashier. day_archive is re-keyed to
(business_date, cashier_name) so a till stays settleable after its bills are
uploaded and deleted. Schema v4 with a migration that carries v3 rows across.
Input and UI
- barcode_service: consume machine-paced keystrokes so a scan cannot also land
in the focused field, and raise the bar to 60ms/char while a text field has
focus so typing a mobile number is not read as a scan. Clock and focus check
injected so the behaviour is testable.
- primary_button: make the label flexible; label plus trailing total overflowed
the Charge button by up to 131px.
- app_router: redirect instead of null-casting when the receipt route is
entered without its transaction.
- customer_repository: reduce the search query to digits so a punctuated mobile
number matches.
Cleanup
- Remove TransactionRepository.save, CustomerRepository.recordSale and
OrderDao.insertOrder, all superseded by commitSale.
- dart fix across the tree; 251 analyzer issues down to 3 info-level.
Tests: 23 passing / 15 failing -> 90 passing. Fixed the two defects that broke
the existing suite (containsAll type argument, reset() needing a catalogue) and
deleted the leftover template test. Added coverage for the order round trip,
the day archive after a real sync, stock safety, checkout atomicity, the v3->v4
migration, scanner-versus-human input, and an app-level smoke test that renders
every module.
Note: bills already uploaded with a discount went up overstated. This stops it
happening again but does not correct historical server data.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>