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; } /// Bill-level reductions, allocated to the lines that earned them. /// /// Returns one figure per line, in [lines] order, summing to exactly /// `subtotal - netAmount`. /// /// This exists because GST is charged per line at that line's own slab, so /// *which* line a discount lands on changes the tax. A bill-wide reduction — /// a tier discount, a manual markdown, points redeemed — genuinely belongs to /// every line, and spreading it pro rata is right. A campaign that names a /// category or a product does not: taking "20% off Beverages" out of the /// atta line as well understates the 18% slab and overstates the 5% one. The /// bill total is identical either way, which is exactly why the error is easy /// to ship — it only shows up in the slab split on a filed return. List get _lineReductions { final result = List.filled(lines.length, 0); if (lines.isEmpty) return result; // What the shopper actually saved at bill level, after the clamps in // [billDiscountTotal] and [netAmount] have had their say. final ceiling = (subtotal - netAmount).asMoney; if (ceiling <= 0) return result; void spread(double amount, bool Function(CartLine) targets) { if (amount <= 0) return; final matched = []; var base = 0.0; for (var i = 0; i < lines.length; i++) { if (!targets(lines[i])) continue; matched.add(i); base += lines[i].payable; } if (base <= 0) return; for (final i in matched) { result[i] += amount * (lines[i].payable / base); } } for (final applied in appliedPromos) { spread(applied.amount, (l) => applied.promo.targets(l.product)); } spread(membershipDiscountAmount, (_) => true); spread(manualBillDiscountAmount, (_) => true); spread(loyaltyRedemptionValue, (_) => true); return _fitToCeiling(result, ceiling); } /// Scales [raw] so it sums to [ceiling], with no line reduced below zero. /// /// The components arrive individually clamped and then clamped again as a /// group, so their raw sum is only approximately what came off the bill. /// Scaling reconciles the two. Capping is a separate pass because a targeted /// campaign can take a line to zero on its own, and the tier discount layered /// on top would otherwise push it negative — which would show up as a /// *credit* in that line's GST slab. List _fitToCeiling(List raw, double ceiling) { final out = List.filled(raw.length, 0); final open = [for (var i = 0; i < raw.length; i++) i]; var pool = ceiling; // Loops because capping one line hands its excess back to the pool, which // can in turn push another line past its own value. while (open.isNotEmpty && pool > 0) { final weight = open.fold(0.0, (sum, i) => sum + raw[i]); if (weight <= 0) break; final capped = open .where((i) => pool * (raw[i] / weight) >= lines[i].payable) .toList(); if (capped.isEmpty) { for (final i in open) { out[i] = pool * (raw[i] / weight); } break; } for (final i in capped) { out[i] = lines[i].payable; pool -= lines[i].payable; open.remove(i); } } return out; } /// What each line is worth after its share of the bill-level reductions. List get _lineNetAmounts { final reductions = _lineReductions; return [ for (var i = 0; i < lines.length; i++) (lines[i].payable - reductions[i]).clamp(0, double.infinity).toDouble(), ]; } /// GST payable across the bill, after apportioning bill-level discounts. double get taxAmount { final nets = _lineNetAmounts; var total = 0.0; for (var i = 0; i < lines.length; i++) { total += nets[i] - nets[i] / (1 + lines[i].product.gstRate); } return total.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 nets = _lineNetAmounts; final raw = {}; for (var i = 0; i < lines.length; i++) { final rate = lines[i].product.gstRate; raw[rate] = (raw[rate] ?? 0) + (nets[i] - nets[i] / (1 + rate)); } 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]; }