diff --git a/docs/sync-contract.md b/docs/sync-contract.md index cfbfa2e..33a9207 100644 --- a/docs/sync-contract.md +++ b/docs/sync-contract.md @@ -47,6 +47,11 @@ Every `order.id` is a UUID minted at the till. Put a unique index on it and upsert. Without this you will double-count a day's takings the first time a shop's line wobbles. +Invoice numbers are `INV-2608-T4A9-00042` — the terminal code is in there +because each till's sequence counter lives in its own database and starts at 1. +Unique per terminal, not globally sequential. Do not assume gaps mean missing +bills; a till that was replaced restarts its own series. + **4. A refusal is final, a failure is not.** Naming an id in `rejected` halts the terminal's drain — it will not retry the same bytes, and a person has to press Sync. Use it for "this bill is wrong" @@ -63,11 +68,62 @@ connection or return 5xx instead, and the terminal will back off and retry. | `pos/{store}/{terminal}/command` | cloud → till | 1 | no | | `pos/{store}/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 +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. + `status` is also the Last Will. If a till loses power the broker publishes `{"state":"offline"}` on its behalf — that is what makes a "which tills are dark" board possible, and it is the only way to tell *closed for the night* from *unplugged*. +### Running this on NATS + +The MQTT gateway maps `/` to `.`, so the topics above arrive as subjects and a +JetStream consumer binds to them directly: + +| Purpose | Subject | +|---|---| +| 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` | + +`SyncConfig.asNatsSubject()` does the translation, so a consumer's subject can +be read off the terminal rather than guessed. + +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. + +### Fleet presence + +Every terminal publishes a retained record on its status topic on connect and +once a minute. Retained matters: a dashboard connecting at noon gets all 100 +terminals' last state immediately instead of a blank board. + +```json +{ + "schema": 1, "state": "online", + "device_id": "…", "terminal_code": "T4A9", "terminal_name": "Counter 2", + "store_id": "store-01", "app_version": "1.1.0", + "reported_at": "2026-08-01T14:22:05Z", + "pending_bills": 3, "last_upload_at": "…", "catalogue_revision": "rev-8821", + "sync_halted": false, "sync_error": null, "consecutive_failures": 0, + "transport": "mqtt" +} +``` + +The Last Will answers *is it reachable*. These fields answer *is it healthy* — +a till can be connected and still be holding 200 unsent bills or running last +month's price list, and only `pending_bills` and `catalogue_revision` will say +so. + ## Payloads **Uplink** — `pos/{store}/{terminal}/order` @@ -77,7 +133,7 @@ from *unplugged*. "schema": 1, "batch_id": "9f1c…", "store_id": "store-01", - "terminal_id": "TERM-01", + "terminal_id": "T4A9", "sent_at": "2026-08-01T14:22:05.123Z", "orders": [ { "id": "…", "invoice_number": "…", "items": [ … ] } ] } diff --git a/lib/app/providers.dart b/lib/app/providers.dart index ca60194..373454a 100644 --- a/lib/app/providers.dart +++ b/lib/app/providers.dart @@ -14,6 +14,7 @@ import '../data/remote/order_transport.dart'; import '../data/remote/simulated_order_transport.dart'; import '../data/repositories/sync_repository_impl.dart'; import '../data/repositories/transaction_repository_impl.dart'; +import '../data/local/terminal_identity.dart'; import '../data/sync/sync_engine.dart'; import '../domain/repositories/customer_repository.dart'; import '../domain/repositories/product_repository.dart'; @@ -56,7 +57,17 @@ final remoteCatalogueProvider = Provider( /// /// Defaults to the simulated route so a fresh install is usable with no broker /// and no endpoint; Settings re-points it. -final syncConfigProvider = StateProvider((ref) => const SyncConfig()); +/// +/// 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. +final syncConfigProvider = StateProvider((ref) { + final terminal = ref.watch(terminalIdentityProvider); + return SyncConfig( + storeId: terminal.storeId, + terminalId: terminal.code, + ); +}); /// Real network state, folded with the Settings offline switch. final connectivityServiceProvider = Provider((ref) { @@ -146,13 +157,31 @@ class CashierSession { final String terminalId; } -final cashierSessionProvider = StateProvider( - (ref) => const CashierSession( +/// Identity of the physical till, read from its own database. +/// +/// Falls back only before the store has opened; every real read happens after +/// `LocalStore.init`, which mints the identity if this device has never run +/// before. +final terminalIdentityProvider = Provider((ref) { + final store = ref.watch(localStoreProvider); + return store.isReady + ? store.terminal + : const TerminalIdentity( + deviceId: 'unopened', + code: 'T0000', + name: 'Terminal', + storeId: 'store-01', + ); +}); + +final cashierSessionProvider = StateProvider((ref) { + final terminal = ref.watch(terminalIdentityProvider); + return CashierSession( name: 'Suriya', role: 'ADMIN', - terminalId: 'TERM-01', - ), -); + terminalId: terminal.code, + ); +}); /// Ticks once a minute to drive the header clock without rebuilding on every /// frame. diff --git a/lib/core/config/sync_config.dart b/lib/core/config/sync_config.dart index f95196c..e1f69d8 100644 --- a/lib/core/config/sync_config.dart +++ b/lib/core/config/sync_config.dart @@ -10,6 +10,11 @@ enum TransportKind { /// Persistent connection. Costs a broker, and buys the downlink: head office /// can push a price change or ask a terminal to sync without waiting for it /// to ask first. + /// + /// Speaks MQTT 3.1.1, so it works against Mosquitto, EMQX or a NATS server + /// with its MQTT gateway enabled. Against NATS the topics below arrive as + /// subjects with `/` mapped to `.` — `pos/store-01/T4A9/order` becomes + /// `pos.store-01.T4A9.order` — which is what a JetStream consumer binds to. mqtt, } @@ -90,8 +95,20 @@ class SyncConfig { /// Stable across restarts so the broker can resume a session and redeliver /// anything in flight, rather than treating each launch as a new client. + /// + /// Derived from the terminal's own identity, which is minted per device — two + /// tills sharing a client id would knock each other off the broker in a loop, + /// since a second connection with the same id evicts the first. String get clientId => 'pos-$storeId-$terminalId'; + /// The same topic as a NATS subject. + /// + /// 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. + static String asNatsSubject(String topic) => topic.replaceAll('/', '.'); + SyncConfig copyWith({ TransportKind? transport, String? storeId, diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart index b0c3d51..2b0594c 100644 --- a/lib/core/constants/app_constants.dart +++ b/lib/core/constants/app_constants.dart @@ -3,6 +3,10 @@ class AppConstants { const AppConstants._(); static const String appName = 'Nearle POS'; + + /// Reported in every presence record. Across a fleet this is how you find + /// the twelve tills still on last month's build when one of them misbehaves. + static const String appVersion = '1.1.0'; static const String storeName = 'Nearle Daily'; static const String storeAddress = '12 Gandhipuram Main Rd, Coimbatore 641012'; static const String storeGstin = '33ABCDE1234F1Z5'; diff --git a/lib/core/utils/formatters.dart b/lib/core/utils/formatters.dart index 055e1ce..c10efdf 100644 --- a/lib/core/utils/formatters.dart +++ b/lib/core/utils/formatters.dart @@ -59,9 +59,28 @@ class Formatters { .toUpperCase(); } - static String invoiceNumber(int sequence, DateTime date) { + /// `INV-2608-T4A9-00042`. + /// + /// The terminal code is not decoration. The sequence counter lives in each + /// till's own database, so without it every terminal in the fleet mints + /// `INV-2608-00001` for its first sale of the month — 100 different bills + /// sharing one number, and no way to tell them apart on a receipt or in an + /// audit. The order's UUID keeps the *data* distinct; this keeps the number + /// a human quotes distinct too. + /// + /// [terminalCode] is optional only so older call sites and fixtures keep + /// working; anything that writes a real bill passes it. + static String invoiceNumber( + int sequence, + DateTime date, { + String? terminalCode, + }) { final y = date.year.toString().substring(2); final m = date.month.toString().padLeft(2, '0'); - return 'INV-$y$m-${sequence.toString().padLeft(5, '0')}'; + final seq = sequence.toString().padLeft(5, '0'); + final code = (terminalCode == null || terminalCode.isEmpty) + ? '' + : '$terminalCode-'; + return 'INV-$y$m-$code$seq'; } } diff --git a/lib/data/datasources/local_store.dart b/lib/data/datasources/local_store.dart index 130f192..efecf42 100644 --- a/lib/data/datasources/local_store.dart +++ b/lib/data/datasources/local_store.dart @@ -5,6 +5,7 @@ import '../local/app_database.dart'; import '../local/catalogue_dao.dart'; import '../local/order_dao.dart'; import '../local/sync_log_dao.dart'; +import '../local/terminal_identity.dart'; /// Terminal-side storage facade. /// @@ -20,6 +21,10 @@ class LocalStore { late CatalogueDao catalogue; late OrderDao orders; late SyncLogDao syncLog; + late TerminalIdentityStore identityStore; + + /// Who this till is. Minted on first run, then stable forever. + late TerminalIdentity terminal; final Map _products = {}; final Map _customers = {}; @@ -44,6 +49,11 @@ class LocalStore { catalogue = CatalogueDao(AppDatabase.instance.db); orders = OrderDao(AppDatabase.instance.db); syncLog = SyncLogDao(AppDatabase.instance.db); + identityStore = TerminalIdentityStore(catalogue); + + // Before anything can be written or published: a bill stamped with the + // wrong terminal cannot be traced back to the till that rang it. + terminal = await identityStore.load(); await hydrate(); _ready = true; @@ -67,6 +77,7 @@ class LocalStore { ? null : DateTime.fromMillisecondsSinceEpoch(int.parse(stamp)); _catalogueRevision = await catalogue.meta(MetaKeys.catalogueRevision); + terminal = await identityStore.load(); _unsyncedOrders = await orders.unsyncedCount(); _syncEvents diff --git a/lib/data/local/app_database.dart b/lib/data/local/app_database.dart index d2c0082..16bd745 100644 --- a/lib/data/local/app_database.dart +++ b/lib/data/local/app_database.dart @@ -60,7 +60,7 @@ class AppDatabase { path, options: OpenDatabaseOptions( version: _version, - onConfigure: (db) => db.execute('PRAGMA foreign_keys = ON'), + onConfigure: _configure, onCreate: (db, version) async => _createSchema(db), onUpgrade: (db, from, to) async { if (from < 2) await db.execute(_createDayArchive); @@ -84,12 +84,35 @@ class AppDatabase { inMemoryDatabasePath, options: OpenDatabaseOptions( version: _version, - onConfigure: (db) => db.execute('PRAGMA foreign_keys = ON'), + onConfigure: _configure, onCreate: (db, version) async => _createSchema(db), ), ); } + /// Pragmas applied on every connection, before any query runs. + /// + /// Defaults are wrong for a till: + /// + /// * **WAL** lets a read proceed while a write is in flight. On the rollback + /// journal the product grid refreshing would block the sale being written. + /// It also survives a power cut better: the database file is never left + /// mid-rewrite. + /// * **busy_timeout** makes a contended lock wait instead of throwing + /// `database is locked` — which, at checkout, is a failed sale. + /// * **synchronous = NORMAL** is the right trade under WAL: an fsync per + /// transaction costs more than a POS can spare, and WAL still recovers a + /// committed transaction after a crash. Only a host OS crash or power loss + /// can lose the last commits, which is what the UPS is for. + static Future _configure(Database db) async { + await db.execute('PRAGMA foreign_keys = ON'); + await db.execute('PRAGMA busy_timeout = 5000'); + + // In-memory databases have no WAL; asking for it is harmless but pointless. + await db.execute('PRAGMA journal_mode = WAL'); + await db.execute('PRAGMA synchronous = NORMAL'); + } + Future close() async { await _db?.close(); _db = null; @@ -345,6 +368,19 @@ class MetaKeys { static const String catalogueRevision = 'catalogue_revision'; static const String invoiceSequence = 'invoice_sequence'; + /// Fleet identity. Minted once on first run and never changed — it is what + /// ties a bill, an MQTT topic and a presence record to one physical till. + static const String deviceId = 'terminal_device_id'; + + /// Short code stamped into invoice numbers, e.g. `T4A9`. Unique per device + /// so two tills in the same shop cannot mint the same invoice. + static const String terminalCode = 'terminal_code'; + + /// Human label shown in Settings and on the fleet board, e.g. "Counter 2". + static const String terminalName = 'terminal_name'; + + static const String storeId = 'store_id'; + /// Printer chosen in Settings. Stored as the printer's `url`, which is what /// `Printing.directPrintPdf` needs to target it without a dialog. static const String printerUrl = 'printer_url'; diff --git a/lib/data/local/terminal_identity.dart b/lib/data/local/terminal_identity.dart new file mode 100644 index 0000000..681bbfe --- /dev/null +++ b/lib/data/local/terminal_identity.dart @@ -0,0 +1,106 @@ +import 'package:uuid/uuid.dart'; + +import 'app_database.dart'; +import 'catalogue_dao.dart'; + +/// Who this till is, across a fleet. +/// +/// Everything that has to be distinguishable between 100 installed terminals +/// hangs off this: MQTT topics, presence records, invoice numbers, and which +/// cashier's shift a bill belongs to. Before it existed every device called +/// itself `TERM-01`, so their topics collided, the fleet board showed one +/// terminal, and 100 tills minted the same invoice number. +class TerminalIdentity { + const TerminalIdentity({ + required this.deviceId, + required this.code, + required this.name, + required this.storeId, + }); + + /// Minted once, on first run, and never changed. The stable machine identity + /// — reinstalling the app on the same till keeps it, because it lives in the + /// database rather than in memory. + final String deviceId; + + /// Short, unique, and safe inside an invoice number: `T4A9`. + final String code; + + /// What a person calls it. Free text, and duplicates are the shop's problem, + /// not the system's — nothing keys on it. + final String name; + + final String storeId; + + /// Used as the MQTT client id and in every topic. Stable across restarts so + /// the broker can resume a session rather than treating each launch as a new + /// client. + String get clientId => 'pos-$storeId-$code'; + + @override + String toString() => '$name ($code)'; +} + +/// Reads the terminal's identity from the database, minting it on first run. +class TerminalIdentityStore { + const TerminalIdentityStore(this._catalogue); + + final CatalogueDao _catalogue; + + static const _uuid = Uuid(); + + /// Loads the identity, creating one the first time this device is started. + /// + /// The mint is idempotent: an existing device id is never replaced, so a + /// terminal cannot silently change identity and orphan its own history. + Future load({String defaultStoreId = 'store-01'}) async { + var deviceId = await _catalogue.meta(MetaKeys.deviceId); + var code = await _catalogue.meta(MetaKeys.terminalCode); + + if (deviceId == null || deviceId.isEmpty) { + deviceId = _uuid.v4(); + await _catalogue.setMeta(MetaKeys.deviceId, deviceId); + } + + if (code == null || code.isEmpty) { + code = codeFor(deviceId); + await _catalogue.setMeta(MetaKeys.terminalCode, code); + } + + final name = await _catalogue.meta(MetaKeys.terminalName); + final storeId = await _catalogue.meta(MetaKeys.storeId); + + return TerminalIdentity( + deviceId: deviceId, + code: code, + name: (name == null || name.isEmpty) ? 'Terminal $code' : name, + storeId: (storeId == null || storeId.isEmpty) ? defaultStoreId : storeId, + ); + } + + /// `T` plus four hex characters of the device id. + /// + /// Short enough to read off a screen and repeat over the phone, and with + /// 65,536 values a 100-terminal fleet has roughly a 7% chance of a collision + /// somewhere in it — which is why [rename] exists and why the back office + /// should reject a duplicate rather than assume uniqueness. + static String codeFor(String deviceId) { + final hex = deviceId.replaceAll('-', ''); + return 'T${hex.substring(0, 4).toUpperCase()}'; + } + + /// Re-codes a terminal, for when head office wants readable numbers or two + /// devices in one shop happened to collide. + /// + /// The device id is deliberately untouched: history already written under the + /// old code keeps pointing at the same physical till. + Future rename({String? code, String? name, String? storeId}) async { + if (code != null && code.isNotEmpty) { + await _catalogue.setMeta(MetaKeys.terminalCode, code.toUpperCase()); + } + if (name != null) await _catalogue.setMeta(MetaKeys.terminalName, name); + if (storeId != null && storeId.isNotEmpty) { + await _catalogue.setMeta(MetaKeys.storeId, storeId); + } + } +} diff --git a/lib/data/remote/mqtt_order_transport.dart b/lib/data/remote/mqtt_order_transport.dart index 98f8fd6..4be5f9d 100644 --- a/lib/data/remote/mqtt_order_transport.dart +++ b/lib/data/remote/mqtt_order_transport.dart @@ -220,6 +220,16 @@ class MqttOrderTransport implements OrderTransport { } // --------------------------------------------------------------- Downlink + /// Publishes a presence record on the status topic, retained. + /// + /// Retained so a dashboard connecting hours later still learns every + /// terminal's last known state, rather than showing a blank board until each + /// one happens to tick. + Future publishStatus(String payload) async { + if (!isConnected) return; + _publish(config.statusTopic, payload, retain: true); + } + /// Registers a batch as awaiting its ack, without publishing one. /// /// Lets a test drive the correlation rules — which is where the logic that diff --git a/lib/data/sync/presence_reporter.dart b/lib/data/sync/presence_reporter.dart new file mode 100644 index 0000000..503a2fc --- /dev/null +++ b/lib/data/sync/presence_reporter.dart @@ -0,0 +1,109 @@ +import 'dart:async'; +import 'dart:convert'; + +import '../../core/config/sync_config.dart'; +import '../local/terminal_identity.dart'; +import '../remote/mqtt_order_transport.dart'; +import 'sync_engine.dart'; + +/// Publishes what this till is doing, so a fleet of them can be watched. +/// +/// The Last Will already answers "is it dead" — the broker publishes `offline` +/// when a terminal stops responding. That is not enough to run 100 shops on: a +/// till can be connected and still be broken, holding 200 unsent bills or +/// running last month's catalogue. This publishes the state that distinguishes +/// *reachable* from *healthy*. +/// +/// Retained on purpose. A dashboard that connects at noon gets every terminal's +/// last report immediately, instead of a blank board until each one happens to +/// tick. +class PresenceReporter { + PresenceReporter({ + required MqttOrderTransport transport, + required TerminalIdentity terminal, + required SyncConfig config, + required SyncEngine engine, + required this.appVersion, + required Future Function() catalogueRevision, + Duration interval = const Duration(minutes: 1), + DateTime Function()? clock, + Timer Function(Duration, void Function())? scheduleTimer, + }) : _transport = transport, + _terminal = terminal, + _config = config, + _engine = engine, + _catalogueRevision = catalogueRevision, + _interval = interval, + _now = clock ?? DateTime.now, + _schedule = scheduleTimer ?? Timer.new; + + final MqttOrderTransport _transport; + final TerminalIdentity _terminal; + final SyncConfig _config; + final SyncEngine _engine; + final Future Function() _catalogueRevision; + final Duration _interval; + final DateTime Function() _now; + final Timer Function(Duration, void Function()) _schedule; + + final String appVersion; + + Timer? _timer; + bool _stopped = false; + + Future start() async { + if (_stopped) throw StateError('This PresenceReporter has been disposed.'); + await publish(); + _tick(); + } + + void _tick() { + _timer = _schedule(_interval, () { + if (_stopped) return; + unawaited(publish()); + _tick(); + }); + } + + /// One presence record. + /// + /// Failures are swallowed. A terminal that cannot tell head office how it is + /// must still sell — losing a heartbeat is a monitoring gap, not a reason to + /// stop trading. + Future publish() async { + if (!_transport.isConnected) return; + + try { + final state = _engine.state; + await _transport.publishStatus( + jsonEncode({ + 'schema': 1, + 'state': 'online', + 'device_id': _terminal.deviceId, + 'terminal_code': _terminal.code, + 'terminal_name': _terminal.name, + 'store_id': _terminal.storeId, + 'app_version': appVersion, + 'reported_at': _now().toIso8601String(), + + // The three numbers that separate a healthy till from a broken one. + 'pending_bills': state.pending, + 'last_upload_at': state.lastSuccessAt?.toIso8601String(), + 'catalogue_revision': await _catalogueRevision(), + + 'sync_halted': state.isHalted, + 'sync_error': state.lastError, + 'consecutive_failures': state.consecutiveFailures, + 'transport': _config.transport.name, + }), + ); + } on Object { + // Deliberately silent — see above. + } + } + + Future dispose() async { + _stopped = true; + _timer?.cancel(); + } +} diff --git a/lib/domain/usecases/checkout_sale.dart b/lib/domain/usecases/checkout_sale.dart index 63a696c..5826274 100644 --- a/lib/domain/usecases/checkout_sale.dart +++ b/lib/domain/usecases/checkout_sale.dart @@ -51,7 +51,8 @@ class CheckoutSale { required Cart cart, required List payments, required String cashierName, - String terminalId = 'TERM-01', + required String terminalId, + String? terminalCode, }) async { _validate(cart, payments); await _assertStockAvailable(cart); @@ -82,7 +83,11 @@ class CheckoutSale { final transaction = SaleTransaction( id: _uuid.v4(), - invoiceNumber: Formatters.invoiceNumber(sequence, now), + invoiceNumber: Formatters.invoiceNumber( + sequence, + now, + terminalCode: terminalCode ?? terminalId, + ), cart: cart, payments: payments, createdAt: now, diff --git a/lib/presentation/sync/providers/sync_controller.dart b/lib/presentation/sync/providers/sync_controller.dart index 2f1afc1..cd926d8 100644 --- a/lib/presentation/sync/providers/sync_controller.dart +++ b/lib/presentation/sync/providers/sync_controller.dart @@ -1,6 +1,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/presence_reporter.dart'; import '../../../domain/entities/shift_report.dart'; import '../../../domain/entities/sync_event.dart'; import '../../../domain/repositories/sync_repository.dart'; @@ -224,4 +227,20 @@ final syncBootstrapProvider = FutureProvider((ref) async { ref.onDispose(subscription.cancel); await engine.start(); + + // Fleet presence only exists on a transport that can carry it. + final transport = ref.read(orderTransportProvider); + if (transport is MqttOrderTransport) { + final reporter = PresenceReporter( + transport: transport, + terminal: ref.read(terminalIdentityProvider), + config: ref.read(syncConfigProvider), + engine: engine, + appVersion: AppConstants.appVersion, + catalogueRevision: () async => + ref.read(syncRepositoryProvider).catalogueRevision, + ); + ref.onDispose(reporter.dispose); + await reporter.start(); + } }); diff --git a/test/unit/checkout_test.dart b/test/unit/checkout_test.dart index 1436d06..4ff25f7 100644 --- a/test/unit/checkout_test.dart +++ b/test/unit/checkout_test.dart @@ -62,6 +62,7 @@ void main() { PaymentSplit(method: PaymentMethod.cash, amount: 124, tendered: 200), ], cashierName: 'Suriya', + terminalId: 'T0TEST', ); final txn = result.transaction; @@ -83,6 +84,7 @@ void main() { PaymentSplit(method: PaymentMethod.cash, amount: 124, tendered: 124), ], cashierName: 'Suriya', + terminalId: 'T0TEST', ); final milk = await products.findByBarcode('8901234500011'); @@ -98,6 +100,7 @@ void main() { PaymentSplit(method: PaymentMethod.cash, amount: 124, tendered: 124), ], cashierName: 'Suriya', + terminalId: 'T0TEST', ); final history = await transactions.history(); @@ -119,6 +122,7 @@ void main() { ), ], cashierName: 'Suriya', + terminalId: 'T0TEST', ); expect(result.transaction.isSplit, isTrue); @@ -140,6 +144,7 @@ void main() { PaymentSplit(method: PaymentMethod.cash, amount: 122, tendered: 200), ], cashierName: 'Suriya', + terminalId: 'T0TEST', ); final updated = result.updatedCustomer!; @@ -158,6 +163,7 @@ void main() { PaymentSplit(method: PaymentMethod.cash, amount: 0), ], cashierName: 'Suriya', + terminalId: 'T0TEST', ), throwsA(isA()), ); @@ -166,7 +172,12 @@ void main() { test('refuses a bill with no tender', () async { final cart = await milkCart(); expect( - () => checkout(cart: cart, payments: const [], cashierName: 'Suriya'), + () => checkout( + cart: cart, + payments: const [], + cashierName: 'Suriya', + terminalId: 'T0TEST', + ), throwsA(isA()), ); }); @@ -180,6 +191,7 @@ void main() { PaymentSplit(method: PaymentMethod.cash, amount: 100, tendered: 100), ], cashierName: 'Suriya', + terminalId: 'T0TEST', ), throwsA(isA()), ); @@ -198,6 +210,7 @@ void main() { ), ], cashierName: 'Suriya', + terminalId: 'T0TEST', ), throwsA(isA()), ); @@ -206,7 +219,12 @@ void main() { test('leaves stock untouched when validation fails', () async { final cart = await milkCart(); try { - await checkout(cart: cart, payments: const [], cashierName: 'Suriya'); + await checkout( + cart: cart, + payments: const [], + cashierName: 'Suriya', + terminalId: 'T0TEST', + ); } on CheckoutFailure { // expected } diff --git a/test/unit/fleet_identity_test.dart b/test/unit/fleet_identity_test.dart new file mode 100644 index 0000000..6721419 --- /dev/null +++ b/test/unit/fleet_identity_test.dart @@ -0,0 +1,171 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:nearle_pos/core/config/sync_config.dart'; +import 'package:nearle_pos/core/utils/formatters.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/local/terminal_identity.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/transaction_repository_impl.dart'; +import 'package:nearle_pos/domain/entities/cart.dart'; +import 'package:nearle_pos/domain/entities/transaction.dart'; +import 'package:nearle_pos/domain/usecases/checkout_sale.dart'; + +/// What has to hold when the same build is installed on 100 tills. +/// +/// Every one of these was broken: the terminal id was the literal `TERM-01` in +/// five places, so the whole fleet shared one identity, one set of MQTT topics +/// and one invoice series. +void main() { + late LocalStore store; + + setUpAll(() { + LocalStore.registerSeed( + products: SeedData.products, + customers: SeedData.customers, + ); + }); + + setUp(() async { + store = LocalStore.instance; + await store.reset(withCatalogue: true); + }); + + group('identity', () { + test('a device mints an identity on first run and keeps it', () async { + final identityStore = TerminalIdentityStore(store.catalogue); + + final first = await identityStore.load(); + expect(first.deviceId, isNotEmpty); + expect(first.code, startsWith('T')); + + // A second read must not re-mint. If it did, a terminal would change + // identity on every restart and orphan the bills it had already written. + final second = await identityStore.load(); + expect(second.deviceId, first.deviceId); + expect(second.code, first.code); + }); + + test('two devices get different codes', () { + // Derived from the device UUID rather than a counter, because there is no + // shared counter to draw from — each till mints alone and offline. + final a = TerminalIdentityStore.codeFor( + 'a1b2c3d4-0000-0000-0000-000000000000', + ); + final b = TerminalIdentityStore.codeFor( + '9f8e7d6c-0000-0000-0000-000000000000', + ); + expect(a, 'TA1B2'); + expect(b, 'T9F8E'); + expect(a, isNot(b)); + }); + + test('renaming keeps the device id, so history still points at the till', + () async { + final identityStore = TerminalIdentityStore(store.catalogue); + final before = await identityStore.load(); + + await identityStore.rename(code: 'till7', name: 'Counter 7'); + final after = await identityStore.load(); + + expect(after.code, 'TILL7'); + expect(after.name, 'Counter 7'); + expect(after.deviceId, before.deviceId, + reason: 'the machine identity must survive a re-code',); + }); + + test('the identity is loaded by the store before anything is written', + () async { + expect(store.isReady, isTrue); + expect(store.terminal.deviceId, isNotEmpty); + expect(store.terminal.code, startsWith('T')); + }); + }); + + group('invoice numbers', () { + test('two terminals ringing their first sale do not collide', () { + // The counter lives in each till's own database, so without the terminal + // code every device in the fleet mints INV-2608-00001 for its first sale. + final date = DateTime(2026, 8, 1); + final onTillA = Formatters.invoiceNumber(1, date, terminalCode: 'TA1B2'); + final onTillB = Formatters.invoiceNumber(1, date, terminalCode: 'T9F8E'); + + expect(onTillA, 'INV-2608-TA1B2-00001'); + expect(onTillA, isNot(onTillB)); + }); + + test('a real sale carries this terminal\'s code', () async { + final products = ProductRepositoryImpl(store); + final checkout = CheckoutSale( + productRepository: products, + customerRepository: CustomerRepositoryImpl(store), + transactionRepository: TransactionRepositoryImpl(store), + ); + + final milk = (await products.findByBarcode('8901234500011'))!; + final result = await checkout( + cart: Cart(lines: [CartLine(product: milk, quantity: 1)]), + payments: const [ + PaymentSplit(method: PaymentMethod.cash, amount: 62, tendered: 100), + ], + cashierName: 'Divya', + terminalId: store.terminal.code, + ); + + expect(result.transaction.invoiceNumber, contains(store.terminal.code)); + expect(result.transaction.terminalId, store.terminal.code); + }); + }); + + group('topics', () { + test('two terminals in one store publish on different topics', () { + const a = SyncConfig(storeId: 'store-01', terminalId: 'TA1B2'); + const b = SyncConfig(storeId: 'store-01', terminalId: 'T9F8E'); + + expect(a.orderTopic, isNot(b.orderTopic)); + expect(a.statusTopic, isNot(b.statusTopic)); + // A shared client id would evict the other till from the broker on every + // connect, in a loop. + expect(a.clientId, isNot(b.clientId)); + }); + + test('the catalogue topic is shared, because a price change is store-wide', + () { + const a = SyncConfig(storeId: 'store-01', terminalId: 'TA1B2'); + const b = SyncConfig(storeId: 'store-01', terminalId: 'T9F8E'); + expect(a.catalogueTopic, b.catalogueTopic); + }); + + test('topics translate to NATS subjects', () { + // NATS' MQTT gateway maps / to . — this is what a JetStream consumer + // binds to. + const config = SyncConfig(storeId: 'store-01', terminalId: 'TA1B2'); + expect( + SyncConfig.asNatsSubject(config.orderTopic), + 'pos.store-01.TA1B2.order', + ); + expect( + SyncConfig.asNatsSubject(config.statusTopic), + 'pos.store-01.TA1B2.status', + ); + }); + }); + + group('database pragmas', () { + test('foreign keys are enforced', () async { + final rows = + await AppDatabase.instance.db.rawQuery('PRAGMA foreign_keys'); + expect(rows.first.values.first, 1, + reason: 'order_items must cascade with their order',); + }); + + test('a contended lock waits instead of throwing', () async { + // Default is 0, which surfaces at checkout as "database is locked" — + // a failed sale with a customer standing there. + final rows = + await AppDatabase.instance.db.rawQuery('PRAGMA busy_timeout'); + expect(rows.first.values.first, greaterThanOrEqualTo(5000)); + }); + }); +} diff --git a/test/unit/persistence_test.dart b/test/unit/persistence_test.dart index e149428..4561f6b 100644 --- a/test/unit/persistence_test.dart +++ b/test/unit/persistence_test.dart @@ -82,6 +82,7 @@ void main() { PaymentSplit(method: PaymentMethod.cash, amount: 529, tendered: 529), ], cashierName: 'Divya', + terminalId: 'T0TEST', ); final stored = (await transactions.history()).single; @@ -113,6 +114,7 @@ void main() { PaymentSplit(method: PaymentMethod.cash, amount: 589, tendered: 600), ], cashierName: 'Divya', + terminalId: 'T0TEST', ); final stored = (await transactions.history()).single; @@ -135,6 +137,7 @@ void main() { PaymentSplit(method: PaymentMethod.cash, amount: 529, tendered: 529), ], cashierName: 'Divya', + terminalId: 'T0TEST', ); final report = ShiftReport.fromTransactions( @@ -193,6 +196,7 @@ void main() { ), ], cashierName: 'Divya', + terminalId: 'T0TEST', ), throwsA(isA()), ); @@ -210,6 +214,7 @@ void main() { PaymentSplit(method: PaymentMethod.cash, amount: 248, tendered: 250), ], cashierName: 'Divya', + terminalId: 'T0TEST', ); expect((await products.findByBarcode('8901234500011'))!.stock, opening - 4,); @@ -248,6 +253,7 @@ void main() { PaymentSplit(method: PaymentMethod.cash, amount: 124, tendered: 124), ], cashierName: 'Divya', + terminalId: 'T0TEST', ), throwsA(isA()), ); @@ -288,6 +294,7 @@ void main() { PaymentSplit(method: PaymentMethod.cash, amount: 529, tendered: 529), ], cashierName: 'Divya', + terminalId: 'T0TEST', ); final outcome = await sync.syncOrders(); @@ -325,6 +332,7 @@ void main() { PaymentSplit(method: PaymentMethod.cash, amount: 62, tendered: 62), ], cashierName: 'Divya', + terminalId: 'T0TEST', ); await sync.syncOrders(); @@ -365,6 +373,7 @@ void main() { PaymentSplit(method: PaymentMethod.cash, amount: due, tendered: due), ], cashierName: cashier, + terminalId: 'T0TEST', ); } diff --git a/test/unit/retention_test.dart b/test/unit/retention_test.dart index 7b25bc6..06527d1 100644 --- a/test/unit/retention_test.dart +++ b/test/unit/retention_test.dart @@ -107,6 +107,7 @@ void main() { ), ], cashierName: cashier, + terminalId: 'T0TEST', ); return due; }