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>
291 lines
9.0 KiB
Dart
291 lines
9.0 KiB
Dart
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<PromoException>()),
|
|
);
|
|
});
|
|
|
|
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<PromoException>()),
|
|
);
|
|
});
|
|
|
|
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<PromoException>()),
|
|
);
|
|
});
|
|
|
|
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<PromoException>()),
|
|
);
|
|
});
|
|
|
|
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<PromoException>()),
|
|
);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
}
|