import 'package:equatable/equatable.dart'; import '../../core/constants/app_constants.dart'; import '../../core/utils/extensions.dart'; import 'customer.dart'; import 'product.dart'; import 'promo.dart'; /// How a discount value should be interpreted. enum DiscountType { none, percentage, flat } /// A discount applied to a single line or to the whole bill. class Discount extends Equatable { const Discount({this.type = DiscountType.none, this.value = 0, this.reason}); final DiscountType type; final double value; final String? reason; static const Discount none = Discount(); bool get isActive => type != DiscountType.none && value > 0; /// Resolves the discount to rupees against [base], never exceeding it. double amountOn(double base) { if (!isActive || base <= 0) return 0; final raw = switch (type) { DiscountType.percentage => base * (value / 100), DiscountType.flat => value, DiscountType.none => 0.0, }; return raw.clamp(0, base).toDouble().asMoney; } String get label => switch (type) { DiscountType.percentage => '${value.toStringAsFixed(0)}% off', DiscountType.flat => 'Flat ${AppConstants.currencySymbol}$value off', DiscountType.none => 'No discount', }; @override List get props => [type, value, reason]; } /// One product line inside the cart. class CartLine extends Equatable { const CartLine({ required this.product, required this.quantity, this.discount = Discount.none, this.addedAt, }); final Product product; final double quantity; final Discount discount; final DateTime? addedAt; String get id => product.id; /// Line value before discount, GST inclusive. double get grossAmount => (product.price * quantity).asMoney; double get discountAmount => discount.amountOn(grossAmount); /// Payable for this line after discount, GST inclusive. double get payable => (grossAmount - discountAmount).asMoney; /// Taxable value inside [payable]. double get taxableValue => (payable / (1 + product.gstRate)).asMoney; /// GST rupees inside [payable]. double get taxAmount => (payable - taxableValue).asMoney; double get cgst => (taxAmount / 2).asMoney; double get sgst => (taxAmount / 2).asMoney; double get mrpSavings => product.hasDiscount ? (product.savings * quantity).asMoney : 0; bool get exceedsStock => quantity > product.stock; CartLine copyWith({double? quantity, Discount? discount}) => CartLine( product: product, quantity: quantity ?? this.quantity, discount: discount ?? this.discount, addedAt: addedAt, ); @override List get props => [product.id, quantity, discount]; } /// The live bill. Immutable — every mutation returns a new instance, which /// keeps the Riverpod notifier predictable and makes undo trivial. class Cart extends Equatable { const Cart({ this.lines = const [], this.customer, this.billDiscount = Discount.none, this.pointsRedeemed = 0, this.note, this.appliedPromos = const [], }); final List lines; final Customer? customer; final Discount billDiscount; final int pointsRedeemed; final String? note; /// Campaigns that fired on this bill. /// /// Resolved by `PromoEngine` and handed in, rather than computed here: which /// campaigns exist is policy that changes weekly, and Cart owns arithmetic /// that must never be wrong. Stored as amounts so a bill read back years /// later shows what was actually given, not what today's rules would give. final List appliedPromos; static const Cart empty = Cart(); bool get isEmpty => lines.isEmpty; bool get isNotEmpty => lines.isNotEmpty; bool get isWalkIn => customer == null; int get lineCount => lines.length; double get totalQuantity => lines.fold(0.0, (sum, l) => sum + l.quantity); /// Sum of line values before any bill-level discount, GST inclusive. double get subtotal => lines.fold(0.0, (sum, l) => sum + l.payable).asMoney; /// Discounts applied at the individual line level. double get lineDiscountTotal => lines.fold(0.0, (sum, l) => sum + l.discountAmount).asMoney; /// Automatic discount earned through the customer's membership tier. Discount get membershipDiscount { final rate = customer?.tier.discountRate ?? 0; if (rate <= 0) return Discount.none; return Discount( type: DiscountType.percentage, value: rate * 100, reason: '${customer!.tier.label} member', ); } double get membershipDiscountAmount => membershipDiscount.amountOn(subtotal); double get manualBillDiscountAmount => billDiscount.amountOn(subtotal); /// What the automatic campaigns took off. double get promoDiscountAmount => appliedPromos.fold(0.0, (sum, p) => sum + p.amount).asMoney; /// All bill-level reductions combined. /// /// Clamped to the subtotal so no combination of tier, campaign and manual /// discount can drive a bill below zero and turn a sale into a payout. double get billDiscountTotal => (membershipDiscountAmount + manualBillDiscountAmount + promoDiscountAmount) .clamp(0, subtotal) .toDouble() .asMoney; double get loyaltyRedemptionValue => (pointsRedeemed * AppConstants.loyaltyPointValue).asMoney; /// Payable after every discount, GST inclusive, before round-off. double get netAmount { final v = subtotal - billDiscountTotal - loyaltyRedemptionValue; return v.clamp(0, double.infinity).toDouble().asMoney; } /// Proportion of the bill remaining after bill-level reductions. Used to /// spread those reductions fairly across lines when apportioning GST. double get _billFactor => subtotal <= 0 ? 1 : netAmount / subtotal; /// GST payable across the bill, after apportioning bill-level discounts. double get taxAmount => lines.fold(0.0, (sum, l) => sum + l.taxAmount * _billFactor).asMoney; double get cgst => (taxAmount / 2).asMoney; double get sgst => (taxAmount / 2).asMoney; /// Taxable value across the bill. double get taxableAmount => (netAmount - taxAmount).asMoney; /// GST broken out per slab — required on a compliant tax invoice. /// /// The slabs are reconciled against [taxAmount] before being returned. /// Rounding each slab on its own leaves the parts summing to a paisa either /// side of the total printed on the same bill, which a tax invoice cannot /// show; the residue is absorbed by the largest slab. Map get taxBreakdown { final raw = {}; for (final line in lines) { final rate = line.product.gstRate; raw[rate] = (raw[rate] ?? 0) + line.taxAmount * _billFactor; } if (raw.isEmpty) return const {}; final map = { for (final e in raw.entries) e.key: e.value.asMoney, }; final drift = (taxAmount - map.values.fold(0.0, (a, b) => a + b)).asMoney; if (drift != 0) { final largest = raw.entries.reduce((a, b) => a.value >= b.value ? a : b).key; map[largest] = (map[largest]! + drift).asMoney; } return map; } double get grandTotal => netAmount.roundedToRupee; /// The paise adjustment shown as "Round Off" on the bill. double get roundOff => (grandTotal - netAmount).asMoney; double get mrpSavingsTotal => lines.fold(0.0, (sum, l) => sum + l.mrpSavings).asMoney; /// Everything the shopper saved on this bill. double get totalSavings => (mrpSavingsTotal + lineDiscountTotal + billDiscountTotal).asMoney; /// Points this sale will earn. Walk-in customers earn nothing. int get pointsEarned { if (customer == null) return 0; return (grandTotal / AppConstants.loyaltyRupeesPerPoint).floor(); } int get maxRedeemablePoints { final c = customer; if (c == null) return 0; final byBalance = c.loyaltyPoints; final byBill = (subtotal - billDiscountTotal) ~/ AppConstants.loyaltyPointValue; return byBalance < byBill ? byBalance : byBill; } CartLine? lineFor(String productId) => lines.firstWhereOrNull((l) => l.product.id == productId); bool contains(String productId) => lineFor(productId) != null; Cart copyWith({ List? lines, Customer? customer, bool clearCustomer = false, Discount? billDiscount, int? pointsRedeemed, String? note, List? appliedPromos, }) { return Cart( lines: lines ?? this.lines, customer: clearCustomer ? null : (customer ?? this.customer), billDiscount: billDiscount ?? this.billDiscount, pointsRedeemed: pointsRedeemed ?? this.pointsRedeemed, note: note ?? this.note, appliedPromos: appliedPromos ?? this.appliedPromos, ); } @override List get props => [lines, customer, billDiscount, pointsRedeemed, note, appliedPromos]; }