140 lines
4.1 KiB
Dart
140 lines
4.1 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,
|
|
}) async {
|
|
_validate(cart, payments);
|
|
|
|
final now = DateTime.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,
|
|
);
|
|
|
|
await _transactions.save(transaction);
|
|
|
|
await _products.decrementStock({
|
|
for (final line in cart.lines) line.product.id: line.quantity,
|
|
});
|
|
|
|
Customer? updatedCustomer;
|
|
final customer = cart.customer;
|
|
if (customer != null) {
|
|
updatedCustomer = await _customers.recordSale(
|
|
customerId: customer.id,
|
|
amount: cart.grandTotal,
|
|
pointsEarned: cart.pointsEarned,
|
|
pointsRedeemed: cart.pointsRedeemed,
|
|
);
|
|
}
|
|
|
|
return CheckoutResult(
|
|
transaction: transaction,
|
|
updatedCustomer: updatedCustomer,
|
|
);
|
|
}
|
|
|
|
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.');
|
|
}
|
|
}
|
|
}
|
|
}
|