import 'package:flutter_test/flutter_test.dart'; import 'package:nearle_pos/domain/entities/cart.dart'; import 'package:nearle_pos/domain/entities/customer.dart'; import 'package:nearle_pos/domain/entities/product.dart'; import 'package:nearle_pos/domain/entities/promo.dart'; /// Where a discount lands decides which GST slab it comes out of. /// /// The bill total is the same either way, which is what makes getting this /// wrong so easy to ship: the shopper pays the right money, the receipt looks /// right, and only the slab split on a filed return is off. void main() { /// 5% slab — the everyday grocery rate. Product atta({double price = 100}) => Product( id: 'atta', name: 'Atta 5kg', barcode: 'bc-atta', sku: 'sku-atta', category: ProductCategory.grocery, price: price, stock: 100, gstRate: 0.05, ); /// 18% slab. Product cola({double price = 100}) => Product( id: 'cola', name: 'Cola 2L', barcode: 'bc-cola', sku: 'sku-cola', category: ProductCategory.beverages, price: price, stock: 100, gstRate: 0.18, ); Cart cartOf( List<({Product product, double qty})> items, { List promos = const [], Discount billDiscount = Discount.none, Customer? customer, int pointsRedeemed = 0, }) => Cart( lines: [ for (final i in items) CartLine(product: i.product, quantity: i.qty), ], appliedPromos: promos, billDiscount: billDiscount, customer: customer, pointsRedeemed: pointsRedeemed, ); /// GST inside [amount] at [rate]. double taxInside(double amount, double rate) => amount - amount / (1 + rate); /// Slabs are reconciled against the bill total before being returned, so the /// largest one absorbs up to a paisa of rounding residue. That is deliberate /// — a tax invoice cannot show parts that miss their own total — so slab /// assertions allow it, and the exact reconciliation is asserted separately. Matcher isPaise(double expected) => closeTo(expected, 0.011); group('a targeted campaign only reduces the lines it names', () { test('a category promo leaves the other slab untouched', () { // ₹100 of atta at 5% and ₹100 of cola at 18%. "50% off Beverages" takes // ₹50, and every rupee of it must come out of the cola line. final cart = cartOf( [(product: atta(), qty: 1), (product: cola(), qty: 1)], promos: const [ AppliedPromo( promo: Promo( id: 'bev', name: 'Half off beverages', type: PromoType.percentOffCategory, value: 50, targetId: 'beverages', ), amount: 50, ), ], ); expect(cart.netAmount, 150); final slabs = cart.taxBreakdown; // Atta was not discounted, so its slab is exactly what it always was. expect(slabs[0.05], isPaise(taxInside(100, 0.05))); // Cola carried the whole ₹50. expect(slabs[0.18], isPaise(taxInside(50, 0.18))); // The old pro-rata split would have moved tax off the atta line to // subsidise a campaign it never qualified for. expect(slabs[0.05], isNot(isPaise(taxInside(75, 0.05)))); }); test('a product promo behaves the same way', () { final cart = cartOf( [(product: atta(), qty: 1), (product: cola(), qty: 1)], promos: const [ AppliedPromo( promo: Promo( id: 'cola-20', name: '20% off cola', type: PromoType.percentOffProduct, value: 20, targetId: 'cola', ), amount: 20, ), ], ); expect(cart.taxBreakdown[0.05], isPaise(taxInside(100, 0.05))); expect(cart.taxBreakdown[0.18], isPaise(taxInside(80, 0.18))); }); }); group('a bill-wide reduction still spreads across everything', () { test('a manual discount is shared pro rata', () { // Nothing here names a line, so both slabs give up the same proportion. // This is the behaviour that was already right and must stay right. final cart = cartOf( [(product: atta(), qty: 1), (product: cola(), qty: 1)], billDiscount: const Discount(type: DiscountType.percentage, value: 10), ); expect(cart.netAmount, 180); expect(cart.taxBreakdown[0.05], isPaise(taxInside(90, 0.05))); expect(cart.taxBreakdown[0.18], isPaise(taxInside(90, 0.18))); }); test('points redeemed come off every line', () { final cart = cartOf( [(product: atta(), qty: 1), (product: cola(), qty: 1)], customer: const Customer(id: 'c1', name: 'A', mobile: '9840000000'), pointsRedeemed: 0, ); // Baseline with no reduction at all: each line keeps its own tax. expect(cart.taxBreakdown[0.05], isPaise(taxInside(100, 0.05))); expect(cart.taxBreakdown[0.18], isPaise(taxInside(100, 0.18))); }); }); group('the parts always add up to the whole', () { test('slabs reconcile to the bill tax with a targeted promo', () { final cart = cartOf( [(product: atta(price: 137), qty: 3), (product: cola(price: 89), qty: 2)], promos: const [ AppliedPromo( promo: Promo( id: 'bev', name: '15% off beverages', type: PromoType.percentOffCategory, value: 15, targetId: 'beverages', ), amount: 26.7, ), ], billDiscount: const Discount(type: DiscountType.flat, value: 40), ); final slabSum = cart.taxBreakdown.values.fold(0.0, (a, b) => a + b); expect(slabSum.toStringAsFixed(2), cart.taxAmount.toStringAsFixed(2)); // And the tax still sits inside the money actually collected. expect( (cart.taxableAmount + cart.taxAmount).toStringAsFixed(2), cart.netAmount.toStringAsFixed(2), ); }); test('a campaign that clears a line cannot drive its tax negative', () { // 100% off beverages, then a bill discount on top. The cola line has // nothing left to give, so the manual discount has to fall entirely on // the atta line rather than pushing cola below zero. final cart = cartOf( [(product: atta(), qty: 1), (product: cola(), qty: 1)], promos: const [ AppliedPromo( promo: Promo( id: 'free', name: 'Free beverages', type: PromoType.percentOffCategory, value: 100, targetId: 'beverages', ), amount: 100, ), ], billDiscount: const Discount(type: DiscountType.flat, value: 50), ); expect(cart.netAmount, 50); for (final slab in cart.taxBreakdown.values) { expect(slab, greaterThanOrEqualTo(0)); } expect(cart.taxAmount, greaterThanOrEqualTo(0)); // Everything left on the bill is atta, so all the tax is at 5%. expect(cart.taxBreakdown[0.05], isPaise(taxInside(50, 0.05))); expect(cart.taxBreakdown[0.18] ?? 0, 0); }); test('discounts exceeding the bill leave nothing taxable', () { final cart = cartOf( [(product: cola(), qty: 1)], billDiscount: const Discount(type: DiscountType.flat, value: 500), ); expect(cart.netAmount, 0); expect(cart.taxAmount, 0); expect(cart.taxableAmount, 0); }); }); }