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:
@@ -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];
|
||||
}
|
||||
|
||||
202
lib/domain/entities/promo.dart
Normal file
202
lib/domain/entities/promo.dart
Normal 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];
|
||||
}
|
||||
151
lib/domain/services/promo_engine.dart
Normal file
151
lib/domain/services/promo_engine.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user