Fix billing data integrity, sale atomicity and stock safety

Bills were persisted correctly but read back wrong. The read path rebuilt a
cart from its lines alone, dropping bill-level discounts and loyalty, so every
figure derived from a stored bill was overstated: the upload payload, the day
archive and the shift report. A discounted 529 bill read back as 620.

Money and data integrity
- order_dao: restore bill_discount and points_redeemed when rebuilding a cart;
  keep the reconstruction tier-less so the membership discount is not applied
  twice. Trust the recorded total and points via SaleTransaction.storedTotal.
- checkout_sale + order_dao.commitSale: write the bill, its stock movement and
  the loyalty update in one transaction. Previously a failure part-way through
  left a persisted bill the cashier believed had failed, inviting a duplicate.
- checkout_sale: re-check every line against live stock. A parked bill resumed
  after its stock was sold passed validation and oversold.
- catalogue_dao: allocate the invoice sequence in one transaction; the previous
  read-modify-write could hand two sales the same number and fail UNIQUE.
- local_store: replay unsynced sales after a catalogue import, so a mid-shift
  re-import cannot restore stock that has already been sold.
- payment_controller: stamp the signed-in operator on the bill instead of the
  hardcoded seed session, and pass the terminal id through.
- cart: reconcile per-slab GST against the bill total so the parts sum to the
  whole on a tax invoice.

Sync and reporting
- sync_repository: drain unsynced bills in a loop rather than silently capping
  at one page; stop on rejection so rejected rows cannot loop forever.
- sync_log_dao (new): persist the sync history to the sync_log table, which the
  schema already defined but nothing used. It was in memory, so the only record
  that bills had been uploaded died at restart.
- Scope shift reports by cashier. day_archive is re-keyed to
  (business_date, cashier_name) so a till stays settleable after its bills are
  uploaded and deleted. Schema v4 with a migration that carries v3 rows across.

Input and UI
- barcode_service: consume machine-paced keystrokes so a scan cannot also land
  in the focused field, and raise the bar to 60ms/char while a text field has
  focus so typing a mobile number is not read as a scan. Clock and focus check
  injected so the behaviour is testable.
- primary_button: make the label flexible; label plus trailing total overflowed
  the Charge button by up to 131px.
- app_router: redirect instead of null-casting when the receipt route is
  entered without its transaction.
- customer_repository: reduce the search query to digits so a punctuated mobile
  number matches.

Cleanup
- Remove TransactionRepository.save, CustomerRepository.recordSale and
  OrderDao.insertOrder, all superseded by commitSale.
- dart fix across the tree; 251 analyzer issues down to 3 info-level.

Tests: 23 passing / 15 failing -> 90 passing. Fixed the two defects that broke
the existing suite (containsAll type argument, reset() needing a catalogue) and
deleted the leftover template test. Added coverage for the order round trip,
the day archive after a real sync, stock safety, checkout atomicity, the v3->v4
migration, scanner-versus-human input, and an app-level smoke test that renders
every module.

Note: bills already uploaded with a discount went up overstated. This stops it
happening again but does not correct historical server data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Suriya
2026-07-31 18:34:10 +05:30
parent 9891a69a5f
commit af3933092f
62 changed files with 2035 additions and 561 deletions

View File

