Build promos for real: engine, storage, editor, and application at the till

The Promo module was a mockup. Three hardcoded rows, a toggle that changed
nothing, and no promo code anywhere in lib/domain or lib/data. A cashier
looking at it would reasonably conclude promotions were running.

Engine (domain/services/promo_engine.dart)
- Five campaign types: percent or flat off the bill, percent off a category
  or a product, and buy-X-get-Y.
- Conditions: date range (inclusive of the closing day), days of the week,
  minimum bill value, and a cap on what a percentage can take off — without
  one an unusually large trolley gives away more than the campaign was costed
  for.
- Stacking is conservative by default. All stackable campaigns apply together;
  of the exclusive ones only the single best does, chosen by what it is worth
  to the shopper with priority breaking ties. Two percentages compounding
  produce a discount nobody signed off, and the shop finds out at the end of
  the month.
- The total is capped at the subtotal, so no combination of campaign, tier and
  manual discount can turn a sale into a payout.
- buy-X-get-Y counts whole groups only, and prices the free unit at what is
  actually being charged — a line already carrying a manual discount must not
  refund more than it took.

Kept out of Cart deliberately: Cart owns arithmetic that must never be wrong,
this owns policy a shop changes weekly.

Storage (schema v6, plus promos_json on orders at v7)
- Campaigns persist locally, because a shop mid-promotion with a dead line
  still has to honour the price on the shelf edge.
- A bill records the campaign name and the amount given, not a link to the
  row. A campaign edited or deleted later cannot change what a past sale
  shows, and a reprinted receipt still names what the shopper was given.
- On read-back the promo amounts are subtracted from the manual discount,
  because bill_discount already contains them. Restoring both at full value
  would discount the bill twice — the same shape as the bug that used to
  overstate synced totals.

At the till
- Every cart mutation re-evaluates, so a promo cannot survive the line that
  earned it being removed.
- A resumed parked bill is re-evaluated rather than restored: a campaign that
  has since ended must not be honoured because the bill was parked while it
  was running.
- Campaigns are named individually on the billing panel and the printed
  receipt, so a shopper who came in for an advertised offer can see it applied.

Editor
- Full CRUD, admin-only, with validation for the cases that would save happily
  and then silently never fire — a targeted campaign with no target, a
  percentage over 100, an end date before the start.

Tests: 199 -> 210. Covers each campaign type, the eligibility conditions, the
stacking rules, the impossible-to-go-negative guarantee, GST recomputation
against the reduced total, round-tripping, and the double-count guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Suriya
2026-08-01 13:36:50 +05:30
parent fdd90f28d9
commit 46d354ced1
18 changed files with 2239 additions and 199 deletions

View File

@@ -4,6 +4,7 @@ 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 }
@@ -100,6 +101,7 @@ class Cart extends Equatable {
this.billDiscount = Discount.none,
this.pointsRedeemed = 0,
this.note,
this.appliedPromos = const [],
});
final List<CartLine> lines;
@@ -108,6 +110,14 @@ class Cart extends Equatable {
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<AppliedPromo> appliedPromos;
static const Cart empty = Cart();
bool get isEmpty => lines.isEmpty;
@@ -143,9 +153,17 @@ class Cart extends Equatable {
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)
(membershipDiscountAmount + manualBillDiscountAmount +
promoDiscountAmount)
.clamp(0, subtotal)
.toDouble()
.asMoney;
@@ -240,6 +258,7 @@ class Cart extends Equatable {
Discount? billDiscount,
int? pointsRedeemed,
String? note,
List<AppliedPromo>? appliedPromos,
}) {
return Cart(
lines: lines ?? this.lines,
@@ -247,10 +266,11 @@ class Cart extends Equatable {
billDiscount: billDiscount ?? this.billDiscount,
pointsRedeemed: pointsRedeemed ?? this.pointsRedeemed,
note: note ?? this.note,
appliedPromos: appliedPromos ?? this.appliedPromos,
);
}
@override
List<Object?> get props =>
[lines, customer, billDiscount, pointsRedeemed, note];
[lines, customer, billDiscount, pointsRedeemed, note, appliedPromos];
}

View File

@@ -0,0 +1,202 @@
import 'package:equatable/equatable.dart';
import '../../core/constants/app_constants.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<int> 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);
/// 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<int>? 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<Object?> 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<Object?> get props => [promo.id, amount];
}

View File

@@ -0,0 +1,151 @@
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<AppliedPromo> evaluate({
required Cart cart,
required List<Promo> promos,
required DateTime at,
}) {
if (cart.isEmpty || promos.isEmpty) return const [];
final subtotal = cart.subtotal;
final eligible = <AppliedPromo>[];
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 = <AppliedPromo>[
...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<AppliedPromo> _capped(List<AppliedPromo> applied, double ceiling) {
final result = <AppliedPromo>[];
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;
}
}