pos changes

This commit is contained in:
2026-08-06 19:26:53 +05:30
parent cb065a0f69
commit eebd10da6d
42 changed files with 3451 additions and 2531 deletions

View File

@@ -9,6 +9,7 @@ import '../local/staff_dao.dart';
import '../local/sync_config_store.dart';
import '../local/sync_log_dao.dart';
import '../local/terminal_identity.dart';
import '../local/void_pin_store.dart';
/// Terminal-side storage facade.
///
@@ -28,6 +29,7 @@ class LocalStore {
late PromoDao promos;
late SyncConfigStore syncConfig;
late TerminalIdentityStore identityStore;
late VoidPinStore voidPin;
/// Who this till is. Minted on first run, then stable forever.
late TerminalIdentity terminal;
@@ -60,6 +62,7 @@ class LocalStore {
promos = PromoDao(AppDatabase.instance.db);
syncConfig = SyncConfigStore(catalogue);
identityStore = TerminalIdentityStore(catalogue);
voidPin = VoidPinStore(catalogue, staff);
// A terminal with no staff cannot be signed into at all, so this runs
// before anything else can ask who is on shift.
@@ -148,9 +151,13 @@ class LocalStore {
/// 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.
/// Called when a *cashier* signs 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.
///
/// Not called on an admin sign-out. An admin's whole job at this terminal is
/// to pull the catalogue and hand the till over, so wiping it on the way out
/// would undo the thing they just did.
Future<void> clearCatalogue() async {
await catalogue.clearCatalogue();
_products.clear();

View File

@@ -524,4 +524,14 @@ class MetaKeys {
static const String printerName = 'printer_name';
static const String autoPrint = 'auto_print';
static const String openDrawer = 'open_cash_drawer';
/// PIN that authorises taking a rung item back off a bill.
///
/// Stored hashed, like a staff PIN — the terminal only ever holds the hash
/// and its salt, so lifting the database file does not hand over the ability
/// to void. Deliberately separate from staff PINs: an admin sets it once and
/// gives it to whoever is on the counter, so a removal can be authorised
/// without an admin walking over to the till.
static const String voidPinHash = 'void_pin_hash';
static const String voidPinSalt = 'void_pin_salt';
}

View File

@@ -303,20 +303,27 @@ class OrderDao {
return rows.isEmpty ? null : rows.first;
}
Future<List<Map<String, Object?>>> syncRows({int limit = 200}) => _db.query(
Tables.orders,
columns: [
'id',
'invoice_number',
'total',
'created_at',
'sync_status',
'synced_at',
'sync_attempts',
'sync_error',
],
orderBy: 'created_at DESC',
limit: limit,
/// Rows for the sync log, with each bill's unit count folded in.
///
/// The count comes from a correlated sum over [Tables.orderItems] rather
/// than a column on the order: quantity can be fractional (loose weight), so
/// there is no line count that answers "how many units were on this bill".
/// A single aggregate keeps this to one query rather than one per row.
Future<List<Map<String, Object?>>> syncRows({int limit = 200}) =>
_db.rawQuery(
'''
SELECT o.id, o.invoice_number, o.total, o.created_at, o.sync_status,
o.synced_at, o.sync_attempts, o.sync_error,
COALESCE(
(SELECT SUM(i.quantity) FROM ${Tables.orderItems} i
WHERE i.order_id = o.id),
0
) AS item_count
FROM ${Tables.orders} o
ORDER BY o.created_at DESC
LIMIT ?
''',
[limit],
);
// ----------------------------------------------------------------- Sync

View File

@@ -1,81 +0,0 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../domain/entities/pos_session.dart';
/// Keeps a terminal signed in across restarts.
///
/// A till is not a browser. It signs in when a shop opens and bills for the
/// whole trading day, often on a connection that comes and goes, and it must
/// survive being rebooted mid-shift without a queue of customers waiting while
/// somebody finds the manager's password.
///
/// The whole session goes to the platform keystore rather than to SQLite. The
/// token is a bearer credential — anything holding it can bill as this shop —
/// and SQLite here is a file on a machine behind a shop counter, readable by
/// anything that can open it. The rest of the session travels with the token
/// because splitting them invites the two halves to disagree about which outlet
/// this terminal is.
class SessionStore {
SessionStore({FlutterSecureStorage? secureStorage})
: _secure = secureStorage ?? const FlutterSecureStorage();
final FlutterSecureStorage _secure;
static const _key = 'pos.session';
/// Reads the saved session, or null when there is none worth using.
///
/// An expired session is treated as absent rather than returned for the
/// caller to check. Every caller would have to make the same check, and the
/// one that forgot would send a dead token all day and read the resulting
/// 401s as a server fault.
Future<PosSession?> read({DateTime? now}) async {
final String? raw;
try {
raw = await _secure.read(key: _key);
} on Object catch (e) {
// No keystore — a headless test host, or a Linux box with no secret
// service. Signing in again is the safe way to fail.
debugPrint('Secure storage unavailable, session not loaded: $e');
return null;
}
if (raw == null || raw.isEmpty) return null;
try {
final session =
PosSession.fromJson(jsonDecode(raw) as Map<String, Object?>);
if (!session.isValidAt(now ?? DateTime.now())) return null;
if (session.token.isEmpty || session.locationId <= 0) return null;
return session;
} on Object catch (e) {
// A stored session this build cannot parse — most likely written by an
// older one. Dropped rather than repaired: a half-understood session is
// worse than none, and re-authenticating costs one screen.
debugPrint('Stored session could not be read, discarding: $e');
return null;
}
}
Future<void> write(PosSession session) async {
try {
await _secure.write(key: _key, value: jsonEncode(session.toJson()));
} on Object catch (e) {
// The terminal keeps working on the session it holds in memory; it just
// will not survive a restart. Failing the sign-in over this would close a
// shop for a keystore problem.
debugPrint('Could not persist the session: $e');
}
}
Future<void> clear() async {
try {
await _secure.delete(key: _key);
} on Object catch (e) {
debugPrint('Could not clear the session: $e');
}
}
}

View File

@@ -25,10 +25,6 @@ class StaffDao {
final Database _db;
/// Exposed for [StaffImport], which lives in this file and is part of this
/// type in everything but syntax — an extension cannot see a private field.
Database get db => _db;
static const _uuid = Uuid();
/// The accounts a shop starts with.
@@ -281,93 +277,3 @@ class StaffDao {
isActive: (row['is_active'] as int? ?? 1) == 1,
);
}
/// Replaces the terminal's staff with what the back office says.
///
/// The back office is the source of truth for who works at a shop, and this is
/// where that becomes true rather than aspirational. It exists because the
/// alternative — three names and three PINs compiled into the app — meant every
/// install of a build shared the same three logins, readable by anyone with the
/// APK.
///
/// Three things happen, and the second is the one that matters:
///
/// 1. every person the back office named is written, keyed on their user id so
/// a re-sync updates rather than duplicates;
/// 2. **the seeded accounts are deactivated**, so the moment a shop has real
/// staff the built-in PINs stop working — without this the hardcoded
/// logins would survive alongside the real ones for ever; and
/// 3. anyone previously imported who is no longer named is deactivated too,
/// because a leaver removed in the back office must lose the till.
///
/// Deactivated, never deleted. Bills carry the cashier's name and shifts are
/// settled against it, so a hard delete would orphan a day's takings.
///
/// Does nothing at all when [members] is empty. That is the common case today —
/// most outlets have no staff recorded — and wiping a working till's logins
/// because the back office has not been filled in yet would close a shop.
extension StaffImport on StaffDao {
Future<int> replaceFromBackOffice(List<StaffImportRecord> members) async {
if (members.isEmpty) return 0;
final now = DateTime.now().millisecondsSinceEpoch;
final imported = <String>{};
for (final member in members) {
final pin = member.pin.trim();
// A blank or malformed PIN cannot be signed in with. Skipped rather than
// written, so the till does not show a name nobody can use.
if (pin.length < 4 || int.tryParse(pin) == null) continue;
final salt = PinHasher.newSalt();
imported.add(member.localId);
await db.insert(
Tables.staff,
{
'id': member.localId,
'name': member.name.isEmpty ? 'Staff ${member.localId}' : member.name,
'role': member.role.name,
'pin_hash': PinHasher.hash(pin, salt),
'pin_salt': salt,
// Not flagged for change: this PIN was set by the shop in the back
// office, so it is already theirs. The flag is for the seeds.
'must_change_pin': 0,
'is_active': 1,
'created_at': now,
'updated_at': now,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
// Nothing usable came back — leave the till exactly as it was rather than
// stranding it with no way to sign in.
if (imported.isEmpty) return 0;
final placeholders = List.filled(imported.length, '?').join(',');
await db.update(
Tables.staff,
{'is_active': 0, 'updated_at': now},
where: 'id NOT IN ($placeholders)',
whereArgs: imported.toList(),
);
return imported.length;
}
}
/// One person to import, already mapped onto the till's own role vocabulary.
class StaffImportRecord {
const StaffImportRecord({
required this.localId,
required this.name,
required this.role,
required this.pin,
});
final String localId;
final String name;
final StaffRole role;
final String pin;
}

View File

@@ -0,0 +1,87 @@
import '../../core/security/pin_hasher.dart';
import '../../domain/entities/store_account.dart';
import 'app_database.dart';
import 'catalogue_dao.dart';
import 'staff_dao.dart';
/// The PIN that authorises removing a rung item from a bill.
///
/// Set once by an admin and handed to whoever is on the counter, so a cashier
/// can void a line without an admin walking over. It is a *separate* secret
/// from staff PINs on purpose: a staff PIN identifies a person and is what
/// stamps a bill, and sharing one to allow voids would put every sale that
/// shift under the wrong name.
///
/// Stored hashed with its own salt, never in the clear. Until an admin sets
/// one, [verify] falls back to any admin's staff PIN — a terminal that cannot
/// void at all is worse than one that needs the admin present.
class VoidPinStore {
const VoidPinStore(this._meta, this._staff);
final CatalogueDao _meta;
final StaffDao _staff;
/// Whether an admin has set a dedicated removal PIN on this terminal.
///
/// Empty counts as absent: [clearPin] blanks the row rather than deleting
/// it, so a null check alone would report a cleared PIN as still set.
Future<bool> get isConfigured async {
final hash = await _meta.meta(MetaKeys.voidPinHash);
return hash != null && hash.isNotEmpty;
}
Future<void> setPin(String pin) async {
_assertAcceptable(pin);
final salt = PinHasher.newSalt();
await _meta.setMeta(MetaKeys.voidPinHash, PinHasher.hash(pin, salt));
await _meta.setMeta(MetaKeys.voidPinSalt, salt);
}
/// Drops the dedicated PIN, returning the terminal to admin-PIN-only voids.
Future<void> clearPin() async {
await _meta.setMeta(MetaKeys.voidPinHash, '');
await _meta.setMeta(MetaKeys.voidPinSalt, '');
}
/// True when [pin] may authorise a removal.
///
/// Checks the dedicated PIN first, then admin staff PINs. An admin's own PIN
/// always works, so setting a removal PIN never locks the owner out of their
/// own till.
Future<bool> verify(String pin) async {
final hash = await _meta.meta(MetaKeys.voidPinHash);
final salt = await _meta.meta(MetaKeys.voidPinSalt);
if (hash != null && hash.isNotEmpty && salt != null && salt.isNotEmpty) {
if (PinHasher.verify(pin, salt: salt, hash: hash)) return true;
}
final user = await _staff.authenticate(pin);
return user != null && user.role == StaffRole.admin;
}
/// Same rule the staff PINs use, for the same reason: these are typed on a
/// keypad behind a counter, in front of a queue.
static void _assertAcceptable(String pin) {
if (pin.length < 4 || int.tryParse(pin) == null) {
throw const VoidPinException('A PIN must be at least four digits.');
}
const tooObvious = {'0000', '1111', '2222', '3333', '4444', '5555', '6666',
'7777', '8888', '9999', '1234', '4321', '0123',};
if (tooObvious.contains(pin)) {
throw const VoidPinException(
'That PIN is too easy to guess from across the counter. '
'Choose another.',
);
}
}
}
class VoidPinException implements Exception {
const VoidPinException(this.message);
final String message;
@override
String toString() => message;
}

View File

@@ -156,8 +156,7 @@ class HttpCatalogueSource implements CatalogueSource {
uri,
headers: {
'accept': 'application/json',
if (config.bearerToken != null)
'authorization': 'Bearer ${config.bearerToken}',
if (config.apiKey != null) 'authorization': 'Bearer ${config.apiKey}',
},
).timeout(_timeout);
} on Exception catch (e) {

View File

@@ -86,8 +86,8 @@ class HttpOrderTransport implements OrderTransport {
Uri.parse('${config.httpBaseUrl}/health'),
headers: {
'content-type': 'application/json',
if (config.bearerToken != null)
'authorization': 'Bearer ${config.bearerToken}',
if (config.apiKey != null)
'authorization': 'Bearer ${config.apiKey}',
},
body: payload,
)
@@ -133,8 +133,8 @@ class HttpOrderTransport implements OrderTransport {
uri,
headers: {
'content-type': 'application/json',
if (config.bearerToken != null)
'authorization': 'Bearer ${config.bearerToken}',
if (config.apiKey != null)
'authorization': 'Bearer ${config.apiKey}',
'idempotency-key': batchId,
},
body: jsonEncode({

View File

@@ -1,149 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../domain/entities/pos_session.dart';
/// Raised when the back office refuses or cannot answer a sign-in.
///
/// Carries a message meant to be shown to whoever is standing at the till, so
/// it is written for them rather than for a log: what happened, and what they
/// can do about it.
class PosAuthException implements Exception {
const PosAuthException(this.message, {this.isCredentialFailure = false});
final String message;
/// Whether the details were wrong, as opposed to the back office being
/// unreachable. The till reacts differently: a bad password is worth
/// re-typing, an unreachable server is worth waiting for.
final bool isCredentialFailure;
@override
String toString() => message;
}
/// Signs a terminal in against the back office.
///
/// Talks to the same `app_users` accounts as the web console, so a manager who
/// can open the back office can open the till with the same details — one
/// account store means deactivating a leaver closes both doors at once.
///
/// ```
/// POST {base}/login
/// { "authname": "…", "password": "…", "terminal_id": "T5EDD" }
/// ```
///
/// answered with `{ code, status, details: { token, store_id, locations, … } }`.
class PosAuthApi {
PosAuthApi({required this.baseUrl, http.Client? client})
: _client = client ?? http.Client();
final String baseUrl;
final http.Client _client;
/// Generous, because this runs on a shop's connection while somebody watches.
/// Short enough that a dead endpoint is reported rather than hung on.
static const _timeout = Duration(seconds: 20);
/// Exchanges credentials for a session.
///
/// [locationId] is only meaningful for an account entitled to several
/// outlets: it says which one this terminal is standing in. It is a request,
/// not an assertion — the back office checks it against what the account may
/// actually reach, and that check is the whole point of the endpoint.
Future<PosSession> login({
required String authname,
required String password,
String? terminalId,
String? deviceId,
int? locationId,
int? configId,
}) async {
if (baseUrl.isEmpty) {
throw const PosAuthException(
'This terminal has no back office configured. Set the endpoint in '
'Settings → Connectivity & sync.',
);
}
final body = <String, Object?>{
'authname': authname.trim(),
'password': password,
if (terminalId != null && terminalId.isNotEmpty) 'terminal_id': terminalId,
if (deviceId != null && deviceId.isNotEmpty) 'device_id': deviceId,
if (locationId != null && locationId > 0) 'location_id': locationId,
// Sent only when known. The backend infers it when absent, and a shop
// has no way to find out what its configid is.
if (configId != null && configId > 0) 'configid': configId,
};
final http.Response response;
try {
response = await _client
.post(
Uri.parse('$baseUrl/login'),
headers: const {'Content-Type': 'application/json'},
body: jsonEncode(body),
)
.timeout(_timeout);
} on TimeoutException {
throw const PosAuthException(
'The back office did not answer in time. Check the connection and try '
'again.',
);
} on Object {
throw const PosAuthException(
'Could not reach the back office. Check the connection and try again.',
);
}
Map<String, Object?> decoded;
try {
decoded = jsonDecode(response.body) as Map<String, Object?>;
} on Object {
throw PosAuthException(
'The back office answered with something this terminal could not read '
'(HTTP ${response.statusCode}).',
);
}
if (response.statusCode != 200) {
throw PosAuthException(
(decoded['message'] as String?) ??
'Sign-in was refused (HTTP ${response.statusCode}).',
// 401 is a wrong email or password; 403 is a real account that may not
// open this till. Only the first is worth re-typing.
isCredentialFailure: response.statusCode == 401,
);
}
final details = decoded['details'];
if (details is! Map<String, Object?>) {
throw const PosAuthException(
'The back office accepted the sign-in but returned no session.',
);
}
final session = PosSession.fromJson(details);
// A session with no token cannot authenticate anything, and one with no
// outlet cannot bill. Refused here rather than being saved and failing
// later against every request, which would be much harder to diagnose.
if (session.token.isEmpty) {
throw const PosAuthException(
'The back office returned a session with no token.',
);
}
if (session.locationId <= 0) {
throw const PosAuthException(
'This account is not attached to an outlet, so it cannot open a till.',
);
}
return session;
}
void dispose() => _client.close();
}

View File

@@ -178,6 +178,7 @@ class SyncRepositoryImpl implements SyncRepository {
createdAt:
DateTime.fromMillisecondsSinceEpoch(r['created_at']! as int),
isSynced: (r['sync_status']! as int) == OrderDao.synced,
itemCount: (r['item_count'] as num?)?.toDouble() ?? 0,
syncedAt: r['synced_at'] == null
? null
: DateTime.fromMillisecondsSinceEpoch(r['synced_at']! as int),