/// What the back office answers a sign-in with. /// /// Replaces the arrangement where a till held a store id typed into Settings /// and a password compiled into the app. That made the store id a *claim*: any /// terminal could name any outlet and be believed, so one leaked build reached /// every tenant on the platform. /// /// Now the outlet arrives *from* the back office as a consequence of who signed /// in, sealed inside a signed token the terminal cannot edit. The till stops /// deciding which shop it belongs to and starts being told. class PosSession { const PosSession({ required this.token, required this.expiresAt, required this.userId, required this.fullName, required this.roleId, required this.tenantId, required this.tenantName, required this.storeId, required this.locationId, required this.locationName, this.email = '', this.role = '', this.canManageStaff = false, this.gstin = '', this.address = '', this.phone = '', this.outlets = const [], this.staff = const [], }); /// The bearer token, sent on every request from here on. /// /// Opaque on purpose. The terminal must not parse it, reason about it, or /// trust anything it appears to say — its only correct use is to hand it back /// and let the server decide what it means. final String token; final DateTime expiresAt; final int userId; final String fullName; final String email; final int roleId; /// The back office's name for [roleId] — "Supervisor", "Cashier", "Admin". /// /// For display. Never branch on it: `app_roles` holds six rows for four /// distinct roles, and a great many accounts carry a `roleid` that is not in /// the table at all and come back blank. [canManageStaff] is the flag to /// read. final String role; /// Whether this account runs the terminal or only bills on it. /// /// The one permission the till acts on, and it is answered by the back office /// rather than worked out here. A supervisor gets the full shell; a cashier /// gets the billing screen. Deciding it locally would mean shipping a copy of /// the role table in the app and keeping the two in step for ever. /// /// Defaults to false, which is the least-privileged answer. That matters on /// the restore path: a session saved by a build that predates this field /// comes back as a cashier rather than silently as an admin. final bool canManageStaff; final int tenantId; final String tenantName; /// The outlet this terminal bills for, as a string because that is the shape /// the sync configuration and every uplink already use. final String storeId; final int locationId; final String locationName; /// Printed on the invoice, so a legal requirement rather than decoration. /// Arriving with the session means a shop that corrects its GSTIN in the back /// office sees it on the next receipt instead of at the next rebuild. final String gstin; final String address; final String phone; /// Every outlet this account may open a till at. /// /// A single-shop user gets a list of one, so the sign-in flow has no special /// case: it offers a choice when there is one and skips it when there is not. final List outlets; bool get hasChoiceOfOutlet => outlets.length > 1; /// The people the back office says may ring a bill here. /// /// Very often empty. Only 116 of 596 accounts on the platform have a PIN set, /// and most outlets — including the one this terminal ships pointed at — have /// none at all. A till must treat that as an unfinished setup rather than as /// a failure, which is why the seeded accounts still exist as a last resort. final List staff; /// Whether the session is still worth sending. /// /// Checked against the terminal's own clock, which is the only one available /// offline. A till whose clock is wrong will re-authenticate unnecessarily — /// annoying, and much better than billing a whole day against a session the /// server has already stopped accepting. bool isValidAt(DateTime now) => now.isBefore(expiresAt); PosSession copyWith({ String? storeId, int? locationId, String? locationName, String? address, }) => PosSession( token: token, expiresAt: expiresAt, userId: userId, fullName: fullName, email: email, roleId: roleId, role: role, canManageStaff: canManageStaff, tenantId: tenantId, tenantName: tenantName, storeId: storeId ?? this.storeId, locationId: locationId ?? this.locationId, locationName: locationName ?? this.locationName, gstin: gstin, address: address ?? this.address, phone: phone, outlets: outlets, staff: staff, ); factory PosSession.fromJson(Map json) { final outlets = (json['locations'] as List? ?? const []) .whereType>() .map(PosOutlet.fromJson) .toList(); return PosSession( token: (json['token'] as String?) ?? '', // A session with no readable expiry is treated as already finished rather // than as never finishing. Guessing "valid" here would keep a till // sending a token the server stopped honouring hours ago. expiresAt: DateTime.tryParse((json['expires_at'] as String?) ?? '') ?.toLocal() ?? DateTime.fromMillisecondsSinceEpoch(0), userId: _int(json['user_id']), fullName: (json['full_name'] as String?)?.trim() ?? '', email: (json['email'] as String?) ?? '', roleId: _int(json['role_id']), role: ((json['role'] as String?) ?? '').trim(), canManageStaff: json['can_manage_staff'] == true, tenantId: _int(json['tenant_id']), tenantName: (json['tenant_name'] as String?) ?? '', storeId: (json['store_id'] as String?) ?? '${_int(json['location_id'])}', locationId: _int(json['location_id']), locationName: (json['location_name'] as String?) ?? '', gstin: (json['gstin'] as String?) ?? '', address: (json['address'] as String?) ?? '', phone: (json['phone'] as String?) ?? '', outlets: outlets, staff: (json['staff'] as List? ?? const []) .whereType>() .map(PosStaffMember.fromJson) .where((m) => m.pin.isNotEmpty) .toList(), ); } Map toJson() => { 'token': token, 'expires_at': expiresAt.toIso8601String(), 'user_id': userId, 'full_name': fullName, 'email': email, 'role_id': roleId, 'role': role, 'can_manage_staff': canManageStaff, 'tenant_id': tenantId, 'tenant_name': tenantName, 'store_id': storeId, 'location_id': locationId, 'location_name': locationName, 'gstin': gstin, 'address': address, 'phone': phone, 'locations': outlets.map((o) => o.toJson()).toList(), 'staff': staff.map((m) => m.toJson()).toList(), }; } /// One outlet a signed-in account may bill for. class PosOutlet { const PosOutlet({ required this.locationId, required this.locationName, this.address = '', this.city = '', }); final int locationId; final String locationName; final String address; final String city; String get storeId => '$locationId'; factory PosOutlet.fromJson(Map json) => PosOutlet( locationId: _int(json['location_id']), locationName: (json['location_name'] as String?) ?? '', address: (json['address'] as String?) ?? '', city: (json['city'] as String?) ?? '', ); Map toJson() => { 'location_id': locationId, 'location_name': locationName, 'address': address, 'city': city, }; } /// Reads an id that may arrive as a number or as a string. /// /// The backend sends `location_id` as an int and `store_id` as a string for the /// same value, and a till that accepted only one shape would silently read zero /// for the other — which looks like "no outlet" rather than like a bug. int _int(Object? value) => switch (value) { final int v => v, final num v => v.toInt(), final String v => int.tryParse(v.trim()) ?? 0, _ => 0, }; /// One person the back office says may ring a bill at this outlet. /// /// The PIN arrives in the clear over TLS and is hashed before it touches disk — /// see [PosSession] and the backend's `PosStaffMember` for why hashing it /// server-side would have bought the appearance of strength and not the /// substance. A four-digit PIN is brute-forceable in microseconds regardless; /// what it is, is *shift attribution* — which of the people already inside a /// shop gets credited with a sale. The security boundary is the session token. class PosStaffMember { const PosStaffMember({ required this.userId, required this.fullName, required this.pin, this.role = '', }); final int userId; final String fullName; final String pin; /// The back office's own role name — "Admin", "Manager", "Operations". Blank /// for the many accounts whose `roleid` is not in `app_roles` at all. final String role; /// A stable local id for a row that came from the back office. /// /// Prefixed so an imported account can be told apart from one seeded on this /// device. That distinction is what lets a sync retire the seeded logins /// without touching anything a shop created itself. String get localId => 'boffice-$userId'; factory PosStaffMember.fromJson(Map json) => PosStaffMember( userId: _int(json['user_id']), fullName: ((json['full_name'] as String?) ?? '').trim(), pin: ((json['pin'] as String?) ?? '').trim(), role: ((json['role'] as String?) ?? '').trim(), ); Map toJson() => { 'user_id': userId, 'full_name': fullName, 'pin': pin, 'role': role, }; }