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:
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];
|
||||
}
|
||||
Reference in New Issue
Block a user