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>
216 lines
8.3 KiB
Dart
216 lines
8.3 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:nearle_pos/data/local/app_database.dart';
|
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
|
|
|
/// A terminal already in the field is running the v3 schema. Upgrading it must
|
|
/// carry the day's archived takings across rather than dropping them, so this
|
|
/// builds a genuine v3 database and opens it with the current code.
|
|
void main() {
|
|
late Directory dir;
|
|
late String dbPath;
|
|
|
|
setUpAll(sqfliteFfiInit);
|
|
|
|
setUp(() async {
|
|
dir = await Directory.systemTemp.createTemp('nearle_migration');
|
|
dbPath = '${dir.path}/nearle_pos.db';
|
|
databaseFactory = databaseFactoryFfi;
|
|
});
|
|
|
|
tearDown(() async {
|
|
await AppDatabase.instance.close();
|
|
if (dir.existsSync()) dir.deleteSync(recursive: true);
|
|
});
|
|
|
|
/// The schema exactly as v3 shipped it.
|
|
Future<void> createV3Database() async {
|
|
final db = await databaseFactory.openDatabase(
|
|
dbPath,
|
|
options: OpenDatabaseOptions(
|
|
version: 3,
|
|
onCreate: (db, _) async {
|
|
await db.execute('''
|
|
CREATE TABLE products (
|
|
id TEXT PRIMARY KEY, name TEXT NOT NULL, barcode TEXT NOT NULL,
|
|
sku TEXT NOT NULL, category TEXT NOT NULL, price REAL NOT NULL,
|
|
mrp REAL, stock REAL NOT NULL DEFAULT 0, emoji TEXT,
|
|
image_url TEXT, unit TEXT NOT NULL DEFAULT 'piece',
|
|
gst_rate REAL NOT NULL DEFAULT 0.18, hsn_code TEXT, brand TEXT,
|
|
is_active INTEGER NOT NULL DEFAULT 1, updated_at INTEGER NOT NULL)
|
|
''');
|
|
await db.execute('''
|
|
CREATE TABLE customers (
|
|
id TEXT PRIMARY KEY, name TEXT NOT NULL, mobile TEXT NOT NULL,
|
|
email TEXT, gender TEXT NOT NULL DEFAULT 'unspecified',
|
|
date_of_birth INTEGER, loyalty_points INTEGER NOT NULL DEFAULT 0,
|
|
lifetime_spend REAL NOT NULL DEFAULT 0,
|
|
visit_count INTEGER NOT NULL DEFAULT 0, created_at INTEGER,
|
|
last_visit_at INTEGER)
|
|
''');
|
|
await db.execute('''
|
|
CREATE TABLE orders (
|
|
id TEXT PRIMARY KEY, invoice_number TEXT NOT NULL UNIQUE,
|
|
created_at INTEGER NOT NULL, business_date TEXT NOT NULL,
|
|
cashier_name TEXT NOT NULL, terminal_id TEXT NOT NULL,
|
|
customer_id TEXT, customer_mobile TEXT, customer_name TEXT,
|
|
subtotal REAL NOT NULL, line_discount REAL NOT NULL DEFAULT 0,
|
|
bill_discount REAL NOT NULL DEFAULT 0,
|
|
loyalty_value REAL NOT NULL DEFAULT 0,
|
|
taxable_amount REAL NOT NULL DEFAULT 0,
|
|
tax_amount REAL NOT NULL DEFAULT 0,
|
|
round_off REAL NOT NULL DEFAULT 0, total REAL NOT NULL,
|
|
points_earned INTEGER NOT NULL DEFAULT 0,
|
|
points_redeemed INTEGER NOT NULL DEFAULT 0,
|
|
payments_json TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'completed',
|
|
sync_status INTEGER NOT NULL DEFAULT 0, synced_at INTEGER,
|
|
sync_attempts INTEGER NOT NULL DEFAULT 0, sync_error TEXT)
|
|
''');
|
|
await db.execute('''
|
|
CREATE TABLE order_items (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, order_id TEXT NOT NULL,
|
|
product_id TEXT NOT NULL, name TEXT NOT NULL,
|
|
barcode TEXT NOT NULL, sku TEXT NOT NULL, unit TEXT NOT NULL,
|
|
unit_price REAL NOT NULL, quantity REAL NOT NULL,
|
|
discount REAL NOT NULL DEFAULT 0, gst_rate REAL NOT NULL DEFAULT 0,
|
|
tax_amount REAL NOT NULL DEFAULT 0, line_total REAL NOT NULL,
|
|
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE)
|
|
''');
|
|
await db.execute('''
|
|
CREATE TABLE parked_bills (
|
|
id TEXT PRIMARY KEY, label TEXT, parked_at INTEGER NOT NULL,
|
|
cart_json TEXT NOT NULL)
|
|
''');
|
|
// v3 sync_log: no payload column.
|
|
await db.execute('''
|
|
CREATE TABLE sync_log (
|
|
id TEXT PRIMARY KEY, type TEXT NOT NULL, status TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL, synced_at INTEGER,
|
|
summary TEXT NOT NULL, error TEXT,
|
|
attempts INTEGER NOT NULL DEFAULT 0)
|
|
''');
|
|
// v3 day_archive: keyed by date alone, no cashier.
|
|
await db.execute('''
|
|
CREATE TABLE day_archive (
|
|
business_date TEXT PRIMARY KEY,
|
|
bill_count INTEGER NOT NULL DEFAULT 0,
|
|
item_count REAL NOT NULL DEFAULT 0,
|
|
gross_sales REAL NOT NULL DEFAULT 0,
|
|
tax_collected REAL NOT NULL DEFAULT 0,
|
|
discount_given REAL NOT NULL DEFAULT 0,
|
|
round_off REAL NOT NULL DEFAULT 0,
|
|
points_issued INTEGER NOT NULL DEFAULT 0,
|
|
points_redeemed INTEGER NOT NULL DEFAULT 0,
|
|
payments_json TEXT NOT NULL DEFAULT '{}',
|
|
first_bill_at INTEGER, last_bill_at INTEGER,
|
|
synced_bills INTEGER NOT NULL DEFAULT 0)
|
|
''');
|
|
await db.execute(
|
|
'CREATE TABLE app_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)',
|
|
);
|
|
},
|
|
),
|
|
);
|
|
|
|
await db.insert('day_archive', {
|
|
'business_date': '2026-07-30',
|
|
'bill_count': 12,
|
|
'item_count': 40.0,
|
|
'gross_sales': 8450.0,
|
|
'tax_collected': 620.5,
|
|
'discount_given': 130.0,
|
|
'round_off': 1.5,
|
|
'points_issued': 84,
|
|
'points_redeemed': 20,
|
|
'payments_json': '{"cash":5000.0,"upi":3450.0}',
|
|
'first_bill_at': 1000,
|
|
'last_bill_at': 2000,
|
|
'synced_bills': 12,
|
|
});
|
|
await db.insert('app_meta', {'key': 'invoice_sequence', 'value': '12'});
|
|
await db.close();
|
|
}
|
|
|
|
test('a v3 terminal upgrades without losing its archived takings', () async {
|
|
await createV3Database();
|
|
|
|
await AppDatabase.instance.open(overridePath: dbPath);
|
|
final db = AppDatabase.instance.db;
|
|
|
|
expect(await db.getVersion(), 7);
|
|
|
|
final rows = await db.query('day_archive');
|
|
expect(rows, hasLength(1));
|
|
|
|
final row = rows.single;
|
|
expect(row['business_date'], '2026-07-30');
|
|
expect(row['cashier_name'], '',
|
|
reason: 'rows from before per-cashier attribution get an empty name',);
|
|
|
|
// v5 adds staff. An upgraded terminal must come up with the table present
|
|
// but empty — seeding is the store's job on first open, not the migration's,
|
|
// so an existing shop is never handed accounts it did not create.
|
|
final staff = await db.query('staff');
|
|
expect(staff, isEmpty);
|
|
|
|
// v6 adds campaigns. Same rule: the table exists, and an upgraded shop is
|
|
// not handed promotions it never created.
|
|
final promos = await db.query('promos');
|
|
expect(promos, isEmpty);
|
|
expect(row['bill_count'], 12);
|
|
expect(row['gross_sales'], 8450.0);
|
|
expect(row['tax_collected'], 620.5);
|
|
expect(row['payments_json'], '{"cash":5000.0,"upi":3450.0}');
|
|
expect(row['synced_bills'], 12);
|
|
|
|
// Unrelated state must be untouched by the migration.
|
|
final meta = await db.query('app_meta', where: "key = 'invoice_sequence'");
|
|
expect(meta.single['value'], '12');
|
|
});
|
|
|
|
test('the upgraded sync log accepts a payload', () async {
|
|
await createV3Database();
|
|
await AppDatabase.instance.open(overridePath: dbPath);
|
|
final db = AppDatabase.instance.db;
|
|
|
|
await db.insert('sync_log', {
|
|
'id': 'e1',
|
|
'type': 'shiftReport',
|
|
'status': 'synced',
|
|
'created_at': 1,
|
|
'summary': '3 bills uploaded',
|
|
'attempts': 1,
|
|
'payload_json': '{"invoices":["INV-1"]}',
|
|
});
|
|
|
|
final row = (await db.query('sync_log')).single;
|
|
expect(row['payload_json'], '{"invoices":["INV-1"]}');
|
|
});
|
|
|
|
test('the day archive now holds one row per cashier', () async {
|
|
await createV3Database();
|
|
await AppDatabase.instance.open(overridePath: dbPath);
|
|
final db = AppDatabase.instance.db;
|
|
|
|
for (final cashier in ['Divya', 'Rahul']) {
|
|
await db.insert('day_archive', {
|
|
'business_date': '2026-07-31',
|
|
'cashier_name': cashier,
|
|
'bill_count': 1,
|
|
'gross_sales': 100.0,
|
|
'payments_json': '{}',
|
|
});
|
|
}
|
|
|
|
final rows = await db.query(
|
|
'day_archive',
|
|
where: 'business_date = ?',
|
|
whereArgs: ['2026-07-31'],
|
|
);
|
|
expect(rows, hasLength(2),
|
|
reason: 'two cashiers on the same day must not collide',);
|
|
});
|
|
}
|