Upload shopper registrations, and charge GST to the lines that earned it

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>
This commit is contained in:
Suriya
2026-08-03 11:34:27 +05:30
parent 467d5eee75
commit fe428931ec
24 changed files with 1274 additions and 78 deletions

View File

@@ -0,0 +1,307 @@
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/app_database.dart';
import 'package:nearle_pos/data/remote/order_transport.dart';
import 'package:nearle_pos/data/remote/simulated_catalogue_source.dart';
import 'package:nearle_pos/data/repositories/customer_repository_impl.dart';
import 'package:nearle_pos/data/repositories/product_repository_impl.dart';
import 'package:nearle_pos/data/repositories/sync_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/customer.dart';
import 'package:nearle_pos/domain/entities/transaction.dart';
import 'package:nearle_pos/domain/usecases/checkout_sale.dart';
/// A shopper who signs up at the till has to reach the back office in their
/// own right. Before the customer outbox they only ever travelled as three
/// fields riding along on a bill — so somebody who registered and then bought
/// nothing, or whose bill was still queued, existed on one terminal and
/// nowhere else.
void main() {
late LocalStore store;
late CustomerRepositoryImpl customers;
late CheckoutSale checkout;
setUpAll(() {
LocalStore.registerSeed(
products: SeedData.products,
customers: SeedData.customers,
);
});
setUp(() async {
store = LocalStore.instance;
await store.reset(withCatalogue: true);
customers = CustomerRepositoryImpl(store);
checkout = CheckoutSale(
productRepository: ProductRepositoryImpl(store),
customerRepository: customers,
transactionRepository: TransactionRepositoryImpl(store),
);
});
tearDownAll(() => AppDatabase.instance.close());
SyncRepositoryImpl syncWith(OrderTransport transport) => SyncRepositoryImpl(
store,
SimulatedCatalogueSource(isOffline: () => false),
transport,
);
Future<Customer> register(String mobile, {String name = 'Meena'}) =>
customers.create(Customer(id: '', name: name, mobile: mobile));
group('identity', () {
test('the same mobile produces the same id on any terminal', () {
// The whole point. A hundred tills mint ids without talking to each
// other, so the id has to be a function of the shopper, not of chance.
expect(
Customer.idForMobile('9840012345'),
Customer.idForMobile('9840012345'),
);
});
test('formatting does not create a second shopper', () {
final plain = Customer.idForMobile('9840012345');
expect(Customer.idForMobile('+91 98400 12345'), plain);
expect(Customer.idForMobile('98400-12345'), plain);
});
test('the country code and the trunk prefix are stripped', () {
expect(Customer.normaliseMobile('+91 98400 12345'), '9840012345');
expect(Customer.normaliseMobile('098400 12345'), '9840012345');
expect(Customer.normaliseMobile('9840012345'), '9840012345');
});
test('a number the rule was not written for is left alone', () {
// Mangling something unrecognised is worse than storing it verbatim: a
// wrongly-trimmed number silently merges two different shoppers.
expect(Customer.normaliseMobile('4155550123'), '4155550123');
expect(Customer.normaliseMobile('12345'), '12345');
// Twelve digits that do not start with the Indian country code.
expect(Customer.normaliseMobile('442071234567'), '442071234567');
});
test('different shoppers get different ids', () {
expect(
Customer.idForMobile('9840012345'),
isNot(Customer.idForMobile('9840012346')),
);
});
test('a registration is keyed on the number, not on chance', () async {
final created = await register('+91 98400 12345');
expect(created.id, Customer.idForMobile('9840012345'));
expect(created.mobile, '9840012345');
});
});
group('the outbox', () {
test('a shopper registered at the till is queued', () async {
final before = await store.catalogue.unsyncedCustomerCount();
await register('9840012345');
expect(await store.catalogue.unsyncedCustomerCount(), before + 1);
});
test('a shopper who buys nothing still goes up', () async {
// The case that used to be lost entirely: no bill, so nothing to ride.
final created = await register('9840012345');
final transport = _RecordingTransport();
final outcome = await syncWith(transport).syncCustomers();
expect(outcome.uploaded, greaterThanOrEqualTo(1));
expect(transport.sentIds, contains(created.id));
});
test('shoppers that came from the back office are not posted back',
() async {
// The seed catalogue arrives as an import. Sending it straight back
// would be a round trip telling the server what it just told us.
final imported = store.customers.map((c) => c.id).toSet();
expect(imported, isNotEmpty, reason: 'the fixture needs seeded shoppers');
final pending = await store.catalogue.unsyncedCustomers(limit: 500);
expect(
pending.map((c) => c.id).toSet().intersection(imported),
isEmpty,
);
});
test('only the ids the back office names are marked sent', () async {
final kept = await register('9840012345', name: 'Meena');
final dropped = await register('9840099999', name: 'Ravi');
// Silence about a row is not acceptance of it.
final transport = _RecordingTransport(
accept: (ids) => ids.where((id) => id == kept.id).toList(),
);
await syncWith(transport).syncCustomers();
final stillPending =
(await store.catalogue.unsyncedCustomers(limit: 500))
.map((c) => c.id)
.toSet();
expect(stillPending, contains(dropped.id));
expect(stillPending, isNot(contains(kept.id)));
});
test('a batch nobody accepts stops rather than looping for ever',
() async {
await register('9840012345');
final transport = _RecordingTransport(accept: (_) => const []);
final outcome = await syncWith(transport).syncCustomers();
expect(outcome.isSuccess, isFalse);
expect(outcome.isRetryable, isFalse);
expect(transport.calls, 1, reason: 'the same page must not be re-read');
});
test('an unreachable back office leaves everyone pending', () async {
final created = await register('9840012345');
final outcome = await syncWith(_FailingTransport()).syncCustomers();
expect(outcome.isSuccess, isFalse);
expect(outcome.uploaded, 0);
expect(
(await store.catalogue.unsyncedCustomers(limit: 500))
.map((c) => c.id),
contains(created.id),
);
});
});
group('a sale does not disturb the outbox', () {
/// Rings a bill for [customer] so loyalty movement is written.
Future<void> ringSaleFor(Customer customer) async {
final product = store.products.first;
final cart = Cart(
lines: [CartLine(product: product, quantity: 1)],
customer: customer,
);
await checkout(
cart: cart,
payments: [
PaymentSplit(method: PaymentMethod.cash, amount: cart.grandTotal),
],
cashierName: 'Suriya',
terminalId: 'T4A9',
);
}
test('a sale does not re-queue a shopper already sent', () async {
final created = await register('9840012345');
await syncWith(_RecordingTransport()).syncCustomers();
expect(await store.catalogue.unsyncedCustomerCount(), 0);
await ringSaleFor(created);
// A sale writes the shopper's new points and spend. Done as an upsert
// that replaces the row, every column absent from it — sync_status
// included — would silently revert to its schema default.
expect(
await store.catalogue.unsyncedCustomerCount(),
0,
reason: 'loyalty movement is not a registration change',
);
});
test('a sale still moves the loyalty figures', () async {
// Guards the fix above from being "achieved" by not writing at all.
final created = await register('9840012345');
await ringSaleFor(created);
final after = await store.catalogue.customerById(created.id);
expect(after!.visitCount, 1);
expect(after.lifetimeSpend, greaterThan(0));
expect(after.lastVisitAt, isNotNull);
});
test('a sale does not overwrite a profile', () async {
final created = await register('9840012345', name: 'Meena');
await ringSaleFor(created);
final after = await store.catalogue.customerById(created.id);
expect(after!.name, 'Meena');
expect(after.mobile, '9840012345');
});
});
}
/// Accepts what it is told to and remembers what it saw.
class _RecordingTransport implements OrderTransport {
_RecordingTransport({List<String> Function(List<String> ids)? accept})
: accept = accept ?? ((ids) => ids);
final List<String> Function(List<String> ids) accept;
final sentIds = <String>[];
int calls = 0;
@override
String get label => 'Recording';
@override
bool get isConnected => true;
@override
Stream<DownlinkMessage> get downlink => const Stream.empty();
@override
Stream<bool> get connectionState => const Stream.empty();
@override
Future<void> connect() async {}
@override
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) async =>
PushReceipt(accepted: orders.map((o) => o['id']! as String).toList());
@override
Future<PushReceipt> pushCustomers(
List<Map<String, Object?>> customers,
) async {
calls++;
final ids = customers.map((c) => c['id']! as String).toList();
sentIds.addAll(ids);
return PushReceipt(accepted: accept(ids));
}
@override
Future<void> dispose() async {}
}
class _FailingTransport implements OrderTransport {
@override
String get label => 'Failing';
@override
bool get isConnected => false;
@override
Stream<DownlinkMessage> get downlink => const Stream.empty();
@override
Stream<bool> get connectionState => const Stream.empty();
@override
Future<void> connect() async {}
@override
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) async =>
throw const TransportException('unreachable');
@override
Future<PushReceipt> pushCustomers(
List<Map<String, Object?>> customers,
) async =>
throw const TransportException('unreachable');
@override
Future<void> dispose() async {}
}

