Files
nearle_pos/lib/domain/entities/pos_session.dart
Suriya 4f9a5c3d6b Take staff from the back office, and let the seeded PINs die when it has any
Suriya/4821, Divya/5093, Rahul/6274 were compiled into the app — the same three
logins on every install, readable by anyone with the APK, and unreplaceable.

Sign-in now imports the outlet's real staff and deactivates everything it
didn't import, so the built-in PINs stop working the moment a shop has anyone
recorded. That deactivation is the point: merging would have left the hardcoded
logins alive alongside the real ones for ever.

The seeds stay, and that is not a hedge. Only 116 of 596 accounts on the
platform have a PIN set, and outlet 1135 — the one this build ships pointed at
— has none at all. Deleting them would hand 33 of 34 tenants a till nobody can
sign in to. So: back office first, local database once synced, seeds only when
there is nothing else.

Rows are keyed on the back office user id, so a re-sync updates one account
rather than creating a second. A leaver removed upstream loses the till on the
next sign-in. Accounts are deactivated rather than deleted, because bills carry
the cashier's name and shifts settle against it.

An import that writes nobody is treated exactly like an empty answer — a back
office full of `pin = 0` rows must not deactivate the seeds and strand the
counter. That is a real shape in the data, not a hypothetical.

An imported PIN is not flagged for change; the shop already chose it. The flag
belongs to the seeds, which everyone shares.

Role names are mapped by name and fall back to cashier. `app_roles` holds six
rows for four roles — Admin and Manager appear twice each — and most accounts
carry a roleid absent from the table entirely, so an unrecognised role must not
quietly become an admin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 16:02:04 +05:30

254 lines
8.7 KiB
Dart

/// 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 [],
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;
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<PosOutlet> 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<PosStaffMember> 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,
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<String, Object?> json) {
final outlets = (json['locations'] as List<Object?>? ?? const [])
.whereType<Map<String, Object?>>()
.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,
staff: (json['staff'] as List<Object?>? ?? const [])
.whereType<Map<String, Object?>>()
.map(PosStaffMember.fromJson)
.where((m) => m.pin.isNotEmpty)
.toList(),
);
}
Map<String, Object?> 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(),
'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<String, Object?> json) => PosOutlet(
locationId: _int(json['location_id']),
locationName: (json['location_name'] as String?) ?? '',
address: (json['address'] as String?) ?? '',
city: (json['city'] as String?) ?? '',
);
Map<String, Object?> 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<String, Object?> 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<String, Object?> toJson() => {
'user_id': userId,
'full_name': fullName,
'pin': pin,
'role': role,
};
}