Files
nearle_pos/lib/presentation/pos/widgets/billing_panel.dart
Suriya af3933092f 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>
2026-07-31 18:34:10 +05:30

430 lines
13 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/router/app_router.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/utils/extensions.dart';
import '../../../core/utils/formatters.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../domain/entities/cart.dart';
import '../../customer/widgets/customer_capture_sheet.dart';
import '../providers/cart_controller.dart';
import 'cart_line_tile.dart';
import 'discount_sheet.dart';
/// Always-visible bill on the right of the dashboard.
class BillingPanel extends ConsumerWidget {
const BillingPanel({super.key, this.inSheet = false});
final bool inSheet;
@override
Widget build(BuildContext context, WidgetRef ref) {
final cart = ref.watch(cartControllerProvider);
final controller = ref.read(cartControllerProvider.notifier);
return Container(
color: AppColors.surface,
child: Column(children: [
_Header(cart: cart, inSheet: inSheet),
const Divider(height: 1),
Expanded(
child: cart.isEmpty
? const EmptyState(
title: 'Cart is empty',
message: 'Scan a barcode or tap a product to begin.',
emoji: '🛒',
compact: true,
)
: ListView.separated(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.md,
),
itemCount: cart.lines.length,
separatorBuilder: (_, __) =>
const SizedBox(height: AppSpacing.sm),
itemBuilder: (_, i) {
// Newest line first mirrors what the cashier just scanned.
final line = cart.lines[cart.lines.length - 1 - i];
return CartLineTile(
key: ValueKey(line.product.id),
line: line,
onIncrement: () => controller.increment(line.product.id),
onDecrement: () => controller.decrement(line.product.id),
onRemove: () => controller.removeLine(line.product.id),
onDiscount: () =>
showLineDiscountSheet(context, ref, line),
);
},
),
),
if (cart.isNotEmpty) _Summary(cart: cart),
_Actions(cart: cart),
],),
);
}
}
class _Header extends ConsumerWidget {
const _Header({required this.cart, required this.inSheet});
final Cart cart;
final bool inSheet;
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = ref.read(cartControllerProvider.notifier);
return Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.lg,
AppSpacing.md,
AppSpacing.sm,
AppSpacing.md,
),
child: Row(
children: [
Flexible(
child: Text(
'Cart',
overflow: TextOverflow.ellipsis,
style: context.text.titleLarge,
),
),
if (cart.isNotEmpty) ...[
const SizedBox(width: AppSpacing.sm),
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: const BoxDecoration(
color: AppColors.primarySurface,
borderRadius: AppRadius.brPill,
),
child: Text(
'${cart.lineCount}',
style: const TextStyle(
color: AppColors.primary,
fontWeight: FontWeight.w700,
fontSize: 12.5,
),
),
),
],
const Spacer(),
// Icon-only actions: labelled buttons overflowed the 380px panel.
if (controller.canUndo)
_IconAction(
icon: Icons.undo_rounded,
tooltip: 'Undo (F8)',
color: AppColors.textSecondary,
onTap: controller.undo,
),
if (cart.isNotEmpty) ...[
_IconAction(
icon: Icons.pause_circle_outline_rounded,
tooltip: 'Park bill',
color: AppColors.warning,
onTap: () async {
await controller.park();
ref.invalidate(parkedBillsProvider);
if (context.mounted) context.showSnack('Bill parked');
},
),
_IconAction(
icon: Icons.delete_outline_rounded,
tooltip: 'Clear bill',
color: AppColors.danger,
onTap: controller.clear,
),
],
if (inSheet)
_IconAction(
icon: Icons.close_rounded,
tooltip: 'Close',
color: AppColors.textSecondary,
onTap: () => Navigator.of(context).pop(),
),
],
),
);
}
}
/// Compact square action used in the bill header.
class _IconAction extends StatelessWidget {
const _IconAction({
required this.icon,
required this.tooltip,
required this.color,
required this.onTap,
});
final IconData icon;
final String tooltip;
final Color color;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: IconButton(
onPressed: onTap,
icon: Icon(icon, size: 19),
color: color,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
padding: EdgeInsets.zero,
),
);
}
}
class _Summary extends ConsumerWidget {
const _Summary({required this.cart});
final Cart cart;
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = ref.read(cartControllerProvider.notifier);
return Container(
padding: const EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.lg,
AppSpacing.xl,
AppSpacing.md,
),
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: AppColors.divider)),
),
child: Column(children: [
if (cart.pointsEarned > 0)
Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: AppSpacing.md),
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm + 2,
),
decoration: const BoxDecoration(
color: AppColors.successSurface,
borderRadius: AppRadius.brSm,
),
child: Row(children: [
const Icon(Icons.stars_rounded,
size: 16, color: AppColors.success,),
const SizedBox(width: AppSpacing.sm),
Text(
'This sale earns +${cart.pointsEarned} pts',
style: const TextStyle(
color: AppColors.success,
fontWeight: FontWeight.w700,
fontSize: 13,
),
),
],),
),
_Row(label: 'Subtotal', value: Formatters.money(cart.subtotal)),
if (cart.membershipDiscountAmount > 0)
_Row(
label: '${cart.customer!.tier.label} discount',
value: '-${Formatters.money(cart.membershipDiscountAmount)}',
valueColor: AppColors.success,
),
_Row(
label: 'GST',
value: Formatters.money(cart.taxAmount),
hint: cart.taxBreakdown.keys.isEmpty
? null
: cart.taxBreakdown.keys
.map((r) => '${(r * 100).toStringAsFixed(0)}%')
.join(', '),
),
InkWell(
onTap: () => showBillDiscountSheet(context, ref),
borderRadius: AppRadius.brXs,
child: _Row(
label: 'Discount',
value: cart.manualBillDiscountAmount > 0
? '-${Formatters.money(cart.manualBillDiscountAmount)}'
: '-${Formatters.money(0)}',
valueColor: cart.manualBillDiscountAmount > 0
? AppColors.success
: null,
trailingIcon: Icons.edit_outlined,
),
),
if (cart.maxRedeemablePoints > 0 || cart.pointsRedeemed > 0)
InkWell(
onTap: () => cart.pointsRedeemed > 0
? controller.clearRedemption()
: controller.redeemAllPoints(),
borderRadius: AppRadius.brXs,
child: _Row(
label: cart.pointsRedeemed > 0
? 'Points redeemed (${cart.pointsRedeemed})'
: 'Redeem ${cart.maxRedeemablePoints} points',
value: cart.pointsRedeemed > 0
? '-${Formatters.money(cart.loyaltyRedemptionValue)}'
: 'Apply',
valueColor: AppColors.primary,
trailingIcon: cart.pointsRedeemed > 0
? Icons.close_rounded
: Icons.add_rounded,
),
),
if (cart.roundOff != 0)
_Row(
label: 'Round Off',
value: '${cart.roundOff >= 0 ? '+' : ''}'
'${Formatters.money(cart.roundOff)}',
),
const Padding(
padding: EdgeInsets.symmetric(vertical: AppSpacing.md),
child: Divider(height: 1),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Total', style: context.text.titleLarge),
Text(
Formatters.money(cart.grandTotal),
style: AppTypography.money(26, color: AppColors.primary),
),
],
),
if (cart.totalSavings > 0)
Padding(
padding: const EdgeInsets.only(top: AppSpacing.xs),
child: Align(
alignment: Alignment.centerRight,
child: Text(
'You saved ${Formatters.money(cart.totalSavings)}',
style: const TextStyle(
color: AppColors.success,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
),
],),
);
}
}
class _Row extends StatelessWidget {
const _Row({
required this.label,
required this.value,
this.valueColor,
this.hint,
this.trailingIcon,
});
final String label;
final String value;
final Color? valueColor;
final String? hint;
final IconData? trailingIcon;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs + 2),
child: Row(children: [
Flexible(
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
),
),
if (hint != null) ...[
const SizedBox(width: AppSpacing.xs),
Text('($hint)',
style: const TextStyle(
fontSize: 11.5,
color: AppColors.textTertiary,
),),
],
const SizedBox(width: AppSpacing.sm),
const Spacer(),
Text(
value,
style: AppTypography.money(
14.5,
weight: FontWeight.w600,
color: valueColor ?? AppColors.textPrimary,
),
),
if (trailingIcon != null) ...[
const SizedBox(width: AppSpacing.xs),
Icon(trailingIcon, size: 14, color: AppColors.textTertiary),
],
],),
);
}
}
class _Actions extends ConsumerWidget {
const _Actions({required this.cart});
final Cart cart;
@override
Widget build(BuildContext context, WidgetRef ref) {
final enabled = cart.isNotEmpty;
return Container(
padding: const EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.sm,
AppSpacing.xl,
AppSpacing.xl,
),
child: PrimaryButton(
label: 'CHARGE',
large: true,
onPressed: enabled
? () async {
// Ask once per bill, before payment. Skipping is one tap and
// leaves the sale as walk-in.
if (ref.read(cartControllerProvider).customer == null) {
await showCustomerCaptureSheet(context);
}
// Navigation result is not needed here.
if (context.mounted) unawaited(context.push(AppRoutes.payment));
}
: null,
trailing: enabled
? Text(
Formatters.money(cart.grandTotal),
style: AppTypography.money(21, color: Colors.white),
)
: null,
),
);
}
}