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>
203 lines
5.6 KiB
Dart
203 lines
5.6 KiB
Dart
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];
|
|
}
|