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>
178 lines
5.4 KiB
Dart
178 lines
5.4 KiB
Dart
import 'package:uuid/uuid.dart';
|
|
|
|
import '../../core/constants/app_constants.dart';
|
|
import '../../core/utils/formatters.dart';
|
|
import '../entities/cart.dart';
|
|
import '../entities/customer.dart';
|
|
import '../entities/transaction.dart';
|
|
import '../repositories/customer_repository.dart';
|
|
import '../repositories/product_repository.dart';
|
|
import '../repositories/transaction_repository.dart';
|
|
|
|
/// Raised when a sale cannot be completed. Carries a cashier-readable message.
|
|
class CheckoutFailure implements Exception {
|
|
const CheckoutFailure(this.message);
|
|
|
|
final String message;
|
|
|
|
@override
|
|
String toString() => message;
|
|
}
|
|
|
|
/// Result of a successful checkout.
|
|
class CheckoutResult {
|
|
const CheckoutResult({required this.transaction, this.updatedCustomer});
|
|
|
|
final SaleTransaction transaction;
|
|
final Customer? updatedCustomer;
|
|
}
|
|
|
|
/// Completes a sale end to end.
|
|
///
|
|
/// Validates tenders, persists the transaction, decrements stock and applies
|
|
/// loyalty movement. Everything the cashier's Complete Sale button needs lives
|
|
/// here rather than in the UI, so the flow is unit-testable in isolation.
|
|
class CheckoutSale {
|
|
const CheckoutSale({
|
|
required ProductRepository productRepository,
|
|
required CustomerRepository customerRepository,
|
|
required TransactionRepository transactionRepository,
|
|
}) : _products = productRepository,
|
|
_customers = customerRepository,
|
|
_transactions = transactionRepository;
|
|
|
|
final ProductRepository _products;
|
|
final CustomerRepository _customers;
|
|
final TransactionRepository _transactions;
|
|
|
|
static const _uuid = Uuid();
|
|
|
|
Future<CheckoutResult> call({
|
|
required Cart cart,
|
|
required List<PaymentSplit> payments,
|
|
required String cashierName,
|
|
String terminalId = 'TERM-01',
|
|
}) async {
|
|
_validate(cart, payments);
|
|
await _assertStockAvailable(cart);
|
|
|
|
final now = DateTime.now();
|
|
|
|
// The shopper's new balance is computed before anything is written, so it
|
|
// can be persisted in the same transaction as the bill.
|
|
Customer? updatedCustomer;
|
|
final customer = cart.customer;
|
|
if (customer != null) {
|
|
final current = await _customers.findById(customer.id);
|
|
if (current == null) {
|
|
throw const CheckoutFailure(
|
|
'This customer is no longer on file. Remove them from the bill to '
|
|
'continue.',
|
|
);
|
|
}
|
|
updatedCustomer = current.applySale(
|
|
amount: cart.grandTotal,
|
|
pointsEarned: cart.pointsEarned,
|
|
pointsRedeemed: cart.pointsRedeemed,
|
|
at: now,
|
|
);
|
|
}
|
|
|
|
final sequence = await _transactions.nextInvoiceSequence();
|
|
|
|
final transaction = SaleTransaction(
|
|
id: _uuid.v4(),
|
|
invoiceNumber: Formatters.invoiceNumber(sequence, now),
|
|
cart: cart,
|
|
payments: payments,
|
|
createdAt: now,
|
|
cashierName: cashierName,
|
|
terminalId: terminalId,
|
|
);
|
|
|
|
await _transactions.commitSale(
|
|
transaction: transaction,
|
|
stockMovements: {
|
|
for (final line in cart.lines) line.product.id: line.quantity,
|
|
},
|
|
updatedCustomer: updatedCustomer,
|
|
);
|
|
|
|
return CheckoutResult(
|
|
transaction: transaction,
|
|
updatedCustomer: updatedCustomer,
|
|
);
|
|
}
|
|
|
|
/// Re-checks every line against live stock.
|
|
///
|
|
/// [CartLine.exceedsStock] reads the product snapshot taken when the item was
|
|
/// added, which goes stale the moment anything else sells the same item — a
|
|
/// parked bill resumed after its stock was sold would otherwise pass
|
|
/// validation and oversell.
|
|
Future<void> _assertStockAvailable(Cart cart) async {
|
|
for (final line in cart.lines) {
|
|
final live = await _products.findById(line.product.id);
|
|
if (live == null) {
|
|
throw CheckoutFailure(
|
|
'${line.product.name} is no longer in the catalogue.',
|
|
);
|
|
}
|
|
if (line.quantity > live.stock) {
|
|
throw CheckoutFailure(
|
|
'Only ${live.stock.toStringAsFixed(0)} ${live.unit.symbol} of '
|
|
'${live.name} in stock.',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _validate(Cart cart, List<PaymentSplit> payments) {
|
|
if (cart.isEmpty) {
|
|
throw const CheckoutFailure('Add at least one item before charging.');
|
|
}
|
|
if (payments.isEmpty) {
|
|
throw const CheckoutFailure('Select a payment method.');
|
|
}
|
|
|
|
for (final line in cart.lines) {
|
|
if (line.quantity <= 0) {
|
|
throw CheckoutFailure('${line.product.name} has an invalid quantity.');
|
|
}
|
|
if (line.exceedsStock) {
|
|
throw CheckoutFailure(
|
|
'Only ${line.product.stock.toStringAsFixed(0)} '
|
|
'${line.product.unit.symbol} of ${line.product.name} in stock.',
|
|
);
|
|
}
|
|
}
|
|
|
|
if (cart.pointsRedeemed > 0) {
|
|
final available = cart.customer?.loyaltyPoints ?? 0;
|
|
if (cart.pointsRedeemed > available) {
|
|
throw const CheckoutFailure('Not enough loyalty points to redeem.');
|
|
}
|
|
}
|
|
|
|
final paid = payments.fold(0.0, (sum, p) => sum + p.amount);
|
|
final shortfall = cart.grandTotal - paid;
|
|
if (shortfall > 0.01) {
|
|
throw CheckoutFailure(
|
|
'${AppConstants.currencySymbol}${shortfall.toStringAsFixed(2)} '
|
|
'still due on this bill.',
|
|
);
|
|
}
|
|
|
|
for (final p in payments) {
|
|
if (p.amount <= 0) {
|
|
throw CheckoutFailure('${p.method.label} amount must be positive.');
|
|
}
|
|
if (p.method.needsChange &&
|
|
p.tendered != null &&
|
|
p.tendered! < p.amount) {
|
|
throw const CheckoutFailure('Cash tendered is less than the amount due.');
|
|
}
|
|
}
|
|
}
|
|
}
|