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, ); }); }); }