diff --git a/lib/core/utils/formatters.dart b/lib/core/utils/formatters.dart index c10efdf..c0ce107 100644 --- a/lib/core/utils/formatters.dart +++ b/lib/core/utils/formatters.dart @@ -83,4 +83,35 @@ class Formatters { : '$terminalCode-'; return 'INV-$y$m-$code$seq'; } + + /// A timestamp the back office cannot misread, with its UTC offset attached. + /// + /// `DateTime.toIso8601String()` on a local time emits no zone marker at all — + /// `2026-08-05T12:49:28.245`. That is not wrong, it is *silent*, and the + /// receiver has to guess. Go's `time.Parse` guesses UTC, so a bill rung at + /// 12:49 in Coimbatore was stored as 12:49 UTC: five and a half hours in the + /// future, and reading as *later than the moment it was received*. + /// + /// The daily figures survived that by luck. `businessdate` is derived from + /// the wall clock either way, and the wall clock was always the till's own — + /// so a day's takings landed on the right day even while the instant was + /// wrong. Anything comparing `billedat` to real time did not survive it. + /// + /// Emitting the offset ends the guessing: `2026-08-05T12:49:28.245+05:30` + /// parses to the correct instant *and* still formats to the correct local + /// date, so both readings stay right. + static String isoWithOffset(DateTime time) { + final local = time.toLocal(); + final offset = local.timeZoneOffset; + + final sign = offset.isNegative ? '-' : '+'; + final magnitude = offset.abs(); + final hours = magnitude.inHours.toString().padLeft(2, '0'); + // India is +05:30, so the minutes are load-bearing here in a way they are + // not in a whole-hour zone. Taken from the total rather than assumed zero. + final minutes = + (magnitude.inMinutes % 60).toString().padLeft(2, '0'); + + return '${local.toIso8601String()}$sign$hours:$minutes'; + } } diff --git a/lib/data/repositories/sync_repository_impl.dart b/lib/data/repositories/sync_repository_impl.dart index 7297a7f..d865c58 100644 --- a/lib/data/repositories/sync_repository_impl.dart +++ b/lib/data/repositories/sync_repository_impl.dart @@ -433,7 +433,7 @@ class SyncRepositoryImpl implements SyncRepository { 'email': c.email, 'gender': c.gender.name, 'date_of_birth': c.dateOfBirth?.toIso8601String(), - 'registered_at': c.createdAt?.toIso8601String(), + 'registered_at': _iso(c.createdAt), 'registered_by_terminal': _store.terminal.code, }; @@ -442,7 +442,7 @@ class SyncRepositoryImpl implements SyncRepository { Map _orderToPayload(SaleTransaction t) => { 'id': t.id, 'invoice_number': t.invoiceNumber, - 'created_at': t.createdAt.toIso8601String(), + 'created_at': Formatters.isoWithOffset(t.createdAt), 'cashier': t.cashierName, 'customer': t.customer == null ? null @@ -491,3 +491,12 @@ class SyncRepositoryImpl implements SyncRepository { ], }; } + +/// [Formatters.isoWithOffset], tolerating a null. +/// +/// Several of these are genuinely absent — a till that has never uploaded has +/// no last upload, and one with an empty queue has no oldest pending bill. +/// Omitting the key is the honest answer; sending an epoch would put 1970 on a +/// dashboard and read as a real reading. +String? _iso(DateTime? time) => + time == null ? null : Formatters.isoWithOffset(time); diff --git a/lib/data/sync/health_reporter.dart b/lib/data/sync/health_reporter.dart index 9a9b4b2..2faecbd 100644 --- a/lib/data/sync/health_reporter.dart +++ b/lib/data/sync/health_reporter.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; import '../../core/config/sync_config.dart'; +import '../../core/utils/formatters.dart'; import '../../domain/repositories/sync_repository.dart'; import '../local/terminal_identity.dart'; import '../remote/order_transport.dart'; @@ -125,19 +126,19 @@ class HealthReporter { // Queue depth — the number that makes a silent failure visible. 'pending_bills': pendingBills, 'pending_registrations': pendingRegistrations, - 'oldest_pending_at': (await _oldestPendingAt())?.toIso8601String(), + 'oldest_pending_at': _iso(await _oldestPendingAt()), 'sync_halted': state.isHalted, 'sync_error': state.lastError, - 'last_upload_at': state.lastSuccessAt?.toIso8601String(), + 'last_upload_at': _iso(state.lastSuccessAt), // Today's trading. A till that is connected but has rung nothing in // three hours is usually a jammed printer or an absent cashier, and // neither shows up on an online/offline board. 'today_bills': today.billCount, 'today_amount': today.grossSales, - 'last_bill_at': today.lastBillAt?.toIso8601String(), + 'last_bill_at': _iso(today.lastBillAt), - 'reported_at': _now().toIso8601String(), + 'reported_at': Formatters.isoWithOffset(_now()), }; final device = await _collectDeviceState(); @@ -214,3 +215,12 @@ class HealthReporter { _timer?.cancel(); } } + +/// [Formatters.isoWithOffset], tolerating a null. +/// +/// Several of these are genuinely absent — a till that has never uploaded has +/// no last upload, and one with an empty queue has no oldest pending bill. +/// Omitting the key is the honest answer; sending an epoch would put 1970 on a +/// dashboard and read as a real reading. +String? _iso(DateTime? time) => + time == null ? null : Formatters.isoWithOffset(time); diff --git a/test/unit/timestamp_offset_test.dart b/test/unit/timestamp_offset_test.dart new file mode 100644 index 0000000..974dcaa --- /dev/null +++ b/test/unit/timestamp_offset_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:nearle_pos/core/utils/formatters.dart'; + +/// The fault these cover was found in live data, not in a test. +/// +/// Bill INV-2608-T5EDD-00116 carried `billedat 2026-08-05T12:49:28.245Z` beside +/// `receivedat 2026-08-05T07:19:28.586Z` — the bill appearing to have been rung +/// five and a half hours *after* the back office received it. Exactly the IST +/// offset, every time. +/// +/// Nothing was lying. `DateTime.toIso8601String()` on a local time emits no +/// zone marker at all, Go's `time.Parse` fills that silence with UTC, and the +/// till's wall clock was recorded as though it had been read in London. +void main() { + group('a timestamp says which zone it was read in', () { + test('carries an explicit offset', () { + final iso = Formatters.isoWithOffset(DateTime(2026, 8, 5, 12, 49, 28)); + + expect( + iso, + matches(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.*[+-]\d{2}:\d{2}$'), + reason: 'without an offset the receiver has to guess, and guesses UTC', + ); + }); + + test('round-trips back to the same instant', () { + // The whole point. The old form parsed to a different moment than the one + // the cashier rang the bill at. + final rung = DateTime(2026, 8, 5, 12, 49, 28, 245); + + final parsed = DateTime.parse(Formatters.isoWithOffset(rung)); + + expect( + parsed.toUtc(), + rung.toUtc(), + reason: 'the instant must survive the trip to the back office', + ); + }); + + test('keeps the wall clock the till actually showed', () { + // Load-bearing for businessdate. The back office derives a day's takings + // from the wall clock, so a bill rung at 12:49 must still read 12:49 — + // converting to UTC before sending would have moved it to 07:19 and put + // late-evening sales on the previous day. + final iso = Formatters.isoWithOffset(DateTime(2026, 8, 5, 12, 49, 28)); + + expect(iso, startsWith('2026-08-05T12:49:28')); + }); + + test('does not drop a half-hour offset', () { + // India is +05:30. An implementation that formatted only whole hours + // would produce +05:00 here and be wrong by thirty minutes — the kind of + // error that survives review because it looks almost right. + final offset = DateTime.now().timeZoneOffset; + final iso = Formatters.isoWithOffset(DateTime(2026, 8, 5, 12, 0)); + + final minutes = offset.abs().inMinutes % 60; + expect( + iso.substring(iso.length - 2), + minutes.toString().padLeft(2, '0'), + reason: 'the minutes component must come from the real offset', + ); + }); + + test('a UTC input is converted, not relabelled', () { + // Passing an already-UTC DateTime must not stamp it with the local + // offset while leaving the UTC wall clock in place — that would recreate + // the original bug in reverse. + final instant = DateTime.utc(2026, 8, 5, 7, 19, 28); + + final parsed = DateTime.parse(Formatters.isoWithOffset(instant)); + + expect(parsed.toUtc(), instant); + }); + }); +}