/// 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.gstin = '', this.address = '', this.phone = '', this.outlets = 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; 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; /// 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, 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, ); 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']), 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, ); } Map toJson() => { 'token': token, 'expires_at': expiresAt.toIso8601String(), 'user_id': userId, 'full_name': fullName, 'email': email, 'role_id': roleId, '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(), }; } /// 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, };