import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../app/providers.dart'; import '../../../core/utils/extensions.dart'; import '../../../domain/entities/transaction.dart'; import '../../../domain/usecases/checkout_sale.dart'; import '../../auth/providers/auth_controller.dart'; import '../../pos/providers/cart_controller.dart'; import '../../modules/providers/printer_settings.dart'; import '../../pos/providers/catalog_providers.dart'; import '../../sync/providers/sync_controller.dart'; /// UI state for the payment screen. class PaymentState { const PaymentState({ this.splits = const [], this.activeMethod = PaymentMethod.cash, this.cashTendered = 0, this.reference = '', this.isProcessing = false, this.error, this.result, }); final List splits; final PaymentMethod activeMethod; final double cashTendered; final String reference; final bool isProcessing; final String? error; final CheckoutResult? result; double get settled => splits.fold(0.0, (sum, s) => sum + s.amount).asMoney; bool get isComplete => result != null; PaymentState copyWith({ List? splits, PaymentMethod? activeMethod, double? cashTendered, String? reference, bool? isProcessing, String? error, bool clearError = false, CheckoutResult? result, bool clearResult = false, }) { return PaymentState( splits: splits ?? this.splits, activeMethod: activeMethod ?? this.activeMethod, cashTendered: cashTendered ?? this.cashTendered, reference: reference ?? this.reference, isProcessing: isProcessing ?? this.isProcessing, error: clearError ? null : (error ?? this.error), result: clearResult ? null : (result ?? this.result), ); } } class PaymentController extends StateNotifier { PaymentController(this._ref) : super(const PaymentState()); final Ref _ref; double get _billTotal => _ref.read(cartControllerProvider).grandTotal; /// Amount still outstanding after the tenders recorded so far. double get balanceDue => (_billTotal - state.settled).clamp(0, double.infinity); double get changeDue { if (!state.activeMethod.needsChange) return 0; final diff = state.cashTendered - balanceDue; return diff > 0 ? diff.asMoney : 0; } /// Whether Complete Sale should be enabled. /// /// Every method now requires the cashier to state what was actually /// received before the sale can complete — a tap on Exact for the common /// case, or a typed amount for a partial tender. `cashTendered` is the /// amount entered for whichever method is currently active, not literally /// cash; the name stayed to keep this change out of the rest of the app. bool get canConfirm { if (_billTotal <= 0) return false; // Staged splits already cover the bill. if (balanceDue <= 0.01) return true; return state.cashTendered >= balanceDue; } /// Why the button is disabled, for display next to it. String? get blockedReason { if (_billTotal <= 0) return 'Add at least one item before charging.'; if (canConfirm) return null; return state.activeMethod.needsChange ? 'Enter the cash received, or tap Exact.' : 'Enter the amount received, or tap Exact.'; } void selectMethod(PaymentMethod method) { state = state.copyWith( activeMethod: method, cashTendered: 0, reference: '', clearError: true, ); } void setCashTendered(double amount) => state = state.copyWith(cashTendered: amount, clearError: true); /// Adds to the tendered amount — powers the quick-cash denomination chips. void addCash(double amount) => setCashTendered(state.cashTendered + amount); /// Fills the exact balance, the most common cash case. void tenderExact() => setCashTendered(balanceDue); void setReference(String value) => state = state.copyWith(reference: value, clearError: true); /// Records the active tender. For a split payment, call this once per part. void addSplit({double? amount}) { final value = (amount ?? balanceDue).clamp(0, balanceDue).toDouble(); if (value <= 0) return; final split = PaymentSplit( method: state.activeMethod, amount: value.asMoney, tendered: state.activeMethod.needsChange ? (state.cashTendered > 0 ? state.cashTendered : value) : null, reference: state.reference.trim().isEmpty ? null : state.reference.trim(), ); state = state.copyWith( splits: [...state.splits, split], cashTendered: 0, reference: '', clearError: true, ); } void removeSplit(int index) { final next = [...state.splits]..removeAt(index); state = state.copyWith(splits: next); } void clearSplits() => state = state.copyWith(splits: const []); /// Finalises the sale. On success the caller navigates to the receipt. Future confirm() async { if (state.isProcessing) return null; // A single-tender sale needn't be staged first — fold it in automatically. // Remembered so it can be rolled back if the sale is rejected, otherwise a // retry starts with a phantom tender already staged. final stagedSplits = state.splits; var splits = stagedSplits; if (splits.isEmpty || balanceDue > 0.01) { addSplit(); splits = state.splits; } state = state.copyWith(isProcessing: true, clearError: true); try { final cart = _ref.read(cartControllerProvider); final session = _ref.read(cashierSessionProvider); // The signed-in operator owns the bill. Falling back to the seed session // stamped every sale with the same name, so a bill could not be traced to // whoever actually rang it. final user = _ref.read(currentUserProvider); final result = await _ref.read(checkoutSaleProvider)( cart: cart, payments: splits, cashierName: user?.name ?? session.name, terminalId: session.terminalId, ); state = state.copyWith(isProcessing: false, result: result); // No auto-print: with no roll printer attached this silently failed and // looked like a bug. The receipt screen shows the bill on the terminal // and offers Print, WhatsApp and Share explicitly. // Only for cash. A card-only sale that pops the drawer is a shrinkage // risk, and it is what a shop notices first. final printer = _ref.read(printerSettingsProvider); final tookCash = splits.any((p) => p.method == PaymentMethod.cash); if (printer.openDrawer && printer.hasDrawer && tookCash) { unawaited( _ref.read(receiptServiceProvider).openCashDrawer( host: printer.drawerHost, port: printer.drawerPort, ), ); } unawaited(_ref.read(soundServiceProvider).saleComplete()); // Stock changed, so the grid must refresh; the new order changes the // unsynced tally and today's totals. _ref.invalidate(allProductsProvider); _ref.invalidate(visibleProductsProvider); _ref.read(orderVersionProvider.notifier).state++; // Deliberately NOT nudging the sync engine here. The bill sits on this // terminal, unsynced, until the receipt screen's cancellation window // closes — either it is pressed past early with New Sale, or the // window runs out — or the sale is voided if the cashier cancels // instead. See ReceiptScreen. return result; } on CheckoutFailure catch (e) { state = state.copyWith( isProcessing: false, error: e.message, splits: stagedSplits, ); return null; } catch (e) { state = state.copyWith( isProcessing: false, error: 'Could not complete the sale. $e', splits: stagedSplits, ); return null; } } void reset() => state = const PaymentState(); } final paymentControllerProvider = StateNotifierProvider.autoDispose( (ref) => PaymentController(ref), );