Files
nearle_pos/lib/domain/usecases/checkout_sale.dart
2026-07-29 11:41:53 +05:30

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.');
}
}
}
}