@@ -68,6 +68,11 @@ final routerProvider = Provider<GoRouter>((ref) {
GoRoute(
path: AppRoutes.receipt,
name: 'receipt',
// The bill travels in `extra`, which does not survive a reload, a deep
// link or a restored session. Sending the cashier back to billing beats
// crashing on a null cast.
redirect: (context, state) =>
state.extra is SaleTransaction ? null : AppRoutes.pos,
pageBuilder: (context, state) => _fade(
state,
ReceiptScreen(transaction: state.extra! as SaleTransaction),

View File

@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../constants/app_constants.dart';
@@ -11,17 +12,39 @@ import '../constants/app_constants.dart';
/// when the characters arrived faster than a human could type — that way the
/// cashier can still type into the same field by hand.
class BarcodeService {
BarcodeService({this.onScan, this.onManualKey});
BarcodeService({
this.onScan,
this.onManualKey,
DateTime Function()? clock,
bool Function()? isEditingText,
}) : _clock = clock ?? DateTime.now,
_isEditingText = isEditingText ?? _primaryFocusIsTextField;
final void Function(String code)? onScan;
final VoidCallback? onManualKey;
/// Injectable so tests can drive keystroke timing exactly rather than
/// sleeping, which is what makes scanner-versus-human behaviour testable.
final DateTime Function() _clock;
final bool Function() _isEditingText;
final StringBuffer _buffer = StringBuffer();
DateTime? _lastKeyAt;
DateTime? _firstKeyAt;
int _charCount = 0;
/// Cleared when any gap in the buffer is too slow to be a machine.
bool _machinePaced = true;
Timer? _flushTimer;
bool _attached = false;
/// Sustained pace no typist holds. Applied when a text field has focus, where
/// the cost of a false positive is a swallowed keystroke rather than nothing.
static Duration get _strictGap =>
Duration(microseconds: AppConstants.barcodeScanTimeout.inMicroseconds ~/ 2);
void attach() {
if (_attached) return;
HardwareKeyboard.instance.addHandler(_handleKey);
@@ -35,22 +58,44 @@ class BarcodeService {
_attached = false;
}
/// Whether the cashier is typing into a field right now.
///
/// A scan still registers while a field has focus, but the bar for calling it
/// a scan is raised, because getting it wrong means eating real keystrokes.
static bool _primaryFocusIsTextField() {
final context = FocusManager.instance.primaryFocus?.context;
if (context == null) return false;
return context.widget is EditableText ||
context.findAncestorWidgetOfExactType<EditableText>() != null;
}
void _resetBuffer() {
_buffer.clear();
_firstKeyAt = null;
_charCount = 0;
_machinePaced = true;
}
/// The key handler, exposed so it can be driven directly in tests.
@visibleForTesting
bool handleKey(KeyEvent event) => _handleKey(event);
bool _handleKey(KeyEvent event) {
if (event is! KeyDownEvent) return false;
final now = DateTime.now();
final gap = _lastKeyAt == null
? Duration.zero
: now.difference(_lastKeyAt!);
final now = _clock();
final gap = _lastKeyAt == null ? Duration.zero : now.difference(_lastKeyAt!);
_lastKeyAt = now;
// A long pause means a new entry started; discard whatever was buffered.
if (gap > AppConstants.barcodeScanTimeout) {
_buffer.clear();
_resetBuffer();
}
if (event.logicalKey == LogicalKeyboardKey.enter ||
event.logicalKey == LogicalKeyboardKey.numpadEnter) {
// Only swallow Enter if the buffer actually resolves to a scan —
// otherwise a form submit on a hand-typed field would be eaten.
return _flush();
}
@@ -58,9 +103,19 @@ class BarcodeService {
if (char == null || char.trim().isEmpty) return false;
if (!RegExp(r'^[0-9A-Za-z\-]$').hasMatch(char)) return false;
if (_charCount > 0 && gap > AppConstants.barcodeScanTimeout) {
_machinePaced = false;
}
_buffer.write(char);
_firstKeyAt ??= now;
_charCount++;
_scheduleFlush();
return false;
// Consume the keystroke once the burst is unmistakably machine-paced.
// Without this the scanned digits also land in whatever field has focus,
// so a scan would add the item *and* type the barcode into the search box.
return _looksLikeScan(atLeast: 2);
}
/// Some scanners are not configured to send a terminating Enter, so we also
@@ -69,16 +124,31 @@ class BarcodeService {
_flushTimer?.cancel();
_flushTimer = Timer(
AppConstants.barcodeScanTimeout * 2,
() => _flush(),
_flush,
);
}
/// Whether the buffer so far has the shape and speed of a scanner burst.
bool _looksLikeScan({required int atLeast}) {
if (_charCount < atLeast || !_machinePaced) return false;
final first = _firstKeyAt;
if (first == null || _charCount < 2) return false;
final span = _lastKeyAt!.difference(first);
final averageGap = span ~/ (_charCount - 1);
return averageGap <=
(_isEditingText() ? _strictGap : AppConstants.barcodeScanTimeout);
}
bool _flush() {
_flushTimer?.cancel();
final code = _buffer.toString().trim();
_buffer.clear();
final wasScan = _looksLikeScan(atLeast: AppConstants.minBarcodeLength);
_resetBuffer();
if (code.length >= AppConstants.minBarcodeLength) {
if (wasScan && code.length >= AppConstants.minBarcodeLength) {
onScan?.call(code);
return true;
}

View File

@@ -1,4 +1,3 @@
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:pdf/pdf.dart';
@@ -58,7 +57,7 @@ class ReceiptService {
doc.addPage(
pw.Page(
pageFormat: PdfPageFormat(
pageFormat: const PdfPageFormat(
_rollWidth,
double.infinity,
marginAll: 5 * PdfPageFormat.mm,
@@ -94,23 +93,23 @@ class ReceiptService {
pw.Widget _header(SaleTransaction txn) => pw.Column(children: [
pw.Text(
AppConstants.storeName.toUpperCase(),
style: pw.TextStyle(fontSize: 15, fontWeight: pw.FontWeight.bold),
style: const pw.TextStyle(fontSize: 15, fontWeight: pw.FontWeight.bold),
),
pw.Text(AppConstants.storeLegalName,
style: const pw.TextStyle(fontSize: 7)),
style: const pw.TextStyle(fontSize: 7),),
pw.SizedBox(height: 2),
pw.Text(AppConstants.storeAddress,
style: const pw.TextStyle(fontSize: 6.5),
textAlign: pw.TextAlign.center),
textAlign: pw.TextAlign.center,),
pw.Text('Customer care : ${AppConstants.storePhone}',
style: const pw.TextStyle(fontSize: 6.5)),
style: const pw.TextStyle(fontSize: 6.5),),
pw.Text('CIN No : ${AppConstants.storeCin}',
style: const pw.TextStyle(fontSize: 6.5)),
style: const pw.TextStyle(fontSize: 6.5),),
pw.Text('GSTIN : ${AppConstants.storeGstin}',
style: const pw.TextStyle(fontSize: 6.5)),
style: const pw.TextStyle(fontSize: 6.5),),
pw.Text('FSSAI Lic No : ${AppConstants.storeFssai}',
style: const pw.TextStyle(fontSize: 6.5)),
]);
style: const pw.TextStyle(fontSize: 6.5),),
],);
pw.Widget _savingsLine(SaleTransaction txn) {
final saved = _grossSalesValue(txn) - txn.cart.netAmount;
@@ -120,7 +119,7 @@ class ReceiptService {
padding: const pw.EdgeInsets.symmetric(vertical: 4),
child: pw.Text(
'You have saved Rs.${saved.toStringAsFixed(2)}',
style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold),
style: const pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold),
textAlign: pw.TextAlign.center,
),
);
@@ -128,11 +127,11 @@ class ReceiptService {
pw.Widget _invoiceTitle() => pw.Column(children: [
pw.Text('TAX INVOICE',
style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold)),
style: const pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold),),
pw.SizedBox(height: 1),
pw.Text('xxxxxx Original for Recipient xxxxxx',
style: const pw.TextStyle(fontSize: 6)),
]);
style: const pw.TextStyle(fontSize: 6),),
],);
pw.Widget _meta(SaleTransaction txn) {
final c = txn.customer;
@@ -167,7 +166,7 @@ class ReceiptService {
pw.Expanded(flex: 16, child: _th('Net Price', right: true)),
pw.Expanded(flex: 8, child: _th('Qty', right: true)),
pw.Expanded(flex: 18, child: _th('Value', right: true)),
]),
],),
pw.SizedBox(height: 2),
for (final slab in slabs) ...[
pw.Padding(
@@ -175,7 +174,7 @@ class ReceiptService {
child: pw.Text(
'${slab.index}) CGST @ ${slab.halfPercent} '
'SGST @ ${slab.halfPercent}',
style: pw.TextStyle(fontSize: 7, fontWeight: pw.FontWeight.bold),
style: const pw.TextStyle(fontSize: 7, fontWeight: pw.FontWeight.bold),
),
),
for (final line in slab.lines)
@@ -193,7 +192,7 @@ class ReceiptService {
pw.Expanded(
flex: 16,
child: _td(line.product.price.toStringAsFixed(2),
right: true, size: 6.5),
right: true, size: 6.5,),
),
pw.Expanded(
flex: 8,
@@ -202,9 +201,9 @@ class ReceiptService {
pw.Expanded(
flex: 18,
child: _td(line.payable.toStringAsFixed(2),
right: true, size: 6.5),
right: true, size: 6.5,),
),
]),
],),
),
],
],
@@ -237,14 +236,14 @@ class ReceiptService {
if (txn.changeDue > 0) _amount('Change Returned', txn.changeDue),
pw.SizedBox(height: 1),
pw.Text('(AMOUNT INCLUSIVE OF APPLICABLE TAXES)',
style: const pw.TextStyle(fontSize: 6)),
]);
style: const pw.TextStyle(fontSize: 6),),
],);
}
pw.Widget _gstBreakup(SaleTransaction txn, List<_Slab> slabs) {
return pw.Column(children: [
pw.Text('------GST Breakup Details------ Amount (INR)',
style: const pw.TextStyle(fontSize: 6.5)),
style: const pw.TextStyle(fontSize: 6.5),),
pw.SizedBox(height: 3),
pw.Row(children: [
pw.Expanded(flex: 10, child: _th('GST\nIND', size: 6)),
@@ -253,7 +252,7 @@ class ReceiptService {
pw.Expanded(flex: 16, child: _th('SGST', right: true, size: 6)),
pw.Expanded(flex: 14, child: _th('CESS', right: true, size: 6)),
pw.Expanded(flex: 20, child: _th('Total\nAmount', right: true, size: 6)),
]),
],),
pw.SizedBox(height: 2),
for (final s in slabs)
pw.Padding(
@@ -263,19 +262,19 @@ class ReceiptService {
pw.Expanded(
flex: 20,
child: _td(s.taxable.toStringAsFixed(2),
right: true, size: 6.5)),
right: true, size: 6.5,),),
pw.Expanded(
flex: 16,
child: _td(s.cgst.toStringAsFixed(2), right: true, size: 6.5)),
child: _td(s.cgst.toStringAsFixed(2), right: true, size: 6.5),),
pw.Expanded(
flex: 16,
child: _td(s.sgst.toStringAsFixed(2), right: true, size: 6.5)),
child: _td(s.sgst.toStringAsFixed(2), right: true, size: 6.5),),
pw.Expanded(flex: 14, child: _td('0.00', right: true, size: 6.5)),
pw.Expanded(
flex: 20,
child:
_td(s.total.toStringAsFixed(2), right: true, size: 6.5)),
]),
_td(s.total.toStringAsFixed(2), right: true, size: 6.5),),
],),
),
pw.Divider(height: 4, borderStyle: pw.BorderStyle.dashed),
pw.Row(children: [
@@ -285,24 +284,24 @@ class ReceiptService {
child: _th(
slabs.fold(0.0, (a, s) => a + s.taxable).toStringAsFixed(2),
right: true,
size: 6.5)),
size: 6.5,),),
pw.Expanded(
flex: 16,
child: _th(slabs.fold(0.0, (a, s) => a + s.cgst).toStringAsFixed(2),
right: true, size: 6.5)),
right: true, size: 6.5,),),
pw.Expanded(
flex: 16,
child: _th(slabs.fold(0.0, (a, s) => a + s.sgst).toStringAsFixed(2),
right: true, size: 6.5)),
right: true, size: 6.5,),),
pw.Expanded(flex: 14, child: _th('0.00', right: true, size: 6.5)),
pw.Expanded(
flex: 20,
child: _th(
slabs.fold(0.0, (a, s) => a + s.total).toStringAsFixed(2),
right: true,
size: 6.5)),
]),
]);
size: 6.5,),),
],),
],);
}
pw.Widget _references(SaleTransaction txn) {
@@ -341,14 +340,14 @@ class ReceiptService {
),
pw.SizedBox(height: 3),
pw.Text('* Thank You for Shopping with us *',
style: pw.TextStyle(fontSize: 7, fontWeight: pw.FontWeight.bold)),
style: const pw.TextStyle(fontSize: 7, fontWeight: pw.FontWeight.bold),),
pw.Text('Goods once sold are exchangeable within 7 days with this bill',
style: const pw.TextStyle(fontSize: 5.5),
textAlign: pw.TextAlign.center),
textAlign: pw.TextAlign.center,),
pw.SizedBox(height: 2),
pw.Text('Powered by Nearle POS',
style: const pw.TextStyle(fontSize: 5.5)),
]);
style: const pw.TextStyle(fontSize: 5.5),),
],);
// -------------------------------------------------------------- Helpers
/// Pre-discount value, using printed MRP where one is known.
@@ -391,12 +390,12 @@ class ReceiptService {
style: pw.TextStyle(
fontSize: 6.8,
fontWeight: bold ? pw.FontWeight.bold : pw.FontWeight.normal,
)),
),),
pw.Text(value.toStringAsFixed(2),
style: pw.TextStyle(
fontSize: 6.8,
fontWeight: bold ? pw.FontWeight.bold : pw.FontWeight.normal,
)),
),),
],
),
);
@@ -404,14 +403,14 @@ class ReceiptService {
pw.Widget _th(String text, {bool right = false, double size = 6.8}) =>
pw.Text(text,
textAlign: right ? pw.TextAlign.right : pw.TextAlign.left,
style: pw.TextStyle(fontSize: size, fontWeight: pw.FontWeight.bold));
style: pw.TextStyle(fontSize: size, fontWeight: pw.FontWeight.bold),);
pw.Widget _td(String text, {bool right = false, double size = 6.8}) =>
pw.Text(text,
maxLines: 1,
overflow: pw.TextOverflow.clip,
textAlign: right ? pw.TextAlign.right : pw.TextAlign.left,
style: pw.TextStyle(fontSize: size));
style: pw.TextStyle(fontSize: size),);
// --------------------------------------------------------- Printing / IO
/// Opens the system print preview.
@@ -483,7 +482,7 @@ class ReceiptService {
final font = await PdfGoogleFonts.robotoMonoRegular();
doc.addPage(
pw.Page(
pageFormat: PdfPageFormat(
pageFormat: const PdfPageFormat(
_rollWidth,
double.infinity,
marginAll: 5 * PdfPageFormat.mm,
@@ -491,16 +490,16 @@ class ReceiptService {
theme: pw.ThemeData.withFont(base: font),
build: (_) => pw.Column(children: [
pw.Text(AppConstants.storeName.toUpperCase(),
style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)),
style: const pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold),),
pw.SizedBox(height: 6),
pw.Text('PRINTER TEST', style: const pw.TextStyle(fontSize: 10)),
pw.SizedBox(height: 4),
pw.Text(target.name, style: const pw.TextStyle(fontSize: 7)),
pw.Text(Formatters.dateTime(DateTime.now()),
style: const pw.TextStyle(fontSize: 7)),
style: const pw.TextStyle(fontSize: 7),),
pw.SizedBox(height: 6),
pw.Text('1234567890 ABCDEFGHIJ',
style: const pw.TextStyle(fontSize: 8)),
style: const pw.TextStyle(fontSize: 8),),
pw.Text('₹ 1,234.56', style: const pw.TextStyle(fontSize: 8)),
pw.SizedBox(height: 6),
pw.BarcodeWidget(
@@ -510,7 +509,7 @@ class ReceiptService {
height: 30,
drawText: false,
),
]),
],),
),
);