View File

@@ -130,6 +130,19 @@ void main() {
'synced_bills': 12,
});
await db.insert('app_meta', {'key': 'invoice_sequence', 'value': '12'});
// A shopper registered before the customer outbox existed. Whether they
// reach the back office at all depends on what v8 does with this row.
await db.insert('customers', {
'id': 'legacy-customer-1',
'name': 'Meena',
'mobile': '9840011111',
'loyalty_points': 40,
'lifetime_spend': 2400.0,
'visit_count': 3,
'created_at': 1000,
});
await db.close();
}
@@ -139,7 +152,7 @@ void main() {
await AppDatabase.instance.open(overridePath: dbPath);
final db = AppDatabase.instance.db;
expect(await db.getVersion(), 7);
expect(await db.getVersion(), 8);
final rows = await db.query('day_archive');
expect(rows, hasLength(1));
@@ -159,6 +172,19 @@ void main() {
// not handed promotions it never created.
final promos = await db.query('promos');
expect(promos, isEmpty);
// v8 turns customers into an outbox. Every existing shopper is queued
// rather than assumed sent: the terminal cannot tell which rows came down
// in a catalogue pull and which were registered at the till, and only one
// of those two mistakes loses somebody. Re-sending is safe because the
// uplink is insert-if-absent on id.
final customer = (await db.query('customers')).single;
expect(customer['sync_status'], 0);
expect(customer['synced_at'], isNull);
// …and their loyalty standing survives the migration untouched.
expect(customer['loyalty_points'], 40);
expect(customer['lifetime_spend'], 2400.0);
expect(row['bill_count'], 12);
expect(row['gross_sales'], 8450.0);
expect(row['tax_collected'], 620.5);

View File

@@ -44,6 +44,16 @@ class _ScriptedTransport implements OrderTransport {
return answer(orders.map((o) => o['id']! as String).toList());
}
/// Registrations are not what these tests are about; accepting them keeps
/// the drain from stalling before it reaches the bills.
@override
Future<PushReceipt> pushCustomers(
List<Map<String, Object?>> customers,
) async =>
PushReceipt(
accepted: customers.map((c) => c['id']! as String).toList(),
);
@override
Future<void> dispose() async {}
}

View File

@@ -39,6 +39,23 @@ class _StubRepository implements SyncRepository {
@override
Future<int> unsyncedCount() async => pending;
/// Counted so a test can prove registrations go up before bills do.
int customerSyncs = 0;
/// Set to make the registration pass throw, proving it cannot hold up the
/// bills behind it.
bool customerSyncThrows = false;
@override
Future<int> unsyncedCustomerCount() async => 0;
@override
Future<SyncOutcome> syncCustomers() async {
customerSyncs++;
if (customerSyncThrows) throw Exception('registration upload failed');
return const SyncOutcome(attempted: 0, uploaded: 0);
}
@override
Future<int> purgeExpired() async => 0;
@@ -308,6 +325,10 @@ void main() {
final engine = build(connectivity: connectivity.stream);
await engine.start();
// start() fires a drain of its own. Let it finish before taking the
// baseline, so this measures what connectivity did rather than how many
// awaits happen to sit in front of the repository call.
await _settle();
final atStart = repo.calls;
connectivity.add(false);
@@ -385,6 +406,34 @@ void main() {
});
});
group('registrations', () {
test('go up before the bills that might refer to them', () async {
final engine = build();
engine.nudge(SyncTrigger.saleCommitted);
await _settle();
expect(repo.customerSyncs, 1);
expect(repo.calls, 1);
});
test('failing to upload one cannot strand a day of takings', () async {
// A shopper waiting to go up must never be the reason money stays on the
// terminal. The registration pass is allowed to fail on its own.
repo.customerSyncThrows = true;
final engine = build();
engine.nudge(SyncTrigger.saleCommitted);
await _settle();
expect(repo.calls, 1, reason: 'the bills still went');
expect(engine.state.consecutiveFailures, 0);
expect(engine.state.isHalted, isFalse);
await engine.dispose();
});
});
test('a repository that throws is treated as a retryable failure, not a crash',
() async {
// Anything escaping the repository is a defect. The engine must still empty

View File

@@ -0,0 +1,219 @@
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);
});
});
}