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

@@ -13,7 +13,7 @@ class AppDatabase {
static final AppDatabase instance = AppDatabase._();
static const String _fileName = 'nearle_pos.db';
static const int _version = 7;
static const int _version = 8;
Database? _db;
@@ -77,6 +77,7 @@ class AppDatabase {
'ALTER TABLE ${Tables.orders} ADD COLUMN promos_json TEXT',
);
}
if (from < 8) await _upgradeToV8(db);
},
),
);
@@ -188,12 +189,24 @@ class AppDatabase {
lifetime_spend REAL NOT NULL DEFAULT 0,
visit_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER,
last_visit_at INTEGER
last_visit_at INTEGER,
-- Registration outbox. A shopper signed up at the till has to reach
-- the back office even if they never buy anything, so this table
-- carries the same pending/synced flag the orders table does.
--
-- Defaults to 1: rows that arrived in a catalogue pull came *from*
-- the back office and must not be posted straight back.
sync_status INTEGER NOT NULL DEFAULT 1,
synced_at INTEGER
)
''');
await db.execute(
'CREATE UNIQUE INDEX idx_customers_mobile ON ${Tables.customers}(mobile)',
);
await db.execute(
'CREATE INDEX idx_customers_sync ON ${Tables.customers}(sync_status)',
);
// -------------------------------------------------------------- orders
await db.execute('''
@@ -322,6 +335,43 @@ Future<void> _upgradeToV4(Database db, {required int from}) async {
await db.execute('DROP TABLE _day_archive_v3');
}
/// Moves the schema to v8 — customers become an outbox.
///
/// Before this, a shopper registered at the till only ever reached the back
/// office as three fields riding along on a bill. Someone who signed up and
/// then didn't buy anything, or whose bill was still queued, existed on one
/// terminal and nowhere else.
///
/// Every existing row is marked pending rather than synced. The terminal
/// cannot tell which of them came down in a catalogue pull and which were rung
/// up locally, and of the two possible mistakes only one loses a shopper. This
/// is safe precisely because the customer uplink is specified as
/// insert-if-absent on id — re-sending one the back office already holds is a
/// no-op, never an overwrite of a profile edited at head office.
///
/// Ids are deliberately *not* rewritten. New customers are keyed on their
/// mobile number (see `Customer.idForMobile`) so terminals agree without
/// coordinating, but rows created before this version carry random ids that
/// bills already in the back office refer to. Re-keying them here would break
/// that link. They stay as they are, and the back office merges them on
/// mobile — a one-off for stores that were already trading.
Future<void> _upgradeToV8(Database db) async {
await db.execute(
'ALTER TABLE ${Tables.customers} '
'ADD COLUMN sync_status INTEGER NOT NULL DEFAULT 1',
);
await db.execute(
'ALTER TABLE ${Tables.customers} ADD COLUMN synced_at INTEGER',
);
await db.execute(
'CREATE INDEX idx_customers_sync ON ${Tables.customers}(sync_status)',
);
await db.rawUpdate(
'UPDATE ${Tables.customers} SET sync_status = 0, synced_at = NULL',
);
}
const String _createSyncLog = '''
CREATE TABLE sync_log (
id TEXT PRIMARY KEY,

View File

@@ -164,7 +164,7 @@ class CatalogueDao {
for (final c in customers) {
batch.insert(
Tables.customers,
customerToRow(c),
_importedCustomerRow(c),
conflictAlgorithm: ConflictAlgorithm.ignore,
);
}
@@ -173,6 +173,18 @@ class CatalogueDao {
});
}
/// A customer row that arrived from the back office, marked as already sent.
///
/// Stated outright rather than left to the column default, because the
/// default existing to protect imports is not obvious from this call site —
/// and getting it wrong would post the whole customer book straight back to
/// the server that just sent it.
static Map<String, Object?> _importedCustomerRow(Customer c) => {
...customerToRow(c),
'sync_status': syncedCustomer,
'synced_at': DateTime.now().millisecondsSinceEpoch,
};
/// Applies a change set, leaving everything it does not mention alone.
///
/// The counterpart to [replaceCatalogue], and the difference matters: a full
@@ -218,7 +230,7 @@ class CatalogueDao {
for (final c in customers) {
batch.insert(
Tables.customers,
customerToRow(c),
_importedCustomerRow(c),
conflictAlgorithm: ConflictAlgorithm.ignore,
);
}
@@ -254,11 +266,10 @@ class CatalogueDao {
}
Future<Customer?> customerByMobile(String mobile) async {
final digits = mobile.replaceAll(RegExp(r'\D'), '');
final rows = await _db.query(
Tables.customers,
where: 'mobile = ?',
whereArgs: [digits],
whereArgs: [Customer.normaliseMobile(mobile)],
limit: 1,
);
return rows.isEmpty ? null : customerFromRow(rows.first);
@@ -274,14 +285,59 @@ class CatalogueDao {
return rows.isEmpty ? null : customerFromRow(rows.first);
}
/// Writes a customer created or edited at the till, and queues them.
///
/// Everything registered on this terminal has to reach the back office in its
/// own right — a shopper who signs up for the loyalty scheme and then buys
/// nothing used to exist here and nowhere else.
Future<void> upsertCustomer(Customer c) async {
await _db.insert(
Tables.customers,
customerToRow(c),
{...customerToRow(c), 'sync_status': pendingCustomer, 'synced_at': null},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
// ---------------------------------------------------- Customer outbox
static const int pendingCustomer = 0;
static const int syncedCustomer = 1;
Future<int> unsyncedCustomerCount() async {
final rows = await _db.rawQuery(
'SELECT COUNT(*) AS n FROM ${Tables.customers} WHERE sync_status = ?',
[pendingCustomer],
);
return (rows.first['n']! as num).toInt();
}
/// A bounded page of shoppers waiting to go up, oldest first.
Future<List<Customer>> unsyncedCustomers({int limit = 100}) async {
final rows = await _db.query(
Tables.customers,
where: 'sync_status = ?',
whereArgs: [pendingCustomer],
orderBy: 'created_at ASC',
limit: limit,
);
return rows.map(customerFromRow).toList();
}
/// Flips only the ids the back office named. Anything it stayed silent about
/// is left pending — the same rule the orders outbox follows.
Future<void> markCustomersSynced(List<String> ids, {DateTime? at}) async {
if (ids.isEmpty) return;
final marks = List.filled(ids.length, '?').join(',');
await _db.rawUpdate(
'UPDATE ${Tables.customers} SET sync_status = ?, synced_at = ? '
'WHERE id IN ($marks)',
[
syncedCustomer,
(at ?? DateTime.now()).millisecondsSinceEpoch,
...ids,
],
);
}
// ------------------------------------------------------------------ Meta
Future<String?> meta(String key) async {
final rows = await _db.query(

View File

@@ -61,10 +61,24 @@ class OrderDao {
);
});
if (customerRow != null) {
batch.insert(
// A targeted UPDATE, not an upsert. `ConflictAlgorithm.replace` is a
// DELETE followed by an INSERT, so every column absent from the row
// silently reverts to its schema default — which would reset
// `sync_status` to 1 and strand a shopper who had never been uploaded.
//
// Restricting it to the four figures a sale actually moves also stops
// a bill overwriting a name or number corrected at head office between
// the shopper being added to the cart and the cashier taking payment.
batch.update(
Tables.customers,
customerRow,
conflictAlgorithm: ConflictAlgorithm.replace,
{
'loyalty_points': customerRow['loyalty_points'],
'lifetime_spend': customerRow['lifetime_spend'],
'visit_count': customerRow['visit_count'],
'last_visit_at': customerRow['last_visit_at'],
},
where: 'id = ?',
whereArgs: [customerRow['id']],
);
}
await batch.commit(noResult: true);