Send timestamps with the offset they were read in
Live bill INV-2608-T5EDD-00116 carried billedat 2026-08-05T12:49:28Z beside receivedat 2026-08-05T07:19:28Z — the sale appearing to have been rung five and a half hours after the back office received it. Exactly the IST offset, on every bill. Nothing was lying. DateTime.toIso8601String() on a local time emits no zone marker at all, and Go's time.Parse fills that silence with UTC, so a Coimbatore wall clock was recorded as though it had been read in London. The daily figures survived 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 against real time did not. Formatters.isoWithOffset attaches the offset, which fixes both readings at once — the instant parses correctly and the local date still formats correctly. The minutes come from the real offset rather than being assumed zero, because India is +05:30 and a whole-hour implementation would be wrong in a way that looks almost right. Applied to all six timestamps the till sends. date_of_birth is left alone: a birthday is a date, not an instant, and giving it a zone would be meaningless. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, Object?> _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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
76
test/unit/timestamp_offset_test.dart
Normal file
76
test/unit/timestamp_offset_test.dart
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user