Files
nearle_pos/lib/app/providers.dart
Suriya 46d354ced1 Build promos for real: engine, storage, editor, and application at the till
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>
2026-08-01 13:36:50 +05:30

218 lines
7.7 KiB
Dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/config/sync_config.dart';
import '../core/services/connectivity_service.dart';
import '../core/services/receipt_service.dart';
import '../core/services/sound_service.dart';
import '../data/datasources/local_store.dart';
import '../data/repositories/customer_repository_impl.dart';
import '../data/repositories/product_repository_impl.dart';
import '../data/datasources/remote_catalogue_source.dart';
import '../data/remote/http_order_transport.dart';
import '../data/remote/mqtt_order_transport.dart';
import '../data/remote/order_transport.dart';
import '../data/remote/simulated_order_transport.dart';
import '../data/repositories/store_repository_impl.dart';
import '../data/repositories/sync_repository_impl.dart';
import '../data/repositories/transaction_repository_impl.dart';
import '../data/local/terminal_identity.dart';
import '../data/sync/sync_engine.dart';
import '../domain/repositories/customer_repository.dart';
import '../domain/repositories/product_repository.dart';
import '../domain/repositories/sync_repository.dart';
import '../domain/repositories/transaction_repository.dart';
import '../domain/entities/promo.dart';
import '../domain/entities/store_account.dart';
import '../domain/usecases/checkout_sale.dart';
import '../presentation/auth/providers/auth_controller.dart';
/// Root data source. Overridden in tests with an in-memory double.
final localStoreProvider = Provider<LocalStore>((ref) => LocalStore.instance);
// ---------------------------------------------------------- Repositories
final productRepositoryProvider = Provider<ProductRepository>(
(ref) => ProductRepositoryImpl(ref.watch(localStoreProvider)),
);
final customerRepositoryProvider = Provider<CustomerRepository>(
(ref) => CustomerRepositoryImpl(ref.watch(localStoreProvider)),
);
final transactionRepositoryProvider = Provider<TransactionRepository>(
(ref) => TransactionRepositoryImpl(ref.watch(localStoreProvider)),
);
/// Mirrors the Settings "Simulate offline" switch so the header can show it.
///
/// Without this the terminal claims LIVE while every call is being failed on
/// purpose, which reads as a real network fault.
final simulateOfflineProvider = StateProvider<bool>((ref) => false);
/// Simulated back-office endpoints. Held as singletons so the offline toggle
/// in Settings affects every call.
final remoteCatalogueProvider = Provider<RemoteCatalogueSource>(
(ref) => RemoteCatalogueSource(
isOffline: () => ref.read(simulateOfflineProvider),
),
);
// ------------------------------------------------------------------- Sync
/// How this terminal reaches the back office.
///
/// Defaults to the simulated route so a fresh install is usable with no broker
/// and no endpoint; Settings re-points it.
///
/// Store and terminal ids always come from this device's own identity, never
/// from a literal — two terminals publishing on the same topic is the failure
/// this exists to prevent.
final syncConfigProvider = StateProvider<SyncConfig>((ref) {
final terminal = ref.watch(terminalIdentityProvider);
return SyncConfig(
storeId: terminal.storeId,
terminalId: terminal.code,
);
});
/// Real network state, folded with the Settings offline switch.
final connectivityServiceProvider = Provider<ConnectivityService>((ref) {
final service = ConnectivityService(
isSimulatedOffline: () => ref.read(simulateOfflineProvider),
);
ref.onDispose(service.dispose);
return service;
});
/// The wire itself. Rebuilt when the configuration changes, and the old one is
/// closed so a re-pointed terminal does not keep a stale broker session open.
final orderTransportProvider = Provider<OrderTransport>((ref) {
final config = ref.watch(syncConfigProvider);
final transport = switch (config.transport) {
TransportKind.mqtt => MqttOrderTransport(config: config),
TransportKind.http => HttpOrderTransport(config: config),
TransportKind.simulated => SimulatedOrderTransport(
isOffline: () => ref.read(simulateOfflineProvider),
),
};
ref.onDispose(transport.dispose);
return transport;
});
final syncRepositoryProvider = Provider<SyncRepository>(
(ref) => SyncRepositoryImpl(
ref.watch(localStoreProvider),
ref.watch(remoteCatalogueProvider),
ref.watch(orderTransportProvider),
batchSize: ref.watch(syncConfigProvider).batchSize,
),
);
/// Decides when bills are uploaded. Started once, by the app shell.
final syncEngineProvider = Provider<SyncEngine>((ref) {
final transport = ref.watch(orderTransportProvider);
final engine = SyncEngine(
repository: ref.watch(syncRepositoryProvider),
connectivity: ref.watch(connectivityServiceProvider).onlineChanges,
downlink: transport.downlink,
onCatalogueChanged: () async {
await ref.read(syncRepositoryProvider).importCatalogue();
ref.invalidate(localStoreProvider);
},
);
ref.onDispose(engine.dispose);
return engine;
});
/// Live engine state for the header pill and the events screen.
final syncEngineStateProvider = StreamProvider<SyncEngineState>((ref) {
final engine = ref.watch(syncEngineProvider);
return engine.states.map((s) => s);
});
/// Store details and staff, read from this terminal's database.
final storeRepositoryProvider = Provider<StoreRepositoryImpl>(
(ref) => StoreRepositoryImpl(ref.watch(localStoreProvider)),
);
/// The outlet, refreshed whenever staff or details change.
final storeAccountProvider = FutureProvider<StoreAccount>(
(ref) => ref.watch(storeRepositoryProvider).load(
email: DemoCredentials.email,
),
);
/// Campaigns stored on this terminal.
final promosProvider = FutureProvider<List<Promo>>(
(ref) => ref.watch(localStoreProvider).promos.all(),
);
/// Only the campaigns the till should be applying right now.
final activePromosProvider = FutureProvider<List<Promo>>(
(ref) => ref.watch(localStoreProvider).promos.all(activeOnly: true),
);
// ------------------------------------------------------------- Use cases
final checkoutSaleProvider = Provider<CheckoutSale>(
(ref) => CheckoutSale(
productRepository: ref.watch(productRepositoryProvider),
customerRepository: ref.watch(customerRepositoryProvider),
transactionRepository: ref.watch(transactionRepositoryProvider),
),
);
// -------------------------------------------------------------- Services
final soundServiceProvider =
Provider<SoundService>((ref) => SoundService.instance);
final receiptServiceProvider =
Provider<ReceiptService>((ref) => ReceiptService.instance);
// --------------------------------------------------------------- Session
class CashierSession {
const CashierSession({
required this.name,
required this.role,
required this.terminalId,
});
final String name;
final String role;
final String terminalId;
}
/// Identity of the physical till, read from its own database.
///
/// Falls back only before the store has opened; every real read happens after
/// `LocalStore.init`, which mints the identity if this device has never run
/// before.
final terminalIdentityProvider = Provider<TerminalIdentity>((ref) {
final store = ref.watch(localStoreProvider);
return store.isReady
? store.terminal
: const TerminalIdentity(
deviceId: 'unopened',
code: 'T0000',
name: 'Terminal',
storeId: 'store-01',
);
});
final cashierSessionProvider = StateProvider<CashierSession>((ref) {
final terminal = ref.watch(terminalIdentityProvider);
return CashierSession(
name: 'Suriya',
role: 'ADMIN',
terminalId: terminal.code,
);
});
/// Ticks once a minute to drive the header clock without rebuilding on every
/// frame.
final clockProvider = StreamProvider<DateTime>((ref) async* {
yield DateTime.now();
yield* Stream.periodic(const Duration(seconds: 20), (_) => DateTime.now());
});