import '../../core/utils/extensions.dart'; import '../entities/cart.dart'; import '../entities/promo.dart'; /// Decides which campaigns fire on a bill, and for how much. /// /// Kept out of [Cart] on purpose. Cart owns arithmetic that must never be /// wrong; this owns policy that a shop changes weekly. Mixing them would put a /// marketing decision in the same class as the GST calculation. class PromoEngine { const PromoEngine._(); /// Evaluates [promos] against [cart] and returns what actually fires. /// /// ### Stacking /// /// All eligible **stackable** promos apply together. Of the **exclusive** /// ones, only the single best applies — the one worth most to the shopper, /// with [Promo.priority] breaking ties. /// /// This is the conservative reading, and deliberately so. Letting two /// percentages compound produces a discount nobody costed, and a shop finds /// out at the end of the month rather than at the till. /// /// The total is capped at the cart subtotal: no combination of campaigns can /// make a bill negative, or turn a sale into a payout. static List evaluate({ required Cart cart, required List promos, required DateTime at, }) { if (cart.isEmpty || promos.isEmpty) return const []; final subtotal = cart.subtotal; final eligible = []; for (final promo in promos) { if (!promo.isLiveAt(at)) continue; if (subtotal < promo.minBillValue) continue; final amount = amountFor(promo: promo, cart: cart); if (amount <= 0) continue; eligible.add(AppliedPromo(promo: promo, amount: amount)); } if (eligible.isEmpty) return const []; final stackable = eligible.where((a) => a.promo.stackable).toList() ..sort((a, b) => a.promo.priority.compareTo(b.promo.priority)); final exclusive = eligible.where((a) => !a.promo.stackable).toList() ..sort((a, b) { // Best for the shopper first; priority only breaks a genuine tie. final byAmount = b.amount.compareTo(a.amount); if (byAmount != 0) return byAmount; return a.promo.priority.compareTo(b.promo.priority); }); final chosen = [ ...stackable, if (exclusive.isNotEmpty) exclusive.first, ]..sort((a, b) => a.promo.priority.compareTo(b.promo.priority)); return _capped(chosen, subtotal); } /// Trims the applied set so it can never exceed the bill. /// /// Trimming the last one rather than scaling all of them keeps every other /// figure on the receipt exactly what the campaign promised. static List _capped(List applied, double ceiling) { final result = []; var running = 0.0; for (final entry in applied) { final headroom = (ceiling - running).asMoney; if (headroom <= 0) break; final amount = entry.amount <= headroom ? entry.amount : headroom; result.add(AppliedPromo(promo: entry.promo, amount: amount)); running = (running + amount).asMoney; } return result; } /// What one promo is worth on this cart, ignoring stacking rules. static double amountFor({required Promo promo, required Cart cart}) { final raw = switch (promo.type) { PromoType.percentOffBill => cart.subtotal * (promo.value / 100), PromoType.flatOffBill => promo.value, PromoType.percentOffCategory => _percentOfMatching( cart, promo.value, // Stored by enum name, which is stable across a label change — // renaming "Personal Care" must not silently switch off a campaign. (line) => line.product.category.name == promo.targetId, ), PromoType.percentOffProduct => _percentOfMatching( cart, promo.value, (line) => line.product.id == promo.targetId, ), PromoType.buyXGetY => _buyXGetY(cart, promo), }; final capped = promo.maxDiscount == null ? raw : (raw < promo.maxDiscount! ? raw : promo.maxDiscount!); return capped.clamp(0, cart.subtotal).toDouble().asMoney; } static double _percentOfMatching( Cart cart, double percent, bool Function(CartLine) matches, ) { final base = cart.lines .where(matches) .fold(0.0, (sum, line) => sum + line.payable); return base * (percent / 100); } /// Free units are the cheapest way to price this: for every group of /// (buy + free), the shopper pays for `buy` of them. /// /// Deliberately counts whole groups only. A "buy 2 get 1" on three items /// gives one free; on five it still gives one, because the fifth has not /// earned the second group. static double _buyXGetY(Cart cart, Promo promo) { if (promo.buyQuantity <= 0 || promo.freeQuantity <= 0) return 0; final line = cart.lines.firstWhereOrNull( (l) => l.product.id == promo.targetId, ); if (line == null) return 0; final groupSize = promo.buyQuantity + promo.freeQuantity; final groups = line.quantity ~/ groupSize; if (groups <= 0) return 0; // Priced at the unit rate actually being charged, so a line that already // carries a manual discount does not refund more than it took. final unitPrice = line.quantity <= 0 ? 0.0 : line.payable / line.quantity; return groups * promo.freeQuantity * unitPrice; } }