View File

@@ -40,13 +40,13 @@ class AppTheme {
?.copyWith(color: AppColors.textOnPrimary),
),
cardTheme: CardThemeData(
cardTheme: const CardThemeData(
color: AppColors.surface,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: AppRadius.brLg,
side: const BorderSide(color: AppColors.border),
side: BorderSide(color: AppColors.border),
),
),
@@ -149,7 +149,7 @@ class AppTheme {
TargetPlatform.macOS: FadeUpwardsPageTransitionsBuilder(),
TargetPlatform.linux: FadeUpwardsPageTransitionsBuilder(),
TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(),
}),
},),
);
}

View File

@@ -31,12 +31,12 @@ class AppTypography {
bodyLarge: _s(base.bodyLarge, 15.5, FontWeight.w400, 0),
bodyMedium: _s(base.bodyMedium, 14.5, FontWeight.w400, 0),
bodySmall: _s(base.bodySmall, 12.5, FontWeight.w400, 0,
color: AppColors.textSecondary),
color: AppColors.textSecondary,),
labelLarge: _s(base.labelLarge, 14.5, FontWeight.w600, 0),
labelMedium: _s(base.labelMedium, 12.5, FontWeight.w600, 0.1),
labelSmall: _s(base.labelSmall, 10.5, FontWeight.w600, 0.6,
color: AppColors.textTertiary),
color: AppColors.textTertiary,),
);
}
@@ -58,7 +58,7 @@ class AppTypography {
/// Tabular figures — essential so totals don't jitter as quantities change.
static TextStyle money(double size,
{FontWeight weight = FontWeight.w700, Color? color}) =>
{FontWeight weight = FontWeight.w700, Color? color,}) =>
GoogleFonts.poppins(
fontSize: size,
fontWeight: weight,

View File

@@ -29,7 +29,7 @@ extension BuildContextX on BuildContext {
content: Text(message),
backgroundColor: background,
duration: const Duration(seconds: 2),
));
),);
}
}

