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

@@ -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];
}