Two defects that share a shape: a figure landing on the wrong record.
Bill-level discounts were apportioned across every line by a single
factor, so "20% off Beverages" pulled tax out of the atta line as well.
The bill total was right either way, which is what made it easy to ship
— only the slab split on a filed return was wrong. Targeted campaigns
now reduce the lines they name, and bill-wide reductions still spread
pro rata, so the arithmetic is unchanged wherever it was already right.
Shoppers registered at a till only ever reached the back office as three
fields riding along on a bill. Somebody who signed up and bought nothing
existed on one terminal and nowhere else, and two tills registering the
same mobile each minted their own row. Customers are now an outbox of
their own on pos/{store}/{terminal}/customer, and the id is a UUIDv5
over the normalised mobile number — so a hundred terminals agree on who
a shopper is without talking to each other.
Registrations go up before bills, and a failure there cannot strand a
day's takings. No loyalty figures are sent: they belong to the bill
stream, which is idempotent and knows about every counter.
Two things found while building it. Numbers were keyed on raw digits, so
a cashier typing +91 forked a shopper as effectively as a random id
would. And the sale path wrote the customer with ConflictAlgorithm
.replace, which is a DELETE and an INSERT — every column absent from the
row reverts to its schema default, so the new sync flag would have been
cleared by the shopper's next purchase.
Schema v8. Existing customers are queued rather than assumed sent: the
terminal cannot tell an imported row from a locally registered one, and
only one of those mistakes loses somebody.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
220 lines
7.4 KiB
Dart
220 lines
7.4 KiB
Dart
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<AppliedPromo> 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);
|
|
});
|
|
});
|
|
}
|