View File

@@ -36,7 +36,7 @@ class EmptyState extends StatelessWidget {
),
alignment: Alignment.center,
child: Text(emoji,
style: TextStyle(fontSize: compact ? 28 : 40)),
style: TextStyle(fontSize: compact ? 28 : 40),),
),
SizedBox(height: compact ? AppSpacing.lg : AppSpacing.xxl),
Text(

View File

@@ -49,28 +49,36 @@ class PrimaryButton extends StatelessWidget {
? MainAxisAlignment.spaceBetween
: MainAxisAlignment.center,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: large ? 24 : 20, color: fg),
const SizedBox(width: AppSpacing.sm),
],
Flexible(
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: fg,
fontSize: large ? 19 : 16,
fontWeight: FontWeight.w700,
letterSpacing: 0.1,
// Flexible so the label gives way to the trailing total rather
// than overflowing the button: on a narrow bill panel "Charge"
// plus a five-figure amount is wider than the button itself.
Flexible(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: large ? 24 : 20, color: fg),
const SizedBox(width: AppSpacing.sm),
],
Flexible(
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: fg,
fontSize: large ? 19 : 16,
fontWeight: FontWeight.w700,
letterSpacing: 0.1,
),
),
),
),
],
],
),
),
if (trailing != null) trailing!,
if (trailing != null) ...[
const SizedBox(width: AppSpacing.sm),
trailing!,
],
],
);