Files
nearle_pos/lib/data/datasources/local_store.dart
Suriya e17937e8f1 Move staff PINs out of the shipped binary into hashed database rows
Three StaffUser constants carried plaintext PINs (1234/2345/3456) in
auth_controller.dart. Every build shipped every till's credentials, readable
by anyone who unzipped the APK. Across 100 deployed devices that is one
credential, not a hundred.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:00:12 +05:30

234 lines
7.8 KiB
Dart

import '../../domain/entities/customer.dart';
import '../../domain/entities/product.dart';
import '../../domain/entities/sync_event.dart';
import '../local/app_database.dart';
import '../local/catalogue_dao.dart';
import '../local/order_dao.dart';
import '../local/staff_dao.dart';
import '../local/sync_log_dao.dart';
import '../local/terminal_identity.dart';
/// Terminal-side storage facade.
///
/// SQLite is the source of truth. The catalogue is additionally held in memory
/// because barcode resolution happens on every scan and the product grid reads
/// it constantly — but every write goes to disk first, so nothing depends on
/// the process staying alive.
class LocalStore {
LocalStore._();
static final LocalStore instance = LocalStore._();
late CatalogueDao catalogue;
late OrderDao orders;
late SyncLogDao syncLog;
late StaffDao staff;
late TerminalIdentityStore identityStore;
/// Who this till is. Minted on first run, then stable forever.
late TerminalIdentity terminal;
final Map<String, Product> _products = {};
final Map<String, Customer> _customers = {};
final List<SyncEvent> _syncEvents = [];
DateTime? _lastImportAt;
String? _catalogueRevision;
int _unsyncedOrders = 0;
bool _ready = false;
bool get isReady => _ready;
/// Opens the database and loads the catalogue into memory.
Future<void> init({String? databasePath, bool inMemory = false}) async {
if (inMemory) {
await AppDatabase.instance.openInMemory();
} else {
await AppDatabase.instance.open(overridePath: databasePath);
}
catalogue = CatalogueDao(AppDatabase.instance.db);
orders = OrderDao(AppDatabase.instance.db);
syncLog = SyncLogDao(AppDatabase.instance.db);
staff = StaffDao(AppDatabase.instance.db);
identityStore = TerminalIdentityStore(catalogue);
// A terminal with no staff cannot be signed into at all, so this runs
// before anything else can ask who is on shift.
await staff.seedIfEmpty();
// 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;
}
/// Re-reads cached state from disk. Called on start and after an import.
Future<void> hydrate() async {
_products
..clear()
..addEntries(
(await catalogue.allProducts()).map((p) => MapEntry(p.id, p)),
);
_customers
..clear()
..addEntries(
(await catalogue.allCustomers()).map((c) => MapEntry(c.id, c)),
);
final stamp = await catalogue.meta(MetaKeys.lastImportAt);
_lastImportAt = stamp == null
? null
: DateTime.fromMillisecondsSinceEpoch(int.parse(stamp));
_catalogueRevision = await catalogue.meta(MetaKeys.catalogueRevision);
terminal = await identityStore.load();
_unsyncedOrders = await orders.unsyncedCount();
_syncEvents
..clear()
..addAll(await syncLog.recent());
}
// ---------------------------------------------------------------- Sync log
/// Newest first. Backed by the table, so it survives a restart.
List<SyncEvent> get syncEvents => List.unmodifiable(_syncEvents);
Future<void> appendSyncEvent(SyncEvent event) async {
await syncLog.insert(event);
await syncLog.trim();
_syncEvents.insert(0, event);
}
/// Test helper: wipes every table and reloads.
Future<void> reset({bool withCatalogue = false}) async {
if (!_ready) await init(inMemory: true);
await AppDatabase.instance.clear();
// Staff and identity are cleared with everything else, and a terminal
// without them cannot be signed into or stamp a bill. Re-minted here so a
// reset leaves a usable till rather than a half-built one.
await staff.seedIfEmpty();
terminal = await identityStore.load();
if (withCatalogue) {
// Imported here rather than at the top of the file so the seed data is
// only pulled in by tests and the simulated remote source.
await catalogue.replaceCatalogue(
products: _seedProducts(),
customers: _seedCustomers(),
);
await catalogue.setMeta(
MetaKeys.lastImportAt,
'${DateTime.now().millisecondsSinceEpoch}',
);
await catalogue.setMeta(MetaKeys.catalogueRevision, 'seed');
}
await hydrate();
}
// -------------------------------------------------------------- Catalogue
/// True once products exist on this terminal. Billing is gated on it.
bool get hasCatalogue => _products.isNotEmpty;
DateTime? get lastImportAt => _lastImportAt;
String? get catalogueRevision => _catalogueRevision;
int get unsyncedOrders => _unsyncedOrders;
Future<void> importCatalogue({
required List<Product> products,
required List<Customer> customers,
required String revision,
required DateTime at,
}) async {
await catalogue.replaceCatalogue(products: products, customers: customers);
// The server's stock figure predates any sale this terminal has made but
// not yet uploaded, so those units would reappear on the shelf. Replay them
// before anyone can bill against the inflated count.
final committed = await orders.unsyncedStockCommitments();
if (committed.isNotEmpty) {
await catalogue.decrementStock(committed);
}
await catalogue.setMeta(
MetaKeys.lastImportAt,
'${at.millisecondsSinceEpoch}',
);
await catalogue.setMeta(MetaKeys.catalogueRevision, revision);
await hydrate();
}
// --------------------------------------------------------------- Products
List<Product> get products => _products.values.toList(growable: false);
Product? productById(String id) => _products[id];
Product? productByBarcode(String barcode) {
final needle = barcode.trim();
for (final p in _products.values) {
if (p.barcode == needle && p.isActive) return p;
}
return null;
}
/// Write-through: disk first, then the cache.
Future<void> putProduct(Product p) async {
await catalogue.upsertProduct(p);
_products[p.id] = p;
}
Future<void> applyStockMovement(Map<String, double> quantities) async {
await catalogue.decrementStock(quantities);
cacheStockMovement(quantities);
}
/// Mirrors a stock decrement already written to disk into the memory cache.
/// Used after a sale is committed as part of a larger transaction.
void cacheStockMovement(Map<String, double> quantities) {
quantities.forEach((id, qty) {
final p = _products[id];
if (p == null) return;
_products[id] =
p.copyWith(stock: (p.stock - qty).clamp(0, double.infinity));
});
}
// -------------------------------------------------------------- Customers
List<Customer> get customers => _customers.values.toList(growable: false);
Customer? customerById(String id) => _customers[id];
Future<void> putCustomer(Customer c) async {
await catalogue.upsertCustomer(c);
_customers[c.id] = c;
}
/// Mirrors a customer already written to disk into the memory cache.
void cacheCustomer(Customer c) => _customers[c.id] = c;
// ----------------------------------------------------------------- Orders
/// Refreshes the cached unsynced tally after a write or a sync.
Future<int> refreshUnsyncedCount() async {
_unsyncedOrders = await orders.unsyncedCount();
return _unsyncedOrders;
}
// ------------------------------------------------------------- Seed data
// Kept behind these hooks so production code never reaches for them.
static List<Product> Function() _seedProducts = () => const [];
static List<Customer> Function() _seedCustomers = () => const [];
/// Lets the simulated remote source and tests supply demo rows.
static void registerSeed({
required List<Product> Function() products,
required List<Customer> Function() customers,
}) {
_seedProducts = products;
_seedCustomers = customers;
}
}