diff --git a/lib/app/providers.dart b/lib/app/providers.dart index 3caf7b5..15fd7f2 100644 --- a/lib/app/providers.dart +++ b/lib/app/providers.dart @@ -21,6 +21,7 @@ import '../domain/repositories/customer_repository.dart'; import '../domain/repositories/product_repository.dart'; import '../domain/repositories/sync_repository.dart'; import '../domain/repositories/transaction_repository.dart'; +import '../domain/entities/promo.dart'; import '../domain/entities/store_account.dart'; import '../domain/usecases/checkout_sale.dart'; import '../presentation/auth/providers/auth_controller.dart'; @@ -143,6 +144,16 @@ final storeAccountProvider = FutureProvider( ), ); +/// Campaigns stored on this terminal. +final promosProvider = FutureProvider>( + (ref) => ref.watch(localStoreProvider).promos.all(), +); + +/// Only the campaigns the till should be applying right now. +final activePromosProvider = FutureProvider>( + (ref) => ref.watch(localStoreProvider).promos.all(activeOnly: true), +); + // ------------------------------------------------------------- Use cases final checkoutSaleProvider = Provider( (ref) => CheckoutSale( diff --git a/lib/core/services/receipt_service.dart b/lib/core/services/receipt_service.dart index 6bf25fd..5ae017c 100644 --- a/lib/core/services/receipt_service.dart +++ b/lib/core/services/receipt_service.dart @@ -226,6 +226,11 @@ class ReceiptService { pw.SizedBox(height: 2), _amount('Gross Sales Value', gross), if (discount > 0) _amount('Total Discount', discount), + // Named on the printed bill too. A shopper who came in for an advertised + // offer needs to see it on the receipt, not just a total that happens to + // be lower than the shelf price. + for (final applied in cart.appliedPromos) + _amount(' ${applied.promo.name}', applied.amount), _amount('Net Sales Value (Inclusive of GST)', cart.netAmount), if (cart.roundOff != 0) _amount('Round Off', cart.roundOff), _amount('Total Amount Paid', txn.total, bold: true), @@ -580,6 +585,10 @@ class ReceiptService { if (cart.billDiscountTotal > 0) { b.writeln('Discount: -${Formatters.money(cart.billDiscountTotal)}'); + for (final applied in cart.appliedPromos) { + b.writeln(' ${applied.promo.name}: ' + '-${Formatters.money(applied.amount)}'); + } } b diff --git a/lib/data/datasources/local_store.dart b/lib/data/datasources/local_store.dart index 3ce702f..1b31408 100644 --- a/lib/data/datasources/local_store.dart +++ b/lib/data/datasources/local_store.dart @@ -4,6 +4,7 @@ import '../../domain/entities/sync_event.dart'; import '../local/app_database.dart'; import '../local/catalogue_dao.dart'; import '../local/order_dao.dart'; +import '../local/promo_dao.dart'; import '../local/staff_dao.dart'; import '../local/sync_config_store.dart'; import '../local/sync_log_dao.dart'; @@ -24,6 +25,7 @@ class LocalStore { late OrderDao orders; late SyncLogDao syncLog; late StaffDao staff; + late PromoDao promos; late SyncConfigStore syncConfig; late TerminalIdentityStore identityStore; @@ -54,6 +56,7 @@ class LocalStore { orders = OrderDao(AppDatabase.instance.db); syncLog = SyncLogDao(AppDatabase.instance.db); staff = StaffDao(AppDatabase.instance.db); + promos = PromoDao(AppDatabase.instance.db); syncConfig = SyncConfigStore(catalogue); identityStore = TerminalIdentityStore(catalogue); diff --git a/lib/data/local/app_database.dart b/lib/data/local/app_database.dart index d01b3f6..e86d035 100644 --- a/lib/data/local/app_database.dart +++ b/lib/data/local/app_database.dart @@ -13,7 +13,7 @@ class AppDatabase { static final AppDatabase instance = AppDatabase._(); static const String _fileName = 'nearle_pos.db'; - static const int _version = 5; + static const int _version = 7; Database? _db; @@ -71,6 +71,12 @@ class AppDatabase { } if (from < 4) await _upgradeToV4(db, from: from); if (from < 5) await db.execute(_createStaff); + if (from < 6) await db.execute(_createPromos); + if (from < 7) { + await db.execute( + 'ALTER TABLE ${Tables.orders} ADD COLUMN promos_json TEXT', + ); + } }, ), ); @@ -131,6 +137,7 @@ class AppDatabase { Tables.parkedBills, Tables.syncLog, Tables.staff, + Tables.promos, Tables.meta, ]) { batch.delete(t); @@ -211,6 +218,10 @@ class AppDatabase { points_earned INTEGER NOT NULL DEFAULT 0, points_redeemed INTEGER NOT NULL DEFAULT 0, payments_json TEXT NOT NULL, + -- Which campaigns fired, and for how much. Stored as amounts rather + -- than ids: a bill read back next year must show what was actually + -- given, not what today's rules would give. + promos_json TEXT, status TEXT NOT NULL DEFAULT 'completed', -- 0 = held on this terminal, 1 = accepted by the server @@ -268,6 +279,7 @@ class AppDatabase { // ------------------------------------------------------------- archive await db.execute(_createDayArchive); await db.execute(_createStaff); + await db.execute(_createPromos); // ----------------------------------------------------------------- meta await db.execute(''' @@ -351,6 +363,34 @@ const String _createStaff = ''' ) '''; +/// Campaigns the till applies automatically. +/// +/// Kept local like everything else: a shop mid-promotion with a dead line still +/// has to honour the price on the shelf edge. +const String _createPromos = ''' + CREATE TABLE promos ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL, + value REAL NOT NULL DEFAULT 0, + target_id TEXT, + target_label TEXT, + buy_quantity INTEGER NOT NULL DEFAULT 0, + free_quantity INTEGER NOT NULL DEFAULT 0, + min_bill_value REAL NOT NULL DEFAULT 0, + max_discount REAL, + valid_from INTEGER, + valid_to INTEGER, + -- Comma-separated DateTime.weekday values. Empty means every day. + days_of_week TEXT NOT NULL DEFAULT '', + stackable INTEGER NOT NULL DEFAULT 0, + priority INTEGER NOT NULL DEFAULT 100, + is_active INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) +'''; + const String _createDayArchive = ''' CREATE TABLE day_archive ( business_date TEXT NOT NULL, @@ -383,6 +423,7 @@ class Tables { static const String parkedBills = 'parked_bills'; static const String syncLog = 'sync_log'; static const String staff = 'staff'; + static const String promos = 'promos'; static const String meta = 'app_meta'; } diff --git a/lib/data/local/order_dao.dart b/lib/data/local/order_dao.dart index 1890f73..e9e761c 100644 --- a/lib/data/local/order_dao.dart +++ b/lib/data/local/order_dao.dart @@ -5,6 +5,7 @@ import 'package:sqflite/sqflite.dart'; import '../../domain/entities/cart.dart'; import '../../domain/entities/customer.dart'; import '../../domain/entities/product.dart'; +import '../../domain/entities/promo.dart'; import '../../domain/entities/transaction.dart'; import 'app_database.dart'; import 'catalogue_dao.dart'; @@ -92,6 +93,17 @@ class OrderDao { 'subtotal': cart.subtotal, 'line_discount': cart.lineDiscountTotal, 'bill_discount': cart.billDiscountTotal, + 'promos_json': cart.appliedPromos.isEmpty + ? null + : jsonEncode([ + for (final applied in cart.appliedPromos) + { + 'id': applied.promo.id, + 'name': applied.promo.name, + 'type': applied.promo.type.name, + 'amount': applied.amount, + }, + ]), 'loyalty_value': cart.loyaltyRedemptionValue, 'taxable_amount': cart.taxableAmount, 'tax_amount': cart.taxAmount, @@ -438,6 +450,36 @@ class OrderDao { await _db.delete(Tables.parkedBills, where: 'id = ?', whereArgs: [id]); } + /// Rebuilds the campaigns recorded against a bill. + /// + /// The stored rows carry the promo's name and amount rather than a live + /// lookup, so a campaign that has since been edited or deleted still prints + /// on a reissued receipt exactly as it was given. + static List _promosFromRow(String? json) { + if (json == null || json.isEmpty) return const []; + + try { + return (jsonDecode(json) as List) + .cast>() + .map((p) => AppliedPromo( + promo: Promo( + id: (p['id'] as String?) ?? '', + name: (p['name'] as String?) ?? 'Promotion', + type: PromoType.values + .where((t) => t.name == p['type']) + .firstOrNull ?? + PromoType.flatOffBill, + ), + amount: (p['amount'] as num?)?.toDouble() ?? 0, + ),) + .toList(); + } on Object { + // A bill that cannot name its campaigns is still a valid bill; the total + // is on the row itself and does not depend on this. + return const []; + } + } + // -------------------------------------------------------------- Internals Future> _query({ String? where, @@ -530,19 +572,28 @@ class OrderDao { // payload, the day archive, the shift report — is overstated. final billDiscount = (o['bill_discount']! as num).toDouble(); + // Campaigns are restored so a reprinted receipt still names what the + // shopper was given. Their amounts are then *subtracted* from the manual + // discount, because `bill_discount` already contains them — restoring both + // at full value would discount the bill twice on the way back in. + final promos = _promosFromRow(o['promos_json'] as String?); + final promoTotal = promos.fold(0, (sum, p) => sum + p.amount); + final manualDiscount = (billDiscount - promoTotal).clamp(0, billDiscount); + return SaleTransaction( id: o['id']! as String, invoiceNumber: o['invoice_number']! as String, cart: Cart( lines: lines, customer: customer, - billDiscount: billDiscount > 0 + billDiscount: manualDiscount > 0 ? Discount( type: DiscountType.flat, - value: billDiscount, + value: manualDiscount.toDouble(), reason: 'Bill discount', ) : Discount.none, + appliedPromos: promos, pointsRedeemed: (o['points_redeemed'] as int?) ?? 0, ), payments: payments, diff --git a/lib/data/local/promo_dao.dart b/lib/data/local/promo_dao.dart new file mode 100644 index 0000000..cf73893 --- /dev/null +++ b/lib/data/local/promo_dao.dart @@ -0,0 +1,185 @@ +import 'package:sqflite/sqflite.dart'; +import 'package:uuid/uuid.dart'; + +import '../../domain/entities/promo.dart'; +import 'app_database.dart'; + +/// Raised when a campaign would be saved in a state the till cannot apply. +class PromoException implements Exception { + const PromoException(this.message); + + final String message; + + @override + String toString() => message; +} + +/// Stores campaigns on the terminal. +/// +/// Local like everything else the till needs: a shop mid-promotion with a dead +/// line still has to honour the price on the shelf edge. +class PromoDao { + const PromoDao(this._db); + + final Database _db; + + static const _uuid = Uuid(); + + Future> all({bool activeOnly = false}) async { + final rows = await _db.query( + Tables.promos, + where: activeOnly ? 'is_active = 1' : null, + orderBy: 'priority ASC, created_at ASC', + ); + return rows.map(_fromRow).toList(); + } + + Future findById(String id) async { + final rows = await _db.query( + Tables.promos, + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + return rows.isEmpty ? null : _fromRow(rows.first); + } + + /// Inserts or updates. Returns the stored campaign, with its id. + Future save(Promo promo) async { + _validate(promo); + + final id = promo.id.isEmpty ? _uuid.v4() : promo.id; + final now = DateTime.now().millisecondsSinceEpoch; + final existing = await findById(id); + + await _db.insert( + Tables.promos, + { + 'id': id, + 'name': promo.name.trim(), + 'type': promo.type.name, + 'value': promo.value, + 'target_id': promo.targetId, + 'target_label': promo.targetLabel, + 'buy_quantity': promo.buyQuantity, + 'free_quantity': promo.freeQuantity, + 'min_bill_value': promo.minBillValue, + 'max_discount': promo.maxDiscount, + 'valid_from': promo.validFrom?.millisecondsSinceEpoch, + 'valid_to': promo.validTo?.millisecondsSinceEpoch, + 'days_of_week': (promo.daysOfWeek.toList()..sort()).join(','), + 'stackable': promo.stackable ? 1 : 0, + 'priority': promo.priority, + 'is_active': promo.isActive ? 1 : 0, + // Preserved on update so the list keeps a stable order. + 'created_at': existing == null ? now : await _createdAt(id) ?? now, + 'updated_at': now, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + + // Read back rather than echoing the input, so the caller gets exactly what + // the database holds — including the id minted for a new campaign. + return (await findById(id))!; + } + + Future _createdAt(String id) async { + final rows = await _db.query( + Tables.promos, + columns: ['created_at'], + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + return rows.isEmpty ? null : rows.first['created_at'] as int?; + } + + Future setActive(String id, {required bool active}) async { + await _db.update( + Tables.promos, + { + 'is_active': active ? 1 : 0, + 'updated_at': DateTime.now().millisecondsSinceEpoch, + }, + where: 'id = ?', + whereArgs: [id], + ); + } + + /// Hard delete. Unlike staff, nothing already recorded points at a promo row + /// — a bill stores the amount it was given, not a reference to the campaign, + /// so deleting one cannot change a past total. + Future delete(String id) async { + await _db.delete(Tables.promos, where: 'id = ?', whereArgs: [id]); + } + + // ------------------------------------------------------------- Internals + static void _validate(Promo promo) { + if (promo.name.trim().isEmpty) { + throw const PromoException('A campaign needs a name.'); + } + + if (promo.type.needsTarget && + (promo.targetId == null || promo.targetId!.isEmpty)) { + throw const PromoException( + 'This campaign needs a product or category to apply to.', + ); + } + + if (promo.type == PromoType.buyXGetY) { + if (promo.buyQuantity < 1 || promo.freeQuantity < 1) { + throw const PromoException( + 'Buy and free quantities must both be at least one.', + ); + } + } else if (promo.value <= 0) { + throw const PromoException('A campaign must give something away.'); + } + + if (promo.type.isPercentage && promo.value > 100) { + // Over 100% is a refund with extra steps. + throw const PromoException('A percentage cannot exceed 100.'); + } + + final from = promo.validFrom; + final to = promo.validTo; + if (from != null && to != null && to.isBefore(from)) { + throw const PromoException('The end date is before the start date.'); + } + + if (promo.daysOfWeek.any((d) => d < 1 || d > 7)) { + throw const PromoException('Days of the week must be 1 (Mon) to 7 (Sun).'); + } + } + + static Promo _fromRow(Map row) { + final days = (row['days_of_week'] as String? ?? '') + .split(',') + .where((s) => s.isNotEmpty) + .map(int.parse) + .toSet(); + + return Promo( + id: row['id']! as String, + name: row['name']! as String, + type: PromoType.values.byName(row['type']! as String), + value: (row['value'] as num? ?? 0).toDouble(), + targetId: row['target_id'] as String?, + targetLabel: row['target_label'] as String?, + buyQuantity: (row['buy_quantity'] as int?) ?? 0, + freeQuantity: (row['free_quantity'] as int?) ?? 0, + minBillValue: (row['min_bill_value'] as num? ?? 0).toDouble(), + maxDiscount: (row['max_discount'] as num?)?.toDouble(), + validFrom: row['valid_from'] == null + ? null + : DateTime.fromMillisecondsSinceEpoch(row['valid_from']! as int), + validTo: row['valid_to'] == null + ? null + : DateTime.fromMillisecondsSinceEpoch(row['valid_to']! as int), + daysOfWeek: days, + stackable: (row['stackable'] as int? ?? 0) == 1, + priority: (row['priority'] as int?) ?? 100, + isActive: (row['is_active'] as int? ?? 1) == 1, + ); + } +} diff --git a/lib/data/repositories/sync_repository_impl.dart b/lib/data/repositories/sync_repository_impl.dart index 91aae00..0a19da9 100644 --- a/lib/data/repositories/sync_repository_impl.dart +++ b/lib/data/repositories/sync_repository_impl.dart @@ -333,6 +333,15 @@ class SyncRepositoryImpl implements SyncRepository { }, 'subtotal': t.cart.subtotal, 'discount': t.cart.billDiscountTotal + t.cart.lineDiscountTotal, + 'promos': [ + for (final applied in t.cart.appliedPromos) + { + 'id': applied.promo.id, + 'name': applied.promo.name, + 'type': applied.promo.type.name, + 'amount': applied.amount, + }, + ], 'tax': t.cart.taxAmount, 'round_off': t.cart.roundOff, 'total': t.total, diff --git a/lib/domain/entities/cart.dart b/lib/domain/entities/cart.dart index 59d0a35..09626e2 100644 --- a/lib/domain/entities/cart.dart +++ b/lib/domain/entities/cart.dart @@ -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 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 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? 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 get props => - [lines, customer, billDiscount, pointsRedeemed, note]; + [lines, customer, billDiscount, pointsRedeemed, note, appliedPromos]; } diff --git a/lib/domain/entities/promo.dart b/lib/domain/entities/promo.dart new file mode 100644 index 0000000..7787e4b --- /dev/null +++ b/lib/domain/entities/promo.dart @@ -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 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? 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 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 get props => [promo.id, amount]; +} diff --git a/lib/domain/services/promo_engine.dart b/lib/domain/services/promo_engine.dart new file mode 100644 index 0000000..e50abd4 --- /dev/null +++ b/lib/domain/services/promo_engine.dart @@ -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 evaluate({ + required Cart cart, + required List promos, + required DateTime at, + }) { + if (cart.isEmpty || promos.isEmpty) return const []; + + final subtotal = cart.subtotal; + + final eligible = []; + 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 = [ + ...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 _capped(List applied, double ceiling) { + final result = []; + 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; + } +} diff --git a/lib/presentation/modules/screens/promos_view.dart b/lib/presentation/modules/screens/promos_view.dart index 07c2e55..38df98a 100644 --- a/lib/presentation/modules/screens/promos_view.dart +++ b/lib/presentation/modules/screens/promos_view.dart @@ -1,202 +1,304 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../app/providers.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; +import '../../../core/utils/formatters.dart'; +import '../../../domain/entities/promo.dart'; +import '../../../domain/entities/store_account.dart'; +import '../../auth/providers/auth_controller.dart'; +import '../../../core/widgets/empty_state.dart'; import '../widgets/module_widgets.dart'; +import '../widgets/promo_editor_dialog.dart'; /// Discount rules and campaigns. -class PromosView extends StatefulWidget { +/// +/// This was a mockup: three hardcoded rows with a toggle that changed nothing, +/// and no promo code anywhere in the domain or data layers. A cashier looking +/// at it would reasonably conclude promotions were running. They were not. +class PromosView extends ConsumerWidget { const PromosView({super.key}); @override - State createState() => _PromosViewState(); -} + Widget build(BuildContext context, WidgetRef ref) { + final promosAsync = ref.watch(promosProvider); + final isAdmin = ref.watch(currentUserProvider)?.role == StaffRole.admin; -class _PromosViewState extends State { - final Set _enabled = {'WEEKEND10', 'DAIRY5', 'FESTIVE'}; - - static const _campaigns = [ - ( - 'WEEKEND10', - 'Weekend Saver', - '10% off bills above ₹500', - 'Sat–Sun', - 412, - AppColors.primary, - ), - ( - 'DAIRY5', - 'Dairy Days', - '5% off all dairy products', - 'Ends 31 Aug', - 286, - AppColors.info, - ), - ( - 'FESTIVE', - 'Festive Bonus', - 'Double loyalty points', - 'Ends 15 Sep', - 178, - AppColors.tierGold, - ), - ( - 'NEWCUST', - 'First Purchase', - '₹50 off the first bill', - 'Always on', - 94, - AppColors.success, - ), - ]; - - @override - Widget build(BuildContext context) { return ModulePage( children: [ - Wrap( - spacing: AppSpacing.lg, - runSpacing: AppSpacing.lg, - children: [ - StatTile( - label: 'Active Campaigns', - value: '${_enabled.length}', - icon: Icons.campaign_rounded, - caption: 'of ${_campaigns.length} configured', - ), - const StatTile( - label: 'Redemptions', - value: '970', - icon: Icons.confirmation_number_rounded, - color: AppColors.info, - caption: 'this month', - ), - const StatTile( - label: 'Discount Given', - value: '₹48,240', - icon: Icons.local_offer_rounded, - color: AppColors.warning, - caption: '2.6% of sales', - ), - const StatTile( - label: 'Incremental Sales', - value: '₹2.14L', - icon: Icons.trending_up_rounded, - color: AppColors.success, - delta: '+18%', - caption: 'attributed', - ), - ], - ), - const SizedBox(height: AppSpacing.lg), - - PanelCard( - title: 'Campaigns', - subtitle: 'Toggle a rule to apply it at the till immediately', - action: FilledButton.icon( - onPressed: () {}, - icon: const Icon(Icons.add_rounded, size: 18), - label: const Text('New campaign'), - style: FilledButton.styleFrom( - backgroundColor: AppColors.primary, - minimumSize: const Size(0, 40), - ), + promosAsync.when( + loading: () => const Padding( + padding: EdgeInsets.all(AppSpacing.xxl), + child: Center(child: CircularProgressIndicator()), ), - child: Column( - mainAxisSize: MainAxisSize.min, + error: (e, _) => Text('Could not load campaigns: $e'), + data: (promos) => Column( children: [ - for (final c in _campaigns) - Container( - margin: const EdgeInsets.only(bottom: AppSpacing.sm), - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppColors.surfaceAlt, - borderRadius: AppRadius.brMd, - border: Border.all(color: AppColors.border), - ), - // Wrap prevents collision when the panel is narrow. - child: Wrap( - alignment: WrapAlignment.spaceBetween, - crossAxisAlignment: WrapCrossAlignment.center, - spacing: AppSpacing.md, - runSpacing: AppSpacing.sm, - children: [ - SizedBox( - width: 320, - child: Row( - children: [ - Container( - width: 38, - height: 38, - decoration: BoxDecoration( - color: c.$6.withValues(alpha: 0.12), - borderRadius: AppRadius.brSm, - ), - child: Icon(Icons.sell_rounded, - size: 18, color: c.$6,), - ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - c.$2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - Text( - c.$3, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 12.5, - color: AppColors.textSecondary, - ), - ), - ], - ), - ), - ], - ), - ), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - TagChip(c.$1, color: c.$6), - const SizedBox(width: AppSpacing.sm), - TagChip(c.$4, color: AppColors.textSecondary), - const SizedBox(width: AppSpacing.sm), - Text( - '${c.$5} used', - style: const TextStyle( - fontSize: 12, - color: AppColors.textTertiary, - ), - ), - const SizedBox(width: AppSpacing.sm), - Switch( - value: _enabled.contains(c.$1), - onChanged: (v) => setState(() { - if (v) { - _enabled.add(c.$1); - } else { - _enabled.remove(c.$1); - } - }), - ), - ], - ), - ], - ), - ), + _summary(promos), + const SizedBox(height: AppSpacing.lg), + _campaignList(context, ref, promos, isAdmin: isAdmin), ], ), ), ], ); } + + Widget _summary(List promos) { + final live = promos.where((p) => p.isLiveAt(DateTime.now())).length; + final scheduled = promos + .where((p) => + p.isActive && + p.validFrom != null && + p.validFrom!.isAfter(DateTime.now()),) + .length; + + return Row( + children: [ + Expanded( + child: StatTile( + label: 'Running now', + value: '$live', + icon: Icons.play_circle_outline_rounded, + color: AppColors.success, + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: StatTile( + label: 'Scheduled', + value: '$scheduled', + icon: Icons.schedule_rounded, + color: AppColors.info, + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: StatTile( + label: 'Paused', + value: '${promos.where((p) => !p.isActive).length}', + icon: Icons.pause_circle_outline_rounded, + color: AppColors.textTertiary, + ), + ), + ], + ); + } + + Widget _campaignList( + BuildContext context, + WidgetRef ref, + List promos, { + required bool isAdmin, + }) { + return PanelCard( + title: 'Campaigns', + subtitle: promos.isEmpty + ? 'Nothing running. Add a campaign and the till applies it ' + 'automatically.' + : 'Applied automatically at the till, in priority order', + action: isAdmin + ? FilledButton.icon( + onPressed: () => showPromoEditor(context), + icon: const Icon(Icons.add_rounded, size: 18), + label: const Text('New campaign'), + style: FilledButton.styleFrom( + backgroundColor: AppColors.primary, + minimumSize: const Size(0, 40), + ), + ) + : null, + child: promos.isEmpty + ? const EmptyState( + title: 'No campaigns yet', + message: 'A campaign here is applied to every bill that ' + 'qualifies, without the cashier doing anything.', + emoji: '🏷️', + compact: true, + ) + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final promo in promos) + _PromoRow(promo: promo, isAdmin: isAdmin), + ], + ), + ); + } +} + +class _PromoRow extends ConsumerWidget { + const _PromoRow({required this.promo, required this.isAdmin}); + + final Promo promo; + final bool isAdmin; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final live = promo.isLiveAt(DateTime.now()); + + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.surfaceAlt, + borderRadius: AppRadius.brMd, + border: Border.all(color: AppColors.border), + ), + // Wrap prevents collision when the panel is narrow. + child: Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: AppSpacing.md, + runSpacing: AppSpacing.sm, + children: [ + SizedBox( + width: 360, + child: Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: (live ? AppColors.success : AppColors.textTertiary) + .withValues(alpha: 0.12), + borderRadius: AppRadius.brSm, + ), + child: Icon( + Icons.sell_rounded, + size: 18, + color: live ? AppColors.success : AppColors.textTertiary, + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + promo.name, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w700), + ), + Text( + promo.summary, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 12.5, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ), + + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: AppSpacing.sm, + children: [ + if (promo.stackable) + const TagChip('Stacks', color: AppColors.info), + // The distinction a shop actually needs: switched on, but out of + // its date range or wrong day, is not the same as switched off. + if (promo.isActive && !live) + const TagChip('Not today', color: AppColors.warning), + Text( + _window(promo), + style: const TextStyle( + fontSize: 12, + color: AppColors.textTertiary, + ), + ), + if (isAdmin) ...[ + Switch( + value: promo.isActive, + onChanged: (v) async { + await ref + .read(localStoreProvider) + .promos + .setActive(promo.id, active: v); + ref + ..invalidate(promosProvider) + ..invalidate(activePromosProvider); + }, + ), + IconButton( + tooltip: 'Edit', + icon: const Icon(Icons.edit_outlined, size: 17), + onPressed: () => showPromoEditor(context, existing: promo), + ), + IconButton( + tooltip: 'Delete', + icon: const Icon(Icons.delete_outline_rounded, size: 17), + color: AppColors.danger, + onPressed: () => _confirmDelete(context, ref), + ), + ], + ], + ), + ], + ), + ); + } + + String _window(Promo promo) { + final from = promo.validFrom; + final to = promo.validTo; + + if (from == null && to == null) { + return promo.daysOfWeek.isEmpty ? 'Always' : _days(promo.daysOfWeek); + } + + final range = [ + if (from != null) 'from ${Formatters.date(from)}', + if (to != null) 'to ${Formatters.date(to)}', + ].join(' '); + + return promo.daysOfWeek.isEmpty + ? range + : '$range · ${_days(promo.daysOfWeek)}'; + } + + static String _days(Set days) { + const names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + final sorted = days.toList()..sort(); + return sorted.map((d) => names[d - 1]).join(', '); + } + + Future _confirmDelete(BuildContext context, WidgetRef ref) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text('Delete "${promo.name}"?'), + content: const Text( + 'Bills already rung keep the discount they were given — a bill ' + 'stores the amount, not a link to the campaign. Only future sales ' + 'are affected.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + style: TextButton.styleFrom(foregroundColor: AppColors.danger), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed != true) return; + + await ref.read(localStoreProvider).promos.delete(promo.id); + ref + ..invalidate(promosProvider) + ..invalidate(activePromosProvider); + } } diff --git a/lib/presentation/modules/widgets/promo_editor_dialog.dart b/lib/presentation/modules/widgets/promo_editor_dialog.dart new file mode 100644 index 0000000..803ca23 --- /dev/null +++ b/lib/presentation/modules/widgets/promo_editor_dialog.dart @@ -0,0 +1,464 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../app/providers.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/primary_button.dart'; +import '../../../data/local/promo_dao.dart'; +import '../../../domain/entities/product.dart'; +import '../../../domain/entities/promo.dart'; +import '../../pos/providers/catalog_providers.dart'; + +/// Creates or edits a campaign. +Future showPromoEditor(BuildContext context, {Promo? existing}) => + showDialog( + context: context, + builder: (_) => _PromoEditor(existing: existing), + ); + +class _PromoEditor extends ConsumerStatefulWidget { + const _PromoEditor({this.existing}); + + final Promo? existing; + + @override + ConsumerState<_PromoEditor> createState() => _PromoEditorState(); +} + +class _PromoEditorState extends ConsumerState<_PromoEditor> { + final _formKey = GlobalKey(); + + late final TextEditingController _name; + late final TextEditingController _value; + late final TextEditingController _minBill; + late final TextEditingController _maxDiscount; + late final TextEditingController _buy; + late final TextEditingController _free; + + late PromoType _type; + String? _targetId; + String? _targetLabel; + DateTime? _from; + DateTime? _to; + late Set _days; + late bool _stackable; + + bool _saving = false; + String? _error; + + bool get _isNew => widget.existing == null; + + @override + void initState() { + super.initState(); + final p = widget.existing; + + _name = TextEditingController(text: p?.name ?? ''); + _value = TextEditingController(text: p == null ? '' : _num(p.value)); + _minBill = TextEditingController( + text: (p == null || p.minBillValue == 0) ? '' : _num(p.minBillValue), + ); + _maxDiscount = TextEditingController( + text: p?.maxDiscount == null ? '' : _num(p!.maxDiscount!), + ); + _buy = TextEditingController(text: '${p?.buyQuantity ?? 2}'); + _free = TextEditingController(text: '${p?.freeQuantity ?? 1}'); + + _type = p?.type ?? PromoType.percentOffBill; + _targetId = p?.targetId; + _targetLabel = p?.targetLabel; + _from = p?.validFrom; + _to = p?.validTo; + _days = {...?p?.daysOfWeek}; + _stackable = p?.stackable ?? false; + } + + static String _num(double v) => + v == v.roundToDouble() ? v.toStringAsFixed(0) : v.toStringAsFixed(2); + + @override + void dispose() { + for (final c in [_name, _value, _minBill, _maxDiscount, _buy, _free]) { + c.dispose(); + } + super.dispose(); + } + + Future _save() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + + if (_type.needsTarget && (_targetId ?? '').isEmpty) { + setState(() => _error = 'Choose what this campaign applies to.'); + return; + } + + setState(() { + _saving = true; + _error = null; + }); + + final promo = Promo( + id: widget.existing?.id ?? '', + name: _name.text, + type: _type, + value: double.tryParse(_value.text.trim()) ?? 0, + targetId: _targetId, + targetLabel: _targetLabel, + buyQuantity: int.tryParse(_buy.text.trim()) ?? 0, + freeQuantity: int.tryParse(_free.text.trim()) ?? 0, + minBillValue: double.tryParse(_minBill.text.trim()) ?? 0, + maxDiscount: double.tryParse(_maxDiscount.text.trim()), + validFrom: _from, + validTo: _to, + daysOfWeek: _days, + stackable: _stackable, + priority: widget.existing?.priority ?? 100, + isActive: widget.existing?.isActive ?? true, + ); + + try { + await ref.read(localStoreProvider).promos.save(promo); + ref + ..invalidate(promosProvider) + ..invalidate(activePromosProvider); + if (mounted) Navigator.of(context).pop(); + } on PromoException catch (e) { + setState(() { + _saving = false; + _error = e.message; + }); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(_isNew ? 'New campaign' : 'Edit campaign'), + content: SizedBox( + width: 540, + child: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + controller: _name, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Campaign name', + hintText: 'Weekend Saver', + helperText: 'Shown on the bill when it applies', + ), + validator: (v) => (v == null || v.trim().isEmpty) + ? 'A campaign needs a name' + : null, + ), + const SizedBox(height: AppSpacing.md), + + DropdownButtonFormField( + initialValue: _type, + isExpanded: true, + decoration: const InputDecoration(labelText: 'What it does'), + items: [ + for (final type in PromoType.values) + DropdownMenuItem( + value: type, + child: Text(type.label, overflow: TextOverflow.ellipsis), + ), + ], + onChanged: (v) => setState(() { + _type = v ?? _type; + // The old target is meaningless under a different type — a + // category id on a product promo would silently never fire. + _targetId = null; + _targetLabel = null; + }), + ), + const SizedBox(height: AppSpacing.md), + + if (_type.needsTarget) ...[ + _targetField(), + const SizedBox(height: AppSpacing.md), + ], + + if (_type == PromoType.buyXGetY) + _buyGetFields() + else + _valueField(), + + const SizedBox(height: AppSpacing.md), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _minBill, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Minimum bill', + hintText: '0', + prefixText: '₹ ', + isDense: true, + ), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: TextFormField( + controller: _maxDiscount, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Cap the discount', + hintText: 'No cap', + prefixText: '₹ ', + isDense: true, + ), + ), + ), + ], + ), + if (_type.isPercentage) + const Padding( + padding: EdgeInsets.only(top: AppSpacing.xs), + child: Text( + 'A cap is worth setting on a percentage: without one, an ' + 'unusually large trolley gives away more than the ' + 'campaign was costed for.', + style: TextStyle( + fontSize: 11.5, + color: AppColors.textTertiary, + height: 1.4, + ), + ), + ), + + const SizedBox(height: AppSpacing.lg), + _dateRange(), + + const SizedBox(height: AppSpacing.md), + _dayPicker(), + + const SizedBox(height: AppSpacing.sm), + SwitchListTile( + contentPadding: EdgeInsets.zero, + value: _stackable, + onChanged: (v) => setState(() => _stackable = v), + title: const Text('Can combine with other campaigns'), + subtitle: const Text( + 'Off by default. Only the best non-combining campaign ' + 'applies to a bill — two percentages compounding produce a ' + 'discount nobody costed.', + style: TextStyle(fontSize: 11.5, height: 1.4), + ), + ), + + if (_error != null) ...[ + const SizedBox(height: AppSpacing.md), + Text( + _error!, + style: const TextStyle( + color: AppColors.danger, + fontSize: 12.5, + ), + ), + ], + ], + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: _saving ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + PrimaryButton( + label: _isNew ? 'Create' : 'Save', + expanded: false, + busy: _saving, + onPressed: _save, + ), + ], + ); + } + + Widget _valueField() => TextFormField( + controller: _value, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: _type.isPercentage ? 'Percentage off' : 'Amount off', + suffixText: _type.isPercentage ? '%' : null, + prefixText: _type.isPercentage ? null : '₹ ', + ), + validator: (v) { + final parsed = double.tryParse((v ?? '').trim()); + if (parsed == null || parsed <= 0) { + return 'A campaign must give something away'; + } + if (_type.isPercentage && parsed > 100) { + // Over 100% is a refund with extra steps. + return 'A percentage cannot exceed 100'; + } + return null; + }, + ); + + Widget _buyGetFields() => Row( + children: [ + Expanded( + child: TextFormField( + controller: _buy, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration(labelText: 'Buy', isDense: true), + validator: (v) => (int.tryParse(v ?? '') ?? 0) < 1 + ? 'At least one' + : null, + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: TextFormField( + controller: _free, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: + const InputDecoration(labelText: 'Get free', isDense: true), + validator: (v) => (int.tryParse(v ?? '') ?? 0) < 1 + ? 'At least one' + : null, + ), + ), + ], + ); + + Widget _targetField() { + if (_type == PromoType.percentOffCategory) { + return DropdownButtonFormField( + initialValue: _targetId, + isExpanded: true, + decoration: const InputDecoration(labelText: 'Category'), + items: [ + for (final category in ProductCategory.values) + DropdownMenuItem( + value: category.name, + child: Text('${category.emoji} ${category.label}'), + ), + ], + onChanged: (v) => setState(() { + _targetId = v; + _targetLabel = ProductCategory.values + .where((c) => c.name == v) + .map((c) => c.label) + .firstOrNull; + }), + ); + } + + // Product picker, for both the product promo and buy-X-get-Y. + final products = ref.watch(allProductsProvider).value ?? const []; + + return DropdownButtonFormField( + initialValue: + products.any((p) => p.id == _targetId) ? _targetId : null, + isExpanded: true, + decoration: InputDecoration( + labelText: 'Product', + helperText: products.isEmpty + ? 'Import the catalogue first — there is nothing to pick' + : null, + ), + items: [ + for (final product in products) + DropdownMenuItem( + value: product.id, + child: Text(product.name, overflow: TextOverflow.ellipsis), + ), + ], + onChanged: (v) => setState(() { + _targetId = v; + _targetLabel = + products.where((p) => p.id == v).map((p) => p.name).firstOrNull; + }), + ); + } + + Widget _dateRange() => Row( + children: [ + Expanded(child: _dateButton('Starts', _from, (d) => _from = d)), + const SizedBox(width: AppSpacing.md), + Expanded(child: _dateButton('Ends', _to, (d) => _to = d)), + ], + ); + + Widget _dateButton(String label, DateTime? value, void Function(DateTime?) set) { + return OutlinedButton( + onPressed: () async { + final picked = await showDatePicker( + context: context, + initialDate: value ?? DateTime.now(), + firstDate: DateTime(2024), + lastDate: DateTime(2100), + ); + if (picked != null) setState(() => set(picked)); + }, + onLongPress: () => setState(() => set(null)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 11, + color: AppColors.textTertiary, + ), + ), + Text( + value == null + ? 'Any time' + : '${value.day}/${value.month}/${value.year}', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ], + ), + ); + } + + Widget _dayPicker() { + const names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Days it runs', + style: TextStyle(fontSize: 12, color: AppColors.textSecondary), + ), + const SizedBox(height: AppSpacing.xs), + Wrap( + spacing: AppSpacing.xs, + children: [ + for (var day = 1; day <= 7; day++) + FilterChip( + label: Text(names[day - 1]), + selected: _days.contains(day), + onSelected: (on) => setState(() { + on ? _days.add(day) : _days.remove(day); + }), + ), + ], + ), + const Padding( + padding: EdgeInsets.only(top: AppSpacing.xs), + child: Text( + 'Pick none to run every day.', + style: TextStyle(fontSize: 11.5, color: AppColors.textTertiary), + ), + ), + ], + ); + } +} diff --git a/lib/presentation/pos/providers/cart_controller.dart b/lib/presentation/pos/providers/cart_controller.dart index 5fdb0f3..b9b65a3 100644 --- a/lib/presentation/pos/providers/cart_controller.dart +++ b/lib/presentation/pos/providers/cart_controller.dart @@ -9,9 +9,11 @@ import '../../../core/services/sound_service.dart'; import '../../../domain/entities/cart.dart'; import '../../../domain/entities/customer.dart'; import '../../../domain/entities/product.dart'; +import '../../../domain/entities/promo.dart'; import '../../../domain/entities/transaction.dart'; import '../../../domain/repositories/product_repository.dart'; import '../../../domain/repositories/transaction_repository.dart'; +import '../../../domain/services/promo_engine.dart'; /// Transient feedback for the scan toast — never a blocking dialog. enum ScanOutcome { added, incremented, notFound, outOfStock } @@ -43,9 +45,13 @@ class CartController extends StateNotifier { required TransactionRepository transactions, required SoundService sound, required this.onFeedback, + List promos = const [], + DateTime Function()? clock, }) : _products = products, _transactions = transactions, _sound = sound, + _promos = promos, + _now = clock ?? DateTime.now, super(Cart.empty); final ProductRepository _products; @@ -53,8 +59,29 @@ class CartController extends StateNotifier { final SoundService _sound; final void Function(ScanFeedback) onFeedback; + /// Campaigns live right now. Re-evaluated after every change to the bill, + /// because whether one fires depends on what is in it. + final List _promos; + final DateTime Function() _now; + static const _uuid = Uuid(); + /// Applies the campaign rules to [next] and stores the result. + /// + /// Every mutation goes through here rather than assigning `state` directly, + /// so a promo cannot be left applied after the line that earned it is + /// removed — which is how a shopper gets a discount for an item they put + /// back. + void _commit(Cart next) { + state = next.copyWith( + appliedPromos: PromoEngine.evaluate( + cart: next, + promos: _promos, + at: _now(), + ), + ); + } + /// Snapshots for undo — capped so memory can't grow unbounded on a terminal /// that runs for days. final List _undoStack = []; @@ -105,18 +132,18 @@ class CartController extends StateNotifier { } if (existing == null) { - state = state.copyWith(lines: [ + _commit(state.copyWith(lines: [ ...state.lines, CartLine( product: product, quantity: quantity, addedAt: DateTime.now(), ), - ],); + ],),); } else { - state = state.copyWith( + _commit(state.copyWith( lines: _replace(existing.copyWith(quantity: requested)), - ); + ),); } _clampRedemption(); @@ -171,7 +198,7 @@ class CartController extends StateNotifier { } _push(); - state = state.copyWith(lines: _replace(line.copyWith(quantity: capped))); + _commit(state.copyWith(lines: _replace(line.copyWith(quantity: capped)))); _clampRedemption(); } @@ -190,9 +217,9 @@ class CartController extends StateNotifier { void removeLine(String productId) { if (!state.contains(productId)) return; _push(); - state = state.copyWith( + _commit(state.copyWith( lines: state.lines.where((l) => l.product.id != productId).toList(), - ); + ),); _clampRedemption(); } @@ -200,14 +227,14 @@ class CartController extends StateNotifier { final line = state.lineFor(productId); if (line == null) return; _push(); - state = state.copyWith(lines: _replace(line.copyWith(discount: discount))); + _commit(state.copyWith(lines: _replace(line.copyWith(discount: discount)))); _clampRedemption(); } // ------------------------------------------------------------ Bill level void applyBillDiscount(Discount discount) { _push(); - state = state.copyWith(billDiscount: discount); + _commit(state.copyWith(billDiscount: discount)); _clampRedemption(); } @@ -215,9 +242,11 @@ class CartController extends StateNotifier { void attachCustomer(Customer? customer) { _push(); - state = customer == null - ? state.copyWith(clearCustomer: true, pointsRedeemed: 0) - : state.copyWith(customer: customer); + _commit( + customer == null + ? state.copyWith(clearCustomer: true, pointsRedeemed: 0) + : state.copyWith(customer: customer), + ); _clampRedemption(); } @@ -275,7 +304,9 @@ class CartController extends StateNotifier { Future resume(ParkedBill bill) async { await _transactions.removeParked(bill.id); _undoStack.clear(); - state = bill.cart; + // Re-evaluated rather than restored: a campaign that has since ended must + // not be honoured just because the bill was parked while it was running. + _commit(bill.cart); } List _replace(CartLine updated) => [ @@ -293,6 +324,10 @@ final cartControllerProvider = products: ref.watch(productRepositoryProvider), transactions: ref.watch(transactionRepositoryProvider), sound: ref.watch(soundServiceProvider), + // Watched, so editing a campaign in Settings takes effect at the till + // without a restart. An empty list until they load is correct — no promo + // is safer than a stale one. + promos: ref.watch(activePromosProvider).value ?? const [], onFeedback: (feedback) => ref.read(scanFeedbackProvider.notifier).state = feedback, ); diff --git a/lib/presentation/pos/widgets/billing_panel.dart b/lib/presentation/pos/widgets/billing_panel.dart index 0ecea36..bd11bde 100644 --- a/lib/presentation/pos/widgets/billing_panel.dart +++ b/lib/presentation/pos/widgets/billing_panel.dart @@ -242,6 +242,17 @@ class _Summary extends ConsumerWidget { valueColor: AppColors.success, ), + // Named individually rather than lumped into one "Promotions" line: + // a shopper who came in for a specific offer needs to see it applied, + // and a cashier being asked "did the weekend deal come off?" needs to + // answer without opening a report. + for (final applied in cart.appliedPromos) + _Row( + label: applied.promo.name, + value: '-${Formatters.money(applied.amount)}', + valueColor: AppColors.success, + ), + _Row( label: 'GST', value: Formatters.money(cart.taxAmount), diff --git a/test/unit/migration_test.dart b/test/unit/migration_test.dart index 436b747..884c5c2 100644 --- a/test/unit/migration_test.dart +++ b/test/unit/migration_test.dart @@ -139,7 +139,7 @@ void main() { await AppDatabase.instance.open(overridePath: dbPath); final db = AppDatabase.instance.db; - expect(await db.getVersion(), 5); + expect(await db.getVersion(), 7); final rows = await db.query('day_archive'); expect(rows, hasLength(1)); @@ -154,6 +154,11 @@ void main() { // so an existing shop is never handed accounts it did not create. final staff = await db.query('staff'); expect(staff, isEmpty); + + // v6 adds campaigns. Same rule: the table exists, and an upgraded shop is + // not handed promotions it never created. + final promos = await db.query('promos'); + expect(promos, isEmpty); expect(row['bill_count'], 12); expect(row['gross_sales'], 8450.0); expect(row['tax_collected'], 620.5); diff --git a/test/unit/promo_engine_test.dart b/test/unit/promo_engine_test.dart new file mode 100644 index 0000000..8927c75 --- /dev/null +++ b/test/unit/promo_engine_test.dart @@ -0,0 +1,449 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:nearle_pos/domain/entities/cart.dart'; +import 'package:nearle_pos/domain/entities/product.dart'; +import 'package:nearle_pos/domain/entities/promo.dart'; +import 'package:nearle_pos/domain/services/promo_engine.dart'; + +/// Campaigns give money away automatically, so the rules that decide how much +/// are the ones worth pinning down hardest. +void main() { + Product product({ + String id = 'p1', + ProductCategory category = ProductCategory.beverages, + double price = 100, + double gstRate = 0.18, + }) => + Product( + id: id, + name: 'Item $id', + barcode: 'bc-$id', + sku: 'sku-$id', + category: category, + price: price, + stock: 100, + gstRate: gstRate, + ); + + Cart cartOf(List<({Product product, double qty})> items) => Cart( + lines: [ + for (final i in items) + CartLine(product: i.product, quantity: i.qty), + ], + ); + + final monday = DateTime(2026, 8, 3, 11); + + group('amount', () { + test('a percentage off the bill', () { + final cart = cartOf([(product: product(), qty: 3)]); // 300 + const promo = Promo( + id: 'a', + name: '10% off', + type: PromoType.percentOffBill, + value: 10, + ); + + expect(PromoEngine.amountFor(promo: promo, cart: cart), 30); + }); + + test('a flat amount off the bill', () { + final cart = cartOf([(product: product(), qty: 3)]); + const promo = Promo( + id: 'a', + name: '50 off', + type: PromoType.flatOffBill, + value: 50, + ); + + expect(PromoEngine.amountFor(promo: promo, cart: cart), 50); + }); + + test('a category promo touches only that category', () { + final cart = cartOf([ + (product: product(category: ProductCategory.beverages), qty: 2), // 200 + (product: product(id: 'p2', category: ProductCategory.snacks), qty: 3), + ]); + const promo = Promo( + id: 'a', + name: '20% off drinks', + type: PromoType.percentOffCategory, + value: 20, + targetId: 'beverages', + ); + + expect(PromoEngine.amountFor(promo: promo, cart: cart), 40); + }); + + test('a product promo touches only that product', () { + final cart = cartOf([ + (product: product(), qty: 2), + (product: product(id: 'p2'), qty: 4), + ]); + const promo = Promo( + id: 'a', + name: '50% off p2', + type: PromoType.percentOffProduct, + value: 50, + targetId: 'p2', + ); + + expect(PromoEngine.amountFor(promo: promo, cart: cart), 200); + }); + + test('a promo whose target is not in the bill is worth nothing', () { + final cart = cartOf([(product: product(), qty: 2)]); + const promo = Promo( + id: 'a', + name: '20% off Dairy', + type: PromoType.percentOffCategory, + value: 20, + targetId: 'dairy', + ); + + expect(PromoEngine.amountFor(promo: promo, cart: cart), 0); + }); + + test('a cap stops a large trolley giving away more than was costed', () { + final cart = cartOf([(product: product(), qty: 50)]); // 5000 + const promo = Promo( + id: 'a', + name: '20% off, max 200', + type: PromoType.percentOffBill, + value: 20, + maxDiscount: 200, + ); + + expect(PromoEngine.amountFor(promo: promo, cart: cart), 200); + }); + }); + + group('buy X get Y', () { + const promo = Promo( + id: 'bxgy', + name: 'Buy 2 get 1', + type: PromoType.buyXGetY, + targetId: 'p1', + buyQuantity: 2, + freeQuantity: 1, + ); + + test('gives nothing until a whole group is in the bill', () { + for (final qty in [1.0, 2.0]) { + final cart = cartOf([(product: product(), qty: qty)]); + expect(PromoEngine.amountFor(promo: promo, cart: cart), 0, + reason: '$qty items should not earn a free one',); + } + }); + + test('gives one free per completed group, and no more', () { + // Three earns one. Five still earns one — the fifth has not paid for a + // second group. + expect( + PromoEngine.amountFor( + promo: promo, + cart: cartOf([(product: product(), qty: 3)]), + ), + 100, + ); + expect( + PromoEngine.amountFor( + promo: promo, + cart: cartOf([(product: product(), qty: 5)]), + ), + 100, + ); + expect( + PromoEngine.amountFor( + promo: promo, + cart: cartOf([(product: product(), qty: 6)]), + ), + 200, + ); + }); + + test('prices the free unit at what is actually being charged', () { + // A line already carrying a manual discount must not refund more than it + // took in the first place. + final cart = Cart( + lines: [ + CartLine( + product: product(), + quantity: 3, + discount: const Discount(type: DiscountType.percentage, value: 50), + ), + ], + ); + + expect(PromoEngine.amountFor(promo: promo, cart: cart), 50); + }); + + test('a malformed buy-X-get-Y is worth nothing rather than everything', () { + final cart = cartOf([(product: product(), qty: 10)]); + const broken = Promo( + id: 'b', + name: 'Buy 0 get 0', + type: PromoType.buyXGetY, + targetId: 'p1', + ); + + expect(PromoEngine.amountFor(promo: broken, cart: cart), 0); + }); + }); + + group('eligibility', () { + final cart = Cart( + lines: [CartLine(product: product(), quantity: 3)], + ); // 300 + + List run(List promos, {DateTime? at}) => + PromoEngine.evaluate(cart: cart, promos: promos, at: at ?? monday); + + test('an inactive campaign never fires', () { + expect( + run([ + const Promo( + id: 'a', + name: 'Paused', + type: PromoType.percentOffBill, + value: 10, + isActive: false, + ), + ]), + isEmpty, + ); + }); + + test('a campaign outside its dates never fires', () { + const promo = Promo( + id: 'a', + name: 'August', + type: PromoType.percentOffBill, + value: 10, + ); + + final august = promo.copyWith( + validFrom: DateTime(2026, 8), + validTo: DateTime(2026, 8, 31), + ); + + expect(run([august], at: DateTime(2026, 7, 31, 23)), isEmpty); + expect(run([august], at: DateTime(2026, 8, 15)), hasLength(1)); + + // Inclusive of the closing day: a campaign "to the 31st" runs all of it. + expect(run([august], at: DateTime(2026, 8, 31, 22)), hasLength(1)); + expect(run([august], at: DateTime(2026, 9, 1)), isEmpty); + }); + + test('a weekday-restricted campaign fires only on those days', () { + const weekend = Promo( + id: 'a', + name: 'Weekends', + type: PromoType.percentOffBill, + value: 10, + daysOfWeek: {6, 7}, + ); + + expect(run([weekend], at: DateTime(2026, 8, 3)), isEmpty); // Monday + expect(run([weekend], at: DateTime(2026, 8, 8)), hasLength(1)); // Sat + expect(run([weekend], at: DateTime(2026, 8, 9)), hasLength(1)); // Sun + }); + + test('a minimum bill value is enforced', () { + const promo = Promo( + id: 'a', + name: 'Over 500', + type: PromoType.flatOffBill, + value: 50, + minBillValue: 500, + ); + + expect(run([promo]), isEmpty, reason: 'the bill is only 300'); + }); + }); + + group('stacking', () { + final cart = Cart( + lines: [CartLine(product: product(), quantity: 10)], + ); // 1000 + + List run(List promos) => + PromoEngine.evaluate(cart: cart, promos: promos, at: monday); + + test('only the best exclusive campaign applies', () { + // Two stacking percentages compound into a discount nobody costed, and + // the shop finds out at the end of the month. + final applied = run(const [ + Promo(id: 'a', name: '5%', type: PromoType.percentOffBill, value: 5), + Promo(id: 'b', name: '15%', type: PromoType.percentOffBill, value: 15), + Promo(id: 'c', name: '10%', type: PromoType.percentOffBill, value: 10), + ]); + + expect(applied, hasLength(1)); + expect(applied.single.promo.id, 'b'); + expect(applied.single.amount, 150); + }); + + test('the best means best for the shopper, not highest percentage', () { + final applied = run(const [ + Promo(id: 'pct', name: '5%', type: PromoType.percentOffBill, value: 5), + Promo( + id: 'flat', + name: '200 off', + type: PromoType.flatOffBill, + value: 200, + ), + ]); + + expect(applied.single.promo.id, 'flat'); + expect(applied.single.amount, 200); + }); + + test('stackable campaigns all apply, alongside one exclusive', () { + final applied = run(const [ + Promo( + id: 's1', + name: 'Stack 50', + type: PromoType.flatOffBill, + value: 50, + stackable: true, + priority: 10, + ), + Promo( + id: 's2', + name: 'Stack 30', + type: PromoType.flatOffBill, + value: 30, + stackable: true, + priority: 20, + ), + Promo(id: 'e1', name: '10%', type: PromoType.percentOffBill, value: 10), + Promo(id: 'e2', name: '5%', type: PromoType.percentOffBill, value: 5), + ]); + + expect(applied.map((a) => a.promo.id), ['s1', 's2', 'e1']); + expect(applied.fold(0, (s, a) => s + a.amount), 180); + }); + + test('priority breaks a tie between equal exclusive campaigns', () { + final applied = run(const [ + Promo( + id: 'low', + name: 'A', + type: PromoType.flatOffBill, + value: 100, + priority: 50, + ), + Promo( + id: 'high', + name: 'B', + type: PromoType.flatOffBill, + value: 100, + priority: 10, + ), + ]); + + expect(applied.single.promo.id, 'high'); + }); + + test('no combination can drive the bill below zero', () { + // The one outcome that must be impossible: a sale that becomes a payout. + final applied = run(const [ + Promo( + id: 'a', + name: 'Huge', + type: PromoType.flatOffBill, + value: 900, + stackable: true, + ), + Promo( + id: 'b', + name: 'Also huge', + type: PromoType.flatOffBill, + value: 900, + stackable: true, + ), + ]); + + final total = applied.fold(0, (s, a) => s + a.amount); + expect(total, lessThanOrEqualTo(1000)); + expect(total, 1000); + }); + }); + + group('on the bill', () { + test('a promo reduces the total and is included in savings', () { + final base = Cart(lines: [CartLine(product: product(), quantity: 10)]); + expect(base.grandTotal, 1000); + + const promo = Promo( + id: 'a', + name: '10% off', + type: PromoType.percentOffBill, + value: 10, + ); + + final withPromo = base.copyWith( + appliedPromos: [const AppliedPromo(promo: promo, amount: 100)], + ); + + expect(withPromo.promoDiscountAmount, 100); + expect(withPromo.billDiscountTotal, 100); + expect(withPromo.grandTotal, 900); + expect(withPromo.totalSavings, 100); + }); + + test('GST is recomputed against the reduced total, not the original', () { + // Prices are GST-inclusive, so a discount reduces the tax collected. Left + // unapportioned the shop would remit tax on money it never took. + final base = Cart(lines: [CartLine(product: product(), quantity: 10)]); + const promo = Promo( + id: 'a', + name: '10% off', + type: PromoType.percentOffBill, + value: 10, + ); + + final withPromo = base.copyWith( + appliedPromos: [const AppliedPromo(promo: promo, amount: 100)], + ); + + expect(withPromo.taxAmount, lessThan(base.taxAmount)); + expect(withPromo.taxAmount, closeTo(base.taxAmount * 0.9, 0.02)); + expect( + withPromo.taxableAmount + withPromo.taxAmount, + closeTo(withPromo.netAmount, 0.02), + ); + }); + + test('a promo and a tier discount both apply, without going negative', () { + final base = Cart(lines: [CartLine(product: product(), quantity: 10)]); + const promo = Promo( + id: 'a', + name: 'Everything free', + type: PromoType.flatOffBill, + value: 5000, + ); + + final withPromo = base.copyWith( + appliedPromos: [const AppliedPromo(promo: promo, amount: 5000)], + ); + + expect(withPromo.billDiscountTotal, 1000); + expect(withPromo.grandTotal, 0); + expect(withPromo.netAmount, greaterThanOrEqualTo(0)); + }); + + test('an empty cart earns nothing', () { + expect( + PromoEngine.evaluate( + cart: Cart.empty, + promos: const [ + Promo(id: 'a', name: 'x', type: PromoType.flatOffBill, value: 50), + ], + at: monday, + ), + isEmpty, + ); + }); + }); +} diff --git a/test/unit/promo_persistence_test.dart b/test/unit/promo_persistence_test.dart new file mode 100644 index 0000000..bb4a679 --- /dev/null +++ b/test/unit/promo_persistence_test.dart @@ -0,0 +1,290 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:nearle_pos/data/datasources/local_store.dart'; +import 'package:nearle_pos/data/datasources/seed_data.dart'; +import 'package:nearle_pos/data/local/promo_dao.dart'; +import 'package:nearle_pos/data/repositories/product_repository_impl.dart'; +import 'package:nearle_pos/data/repositories/customer_repository_impl.dart'; +import 'package:nearle_pos/data/repositories/transaction_repository_impl.dart'; +import 'package:nearle_pos/domain/entities/cart.dart'; +import 'package:nearle_pos/domain/entities/promo.dart'; +import 'package:nearle_pos/domain/entities/transaction.dart'; +import 'package:nearle_pos/domain/services/promo_engine.dart'; +import 'package:nearle_pos/domain/usecases/checkout_sale.dart'; + +void main() { + late LocalStore store; + late PromoDao promos; + + setUpAll(() { + LocalStore.registerSeed( + products: SeedData.products, + customers: SeedData.customers, + ); + }); + + setUp(() async { + store = LocalStore.instance; + await store.reset(withCatalogue: true); + promos = store.promos; + }); + + const weekendSaver = Promo( + id: '', + name: 'Weekend Saver', + type: PromoType.percentOffBill, + value: 10, + minBillValue: 500, + maxDiscount: 200, + daysOfWeek: {6, 7}, + ); + + group('storage', () { + test('a campaign round-trips with every field intact', () async { + final saved = await promos.save(weekendSaver.copyWith( + validFrom: DateTime(2026, 8), + validTo: DateTime(2026, 8, 31), + ),); + + final read = await promos.findById(saved.id); + + expect(read!.name, 'Weekend Saver'); + expect(read.type, PromoType.percentOffBill); + expect(read.value, 10); + expect(read.minBillValue, 500); + expect(read.maxDiscount, 200); + expect(read.daysOfWeek, {6, 7}); + expect(read.validFrom, DateTime(2026, 8)); + expect(read.stackable, isFalse); + expect(read.isActive, isTrue); + }); + + test('editing keeps the id, so nothing points at a stale row', () async { + final saved = await promos.save(weekendSaver); + final edited = await promos.save(saved.copyWith(value: 15)); + + expect(edited.id, saved.id); + expect(await promos.all(), hasLength(1)); + expect(edited.value, 15); + }); + + test('pausing removes it from what the till applies', () async { + final saved = await promos.save(weekendSaver); + + await promos.setActive(saved.id, active: false); + + expect(await promos.all(), hasLength(1)); + expect(await promos.all(activeOnly: true), isEmpty); + }); + }); + + group('validation', () { + test('a campaign must give something away', () async { + await expectLater( + promos.save(const Promo( + id: '', + name: 'Nothing', + type: PromoType.percentOffBill, + ),), + throwsA(isA()), + ); + }); + + test('a percentage over 100 is refused', () async { + // Over 100% is a refund with extra steps. + await expectLater( + promos.save(const Promo( + id: '', + name: 'Too much', + type: PromoType.percentOffBill, + value: 150, + ),), + throwsA(isA()), + ); + }); + + test('a targeted campaign without a target is refused', () async { + // It would save happily and then silently never fire, which is worse + // than an error. + await expectLater( + promos.save(const Promo( + id: '', + name: 'Category promo', + type: PromoType.percentOffCategory, + value: 10, + ),), + throwsA(isA()), + ); + }); + + test('an end date before the start is refused', () async { + await expectLater( + promos.save(weekendSaver.copyWith( + validFrom: DateTime(2026, 9), + validTo: DateTime(2026, 8), + ),), + throwsA(isA()), + ); + }); + + test('a buy-X-get-Y with no quantities is refused', () async { + await expectLater( + promos.save(const Promo( + id: '', + name: 'Broken BOGO', + type: PromoType.buyXGetY, + targetId: 'p1', + ),), + throwsA(isA()), + ); + }); + }); + + group('on a completed sale', () { + test('the discount given is recorded and read back', () async { + // A bill stores the amount, not a link to the campaign, so a promo that + // is later edited or deleted cannot change what a past sale shows. + final products = ProductRepositoryImpl(store); + final transactions = TransactionRepositoryImpl(store); + final checkout = CheckoutSale( + productRepository: products, + customerRepository: CustomerRepositoryImpl(store), + transactionRepository: transactions, + ); + + final milk = (await products.findByBarcode('8901234500011'))!; + final saved = await promos.save(const Promo( + id: '', + name: 'Ten off everything', + type: PromoType.percentOffBill, + value: 10, + ),); + + var cart = Cart(lines: [CartLine(product: milk, quantity: 10)]); + cart = cart.copyWith( + appliedPromos: PromoEngine.evaluate( + cart: cart, + promos: [saved], + at: DateTime.now(), + ), + ); + + expect(cart.appliedPromos, hasLength(1)); + expect(cart.promoDiscountAmount, 62); + final due = cart.grandTotal; + + await checkout( + cart: cart, + payments: [ + PaymentSplit(method: PaymentMethod.cash, amount: due, tendered: due), + ], + cashierName: 'Divya', + terminalId: 'T0TEST', + ); + + final stored = (await transactions.history()).single; + + expect(stored.total, due, + reason: 'the figure charged must not move in storage',); + expect(stored.cart.appliedPromos, hasLength(1)); + expect(stored.cart.appliedPromos.single.promo.name, 'Ten off everything'); + expect(stored.cart.appliedPromos.single.amount, 62); + }); + + test('a promo and a manual discount are not double-counted on read-back', + () async { + // bill_discount on the row already contains the promo. Restoring both at + // full value would discount the bill twice on the way back in — the exact + // shape of the bug that used to overstate synced totals. + final products = ProductRepositoryImpl(store); + final transactions = TransactionRepositoryImpl(store); + final checkout = CheckoutSale( + productRepository: products, + customerRepository: CustomerRepositoryImpl(store), + transactionRepository: transactions, + ); + + final milk = (await products.findByBarcode('8901234500011'))!; + final saved = await promos.save(const Promo( + id: '', + name: 'Fifty off', + type: PromoType.flatOffBill, + value: 50, + ),); + + var cart = Cart( + lines: [CartLine(product: milk, quantity: 10)], + billDiscount: const Discount(type: DiscountType.flat, value: 30), + ); + cart = cart.copyWith( + appliedPromos: PromoEngine.evaluate( + cart: cart, + promos: [saved], + at: DateTime.now(), + ), + ); + + expect(cart.billDiscountTotal, 80, reason: '50 promo + 30 manual'); + final due = cart.grandTotal; + + await checkout( + cart: cart, + payments: [ + PaymentSplit(method: PaymentMethod.cash, amount: due, tendered: due), + ], + cashierName: 'Divya', + terminalId: 'T0TEST', + ); + + final stored = (await transactions.history()).single; + + expect(stored.cart.billDiscountTotal, 80); + expect(stored.cart.promoDiscountAmount, 50); + expect(stored.cart.manualBillDiscountAmount, 30); + expect(stored.total, due); + }); + + test('deleting a campaign does not change a bill already rung', () async { + final products = ProductRepositoryImpl(store); + final transactions = TransactionRepositoryImpl(store); + final checkout = CheckoutSale( + productRepository: products, + customerRepository: CustomerRepositoryImpl(store), + transactionRepository: transactions, + ); + + final milk = (await products.findByBarcode('8901234500011'))!; + final saved = await promos.save(const Promo( + id: '', + name: 'Doomed campaign', + type: PromoType.flatOffBill, + value: 100, + ),); + + var cart = Cart(lines: [CartLine(product: milk, quantity: 10)]); + cart = cart.copyWith( + appliedPromos: PromoEngine.evaluate( + cart: cart, + promos: [saved], + at: DateTime.now(), + ), + ); + final due = cart.grandTotal; + + await checkout( + cart: cart, + payments: [ + PaymentSplit(method: PaymentMethod.cash, amount: due, tendered: due), + ], + cashierName: 'Divya', + terminalId: 'T0TEST', + ); + + await promos.delete(saved.id); + + final stored = (await transactions.history()).single; + expect(stored.total, due); + expect(stored.cart.appliedPromos.single.promo.name, 'Doomed campaign'); + expect(stored.cart.appliedPromos.single.amount, 100); + }); + }); +} diff --git a/test/widget/app_smoke_test.dart b/test/widget/app_smoke_test.dart index bc393fc..9bdb57e 100644 --- a/test/widget/app_smoke_test.dart +++ b/test/widget/app_smoke_test.dart @@ -71,6 +71,8 @@ void main() { // timer is left pending and trips the binding's leak check. Stubbed // so this test measures rendering, which is what it is for. unsyncedCountProvider.overrideWith((ref) async => 0), + promosProvider.overrideWith((ref) async => []), + activePromosProvider.overrideWith((ref) async => []), parkedBillsProvider.overrideWith((ref) async => []), orderSyncRowsProvider.overrideWith((ref) async => []), todayReportProvider.overrideWith((ref) async => blankReport()),