import 'package:equatable/equatable.dart'; import '../../core/constants/app_constants.dart'; import 'product.dart'; /// What a promo does to a bill. enum PromoType { percentOffBill('% off the bill'), flatOffBill('flat off the bill'), percentOffCategory('% off a category'), percentOffProduct('% off a product'), /// Buy [Promo.buyQuantity], get [Promo.freeQuantity] of the same product /// free. The cheapest way a shop clears stock, and the one customers ask for /// by name. buyXGetY('buy X get Y free'); const PromoType(this.label); final String label; bool get needsTarget => this == percentOffCategory || this == percentOffProduct || this == buyXGetY; bool get isPercentage => this == percentOffBill || this == percentOffCategory || this == percentOffProduct; } /// A campaign the till applies automatically. class Promo extends Equatable { const Promo({ required this.id, required this.name, required this.type, this.value = 0, this.targetId, this.targetLabel, this.buyQuantity = 0, this.freeQuantity = 0, this.minBillValue = 0, this.maxDiscount, this.validFrom, this.validTo, this.daysOfWeek = const {}, this.stackable = false, this.priority = 100, this.isActive = true, }); final String id; final String name; final PromoType type; /// Percent for a percentage promo, rupees for a flat one. final double value; /// Category name or product id, depending on [type]. final String? targetId; /// Human-readable target, so the bill can say "20% off Beverages" without a /// lookup. final String? targetLabel; final int buyQuantity; final int freeQuantity; /// Floor on the bill before this applies at all. final double minBillValue; /// Ceiling on what a percentage promo can take off. /// /// Without one, "20% off" on an unusually large trolley gives away more than /// the campaign was ever costed for. final double? maxDiscount; final DateTime? validFrom; final DateTime? validTo; /// 1 = Monday … 7 = Sunday, matching [DateTime.weekday]. Empty means every /// day. final Set daysOfWeek; /// Whether this can combine with other promos. /// /// Most campaigns should not. Two stacking percentages compound into a /// discount nobody signed off, and the shop finds out at the end of the /// month. final bool stackable; /// Lower runs first. Only matters for ordering on the bill and for breaking /// ties between equal-value exclusive promos. final int priority; final bool isActive; /// Whether the promo is live at [at], ignoring the contents of the bill. bool isLiveAt(DateTime at) { if (!isActive) return false; final from = validFrom; if (from != null && at.isBefore(from)) return false; final to = validTo; // Inclusive of the closing day: a campaign "to the 31st" runs all of it. if (to != null && at.isAfter(_endOfDay(to))) return false; if (daysOfWeek.isNotEmpty && !daysOfWeek.contains(at.weekday)) return false; return true; } static DateTime _endOfDay(DateTime day) => DateTime(day.year, day.month, day.day, 23, 59, 59, 999); /// Whether this campaign is aimed at [product] in particular. /// /// A bill-wide promo targets everything; a category or product one targets /// only what it names. Two things read this and they must never disagree: /// `PromoEngine` uses it to price the discount, and [Cart] uses it to decide /// which lines carry the GST reduction. A campaign priced against one set of /// lines and taxed against another puts the wrong figure in a slab on a /// filed return, so the rule lives here once rather than in both callers. bool targets(Product product) => switch (type) { PromoType.percentOffBill || PromoType.flatOffBill => true, // Matched on the enum name, which is stable across a label change — // renaming "Personal Care" must not silently switch off a campaign. PromoType.percentOffCategory => product.category.name == targetId, PromoType.percentOffProduct || PromoType.buyXGetY => product.id == targetId, }; /// Whether the discount lands on named lines rather than the whole bill. bool get isTargeted => type.needsTarget; /// One-line description for the campaign list. String get summary => switch (type) { PromoType.percentOffBill => '${_trim(value)}% off the whole bill', PromoType.flatOffBill => '${AppConstants.currencySymbol}${_trim(value)} off the bill', PromoType.percentOffCategory => '${_trim(value)}% off ${targetLabel ?? targetId}', PromoType.percentOffProduct => '${_trim(value)}% off ${targetLabel ?? targetId}', PromoType.buyXGetY => 'Buy $buyQuantity get $freeQuantity free on ${targetLabel ?? targetId}', }; static String _trim(double v) => v == v.roundToDouble() ? v.toStringAsFixed(0) : v.toStringAsFixed(2); Promo copyWith({ String? name, PromoType? type, double? value, String? targetId, String? targetLabel, int? buyQuantity, int? freeQuantity, double? minBillValue, double? maxDiscount, bool clearMaxDiscount = false, DateTime? validFrom, DateTime? validTo, bool clearDates = false, Set? daysOfWeek, bool? stackable, int? priority, bool? isActive, }) => Promo( id: id, name: name ?? this.name, type: type ?? this.type, value: value ?? this.value, targetId: targetId ?? this.targetId, targetLabel: targetLabel ?? this.targetLabel, buyQuantity: buyQuantity ?? this.buyQuantity, freeQuantity: freeQuantity ?? this.freeQuantity, minBillValue: minBillValue ?? this.minBillValue, maxDiscount: clearMaxDiscount ? null : (maxDiscount ?? this.maxDiscount), validFrom: clearDates ? null : (validFrom ?? this.validFrom), validTo: clearDates ? null : (validTo ?? this.validTo), daysOfWeek: daysOfWeek ?? this.daysOfWeek, stackable: stackable ?? this.stackable, priority: priority ?? this.priority, isActive: isActive ?? this.isActive, ); @override List get props => [ id, name, type, value, targetId, buyQuantity, freeQuantity, minBillValue, maxDiscount, validFrom, validTo, daysOfWeek, stackable, priority, isActive, ]; } /// A promo that fired on a particular bill, and what it took off. class AppliedPromo extends Equatable { const AppliedPromo({required this.promo, required this.amount}); final Promo promo; final double amount; @override List get props => [promo.id, amount]; }