pos changes
This commit is contained in:
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
87
lib/data/local/void_pin_store.dart
Normal file
87
lib/data/local/void_pin_store.dart
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user