Answers "which of my 100 tills are alive and healthy", and fixes three things that were fine on one device and broken on a hundred. Terminal identity (lib/data/local/terminal_identity.dart) - Every device mints a UUID on first run, stored in its own database, plus a short code (T4A9) derived from it. Renaming keeps the device id, so history keeps pointing at the same physical till. - Replaces the literal 'TERM-01', which was hardcoded in five places. The whole fleet reported as one terminal: shift reports merged, MQTT topics collided, and a second connection with the same client id evicts the first from the broker — so two tills would have knocked each other offline in a loop. Invoice numbers now carry the terminal code - INV-2608-T4A9-00042. The sequence counter lives in each till's own database and starts at 1, so without this every terminal in the fleet minted INV-2608-00001 for its first sale of the month. The order UUID kept the data distinct; the number a customer quotes on a receipt was not. SQLite pragmas - WAL, so the product grid refreshing does not block the sale being written, and the file is never left mid-rewrite by a power cut. - busy_timeout 5s, so a contended lock waits instead of throwing "database is locked" — which at checkout is a failed sale with a customer standing there. - synchronous NORMAL, the right trade under WAL for a till. Fleet presence (lib/data/sync/presence_reporter.dart) - Retained status record on connect and once a minute: device id, code, name, app version, pending bill count, last upload, catalogue revision, sync halt state. Retained so a dashboard connecting at noon gets all 100 terminals immediately rather than a blank board. - The Last Will already said "reachable". A till can be connected and still be holding 200 unsent bills or running last month's prices; only pending_bills and catalogue_revision say so. NATS - The MQTT gateway maps / to . so the existing transport works unchanged. SyncConfig.asNatsSubject() exposes the translation, and the contract doc gives the JetStream subjects (pos.*.*.order, pos.*.*.status) plus the two server-side requirements: a file-backed stream, and the ack published by the consumer after commit rather than by the ingest handler. Tests: 129 -> 140. New coverage for identity minting and stability, per-device invoice uniqueness, topic and client-id separation, NATS subject mapping, and the two pragmas. Suite run three times clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
183 lines
5.5 KiB
Dart
183 lines
5.5 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,
|
|
required String terminalId,
|
|
String? terminalCode,
|
|
}) 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,
|
|
terminalCode: terminalCode ?? terminalId,
|
|
),
|
|
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.');
|
|
}
|
|
}
|
|
}
|
|
}
|