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 call({ required Cart cart, required List 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 _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 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.'); } } } }