second commit

This commit is contained in:
2026-07-29 11:41:53 +05:30
parent fcccf22bac
commit d72522e737
211 changed files with 19260 additions and 0 deletions

View File

@@ -0,0 +1,238 @@
import 'package:equatable/equatable.dart';
import '../../core/constants/app_constants.dart';
import '../../core/utils/extensions.dart';
import 'customer.dart';
import 'product.dart';
/// How a discount value should be interpreted.
enum DiscountType { none, percentage, flat }
/// A discount applied to a single line or to the whole bill.
class Discount extends Equatable {
const Discount({this.type = DiscountType.none, this.value = 0, this.reason});
final DiscountType type;
final double value;
final String? reason;
static const Discount none = Discount();
bool get isActive => type != DiscountType.none && value > 0;
/// Resolves the discount to rupees against [base], never exceeding it.
double amountOn(double base) {
if (!isActive || base <= 0) return 0;
final raw = switch (type) {
DiscountType.percentage => base * (value / 100),
DiscountType.flat => value,
DiscountType.none => 0.0,
};
return raw.clamp(0, base).toDouble().asMoney;
}
String get label => switch (type) {
DiscountType.percentage => '${value.toStringAsFixed(0)}% off',
DiscountType.flat => 'Flat ${AppConstants.currencySymbol}$value off',
DiscountType.none => 'No discount',
};
@override
List<Object?> get props => [type, value, reason];
}
/// One product line inside the cart.
class CartLine extends Equatable {
const CartLine({
required this.product,
required this.quantity,
this.discount = Discount.none,
this.addedAt,
});
final Product product;
final double quantity;
final Discount discount;
final DateTime? addedAt;
String get id => product.id;
/// Line value before discount, GST inclusive.
double get grossAmount => (product.price * quantity).asMoney;
double get discountAmount => discount.amountOn(grossAmount);
/// Payable for this line after discount, GST inclusive.
double get payable => (grossAmount - discountAmount).asMoney;
/// Taxable value inside [payable].
double get taxableValue => (payable / (1 + product.gstRate)).asMoney;
/// GST rupees inside [payable].
double get taxAmount => (payable - taxableValue).asMoney;
double get cgst => (taxAmount / 2).asMoney;
double get sgst => (taxAmount / 2).asMoney;
double get mrpSavings => product.hasDiscount
? (product.savings * quantity).asMoney
: 0;
bool get exceedsStock => quantity > product.stock;
CartLine copyWith({double? quantity, Discount? discount}) => CartLine(
product: product,
quantity: quantity ?? this.quantity,
discount: discount ?? this.discount,
addedAt: addedAt,
);
@override
List<Object?> get props => [product.id, quantity, discount];
}
/// The live bill. Immutable — every mutation returns a new instance, which
/// keeps the Riverpod notifier predictable and makes undo trivial.
class Cart extends Equatable {
const Cart({
this.lines = const [],
this.customer,
this.billDiscount = Discount.none,
this.pointsRedeemed = 0,
this.note,
});
final List<CartLine> lines;
final Customer? customer;
final Discount billDiscount;
final int pointsRedeemed;
final String? note;
static const Cart empty = Cart();
bool get isEmpty => lines.isEmpty;
bool get isNotEmpty => lines.isNotEmpty;
bool get isWalkIn => customer == null;
int get lineCount => lines.length;
double get totalQuantity =>
lines.fold(0.0, (sum, l) => sum + l.quantity);
/// Sum of line values before any bill-level discount, GST inclusive.
double get subtotal =>
lines.fold(0.0, (sum, l) => sum + l.payable).asMoney;
/// Discounts applied at the individual line level.
double get lineDiscountTotal =>
lines.fold(0.0, (sum, l) => sum + l.discountAmount).asMoney;
/// Automatic discount earned through the customer's membership tier.
Discount get membershipDiscount {
final rate = customer?.tier.discountRate ?? 0;
if (rate <= 0) return Discount.none;
return Discount(
type: DiscountType.percentage,
value: rate * 100,
reason: '${customer!.tier.label} member',
);
}
double get membershipDiscountAmount =>
membershipDiscount.amountOn(subtotal);
double get manualBillDiscountAmount => billDiscount.amountOn(subtotal);
/// All bill-level reductions combined.
double get billDiscountTotal =>
(membershipDiscountAmount + manualBillDiscountAmount)
.clamp(0, subtotal)
.toDouble()
.asMoney;
double get loyaltyRedemptionValue =>
(pointsRedeemed * AppConstants.loyaltyPointValue).asMoney;
/// Payable after every discount, GST inclusive, before round-off.
double get netAmount {
final v = subtotal - billDiscountTotal - loyaltyRedemptionValue;
return v.clamp(0, double.infinity).toDouble().asMoney;
}
/// Proportion of the bill remaining after bill-level reductions. Used to
/// spread those reductions fairly across lines when apportioning GST.
double get _billFactor => subtotal <= 0 ? 1 : netAmount / subtotal;
/// GST payable across the bill, after apportioning bill-level discounts.
double get taxAmount =>
lines.fold(0.0, (sum, l) => sum + l.taxAmount * _billFactor).asMoney;
double get cgst => (taxAmount / 2).asMoney;
double get sgst => (taxAmount / 2).asMoney;
/// Taxable value across the bill.
double get taxableAmount => (netAmount - taxAmount).asMoney;
/// GST broken out per slab — required on a compliant tax invoice.
Map<double, double> get taxBreakdown {
final map = <double, double>{};
for (final line in lines) {
final rate = line.product.gstRate;
map[rate] = ((map[rate] ?? 0) + line.taxAmount * _billFactor).asMoney;
}
return map;
}
double get grandTotal => netAmount.roundedToRupee;
/// The paise adjustment shown as "Round Off" on the bill.
double get roundOff => (grandTotal - netAmount).asMoney;
double get mrpSavingsTotal =>
lines.fold(0.0, (sum, l) => sum + l.mrpSavings).asMoney;
/// Everything the shopper saved on this bill.
double get totalSavings =>
(mrpSavingsTotal + lineDiscountTotal + billDiscountTotal).asMoney;
/// Points this sale will earn. Walk-in customers earn nothing.
int get pointsEarned {
if (customer == null) return 0;
return (grandTotal / AppConstants.loyaltyRupeesPerPoint).floor();
}
int get maxRedeemablePoints {
final c = customer;
if (c == null) return 0;
final byBalance = c.loyaltyPoints;
final byBill =
(subtotal - billDiscountTotal) ~/ AppConstants.loyaltyPointValue;
return byBalance < byBill ? byBalance : byBill;
}
CartLine? lineFor(String productId) =>
lines.firstWhereOrNull((l) => l.product.id == productId);
bool contains(String productId) => lineFor(productId) != null;
Cart copyWith({
List<CartLine>? lines,
Customer? customer,
bool clearCustomer = false,
Discount? billDiscount,
int? pointsRedeemed,
String? note,
}) {
return Cart(
lines: lines ?? this.lines,
customer: clearCustomer ? null : (customer ?? this.customer),
billDiscount: billDiscount ?? this.billDiscount,
pointsRedeemed: pointsRedeemed ?? this.pointsRedeemed,
note: note ?? this.note,
);
}
@override
List<Object?> get props =>
[lines, customer, billDiscount, pointsRedeemed, note];
}

View File

@@ -0,0 +1,123 @@
import 'package:equatable/equatable.dart';
import '../../core/constants/app_constants.dart';
enum Gender {
male('Male'),
female('Female'),
other('Other'),
unspecified('Prefer not to say');
const Gender(this.label);
final String label;
}
/// Loyalty tier, derived from lifetime spend.
enum MembershipTier {
bronze('Bronze', 0, 0.0),
silver('Silver', 10000, 0.02),
gold('Gold', 50000, 0.05),
platinum('Platinum', 150000, 0.08);
const MembershipTier(this.label, this.threshold, this.discountRate);
final String label;
/// Lifetime spend in rupees required to reach this tier.
final double threshold;
/// Automatic bill discount granted to members of this tier.
final double discountRate;
static MembershipTier forSpend(double lifetimeSpend) {
return MembershipTier.values.lastWhere(
(t) => lifetimeSpend >= t.threshold,
orElse: () => MembershipTier.bronze,
);
}
MembershipTier? get next {
final i = index;
return i < MembershipTier.values.length - 1
? MembershipTier.values[i + 1]
: null;
}
}
/// A registered shopper. A `null` customer on a sale means walk-in.
class Customer extends Equatable {
const Customer({
required this.id,
required this.name,
required this.mobile,
this.email,
this.gender = Gender.unspecified,
this.dateOfBirth,
this.loyaltyPoints = 0,
this.lifetimeSpend = 0,
this.visitCount = 0,
this.createdAt,
this.lastVisitAt,
});
final String id;
final String name;
final String mobile;
final String? email;
final Gender gender;
final DateTime? dateOfBirth;
final int loyaltyPoints;
final double lifetimeSpend;
final int visitCount;
final DateTime? createdAt;
final DateTime? lastVisitAt;
MembershipTier get tier => MembershipTier.forSpend(lifetimeSpend);
/// Cash value of the points currently held.
double get redeemableValue =>
loyaltyPoints * AppConstants.loyaltyPointValue;
/// Rupees of additional spend needed to reach the next tier.
double? get spendToNextTier {
final next = tier.next;
if (next == null) return null;
return (next.threshold - lifetimeSpend).clamp(0, double.infinity);
}
bool get isBirthdayToday {
final dob = dateOfBirth;
if (dob == null) return false;
final now = DateTime.now();
return dob.month == now.month && dob.day == now.day;
}
Customer copyWith({
String? name,
String? email,
Gender? gender,
DateTime? dateOfBirth,
int? loyaltyPoints,
double? lifetimeSpend,
int? visitCount,
DateTime? lastVisitAt,
}) {
return Customer(
id: id,
name: name ?? this.name,
mobile: mobile,
email: email ?? this.email,
gender: gender ?? this.gender,
dateOfBirth: dateOfBirth ?? this.dateOfBirth,
loyaltyPoints: loyaltyPoints ?? this.loyaltyPoints,
lifetimeSpend: lifetimeSpend ?? this.lifetimeSpend,
visitCount: visitCount ?? this.visitCount,
createdAt: createdAt,
lastVisitAt: lastVisitAt ?? this.lastVisitAt,
);
}
@override
List<Object?> get props => [id, name, mobile, loyaltyPoints, lifetimeSpend];
}

View File

@@ -0,0 +1,134 @@
import 'package:equatable/equatable.dart';
import '../../core/constants/app_constants.dart';
/// Merchandise categories shown as filter chips on the POS dashboard.
enum ProductCategory {
dairy('Dairy', '🥛'),
grocery('Grocery', '🛒'),
fruits('Fruits', '🍎'),
vegetables('Vegetables', '🥦'),
beverages('Beverages', '🥤'),
snacks('Snacks', '🍪'),
personalCare('Personal Care', '🧴'),
household('Household', '🏠');
const ProductCategory(this.label, this.emoji);
final String label;
final String emoji;
}
/// Unit of measure — drives whether fractional quantities are permitted.
enum UnitOfMeasure {
piece('pc'),
kilogram('kg'),
gram('g'),
litre('L'),
millilitre('ml'),
pack('pack');
const UnitOfMeasure(this.symbol);
final String symbol;
bool get allowsFractional =>
this == UnitOfMeasure.kilogram || this == UnitOfMeasure.litre;
}
/// A sellable item in the catalogue.
class Product extends Equatable {
const Product({
required this.id,
required this.name,
required this.barcode,
required this.sku,
required this.category,
required this.price,
required this.stock,
this.mrp,
this.emoji = '📦',
this.imageUrl,
this.unit = UnitOfMeasure.piece,
this.gstRate = AppConstants.defaultGstRate,
this.brand,
this.isActive = true,
});
final String id;
final String name;
final String barcode;
final String sku;
final ProductCategory category;
/// Selling price per [unit], inclusive of GST (Indian retail convention).
final double price;
/// Printed maximum retail price, used to display savings.
final double? mrp;
final double stock;
final String emoji;
final String? imageUrl;
final UnitOfMeasure unit;
final double gstRate;
final String? brand;
final bool isActive;
bool get isOutOfStock => stock <= 0;
bool get isLowStock =>
stock > 0 && stock <= AppConstants.lowStockThreshold;
bool get hasDiscount => mrp != null && mrp! > price;
double get savings => hasDiscount ? (mrp! - price) : 0;
double get discountPercent =>
hasDiscount ? ((mrp! - price) / mrp!) * 100 : 0;
/// Price stripped of embedded GST — the taxable value.
double get netPrice => price / (1 + gstRate);
/// GST rupees embedded inside [price].
double get taxPerUnit => price - netPrice;
/// Fuzzy match used by the search bar across name, barcode, SKU and brand.
bool matches(String query) {
final q = query.trim().toLowerCase();
if (q.isEmpty) return true;
return name.toLowerCase().contains(q) ||
barcode.toLowerCase().contains(q) ||
sku.toLowerCase().contains(q) ||
(brand?.toLowerCase().contains(q) ?? false) ||
category.label.toLowerCase().contains(q);
}
Product copyWith({
String? name,
double? price,
double? mrp,
double? stock,
ProductCategory? category,
bool? isActive,
}) {
return Product(
id: id,
name: name ?? this.name,
barcode: barcode,
sku: sku,
category: category ?? this.category,
price: price ?? this.price,
mrp: mrp ?? this.mrp,
stock: stock ?? this.stock,
emoji: emoji,
imageUrl: imageUrl,
unit: unit,
gstRate: gstRate,
brand: brand,
isActive: isActive ?? this.isActive,
);
}
@override
List<Object?> get props => [id, name, barcode, sku, price, stock, isActive];
}

View File

@@ -0,0 +1,152 @@
import 'package:equatable/equatable.dart';
import '../../core/utils/extensions.dart';
import 'transaction.dart';
/// Everything the back office needs from a day at this terminal.
///
/// Computed from the locally stored transactions, so it can be produced with
/// no connection and pushed whenever one is available.
class ShiftReport extends Equatable {
const ShiftReport({
required this.businessDate,
required this.terminalId,
required this.cashierName,
required this.billCount,
required this.itemCount,
required this.grossSales,
required this.taxCollected,
required this.discountGiven,
required this.roundOff,
required this.paymentBreakdown,
required this.loyaltyPointsIssued,
required this.loyaltyPointsRedeemed,
this.firstBillAt,
this.lastBillAt,
});
final DateTime businessDate;
final String terminalId;
final String cashierName;
final int billCount;
/// Total units sold across every bill.
final double itemCount;
final double grossSales;
final double taxCollected;
final double discountGiven;
final double roundOff;
final Map<PaymentMethod, double> paymentBreakdown;
final int loyaltyPointsIssued;
final int loyaltyPointsRedeemed;
final DateTime? firstBillAt;
final DateTime? lastBillAt;
/// A zeroed report for a day with no trading. Not a const — [DateTime]
/// cannot appear in a constant expression.
factory ShiftReport.blank({
required DateTime businessDate,
required String terminalId,
required String cashierName,
}) =>
ShiftReport(
businessDate: businessDate,
terminalId: terminalId,
cashierName: cashierName,
billCount: 0,
itemCount: 0,
grossSales: 0,
taxCollected: 0,
discountGiven: 0,
roundOff: 0,
paymentBreakdown: const {},
loyaltyPointsIssued: 0,
loyaltyPointsRedeemed: 0,
);
bool get isEmpty => billCount == 0;
double get averageBasket =>
billCount == 0 ? 0 : (grossSales / billCount).asMoney;
double get netOfTax => (grossSales - taxCollected).asMoney;
/// Builds the report from the day's transactions.
factory ShiftReport.fromTransactions({
required List<SaleTransaction> transactions,
required DateTime businessDate,
required String terminalId,
required String cashierName,
}) {
final completed = transactions
.where((t) =>
t.status == TransactionStatus.completed &&
t.createdAt.year == businessDate.year &&
t.createdAt.month == businessDate.month &&
t.createdAt.day == businessDate.day)
.toList()
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
final byMethod = <PaymentMethod, double>{};
for (final t in completed) {
for (final p in t.payments) {
byMethod[p.method] = ((byMethod[p.method] ?? 0) + p.amount).asMoney;
}
}
return ShiftReport(
businessDate: businessDate,
terminalId: terminalId,
cashierName: cashierName,
billCount: completed.length,
itemCount:
completed.fold(0.0, (s, t) => s + t.cart.totalQuantity),
grossSales: completed.fold(0.0, (s, t) => s + t.total).asMoney,
taxCollected:
completed.fold(0.0, (s, t) => s + t.cart.taxAmount).asMoney,
discountGiven: completed
.fold(0.0,
(s, t) => s + t.cart.billDiscountTotal + t.cart.lineDiscountTotal)
.asMoney,
roundOff: completed.fold(0.0, (s, t) => s + t.cart.roundOff).asMoney,
paymentBreakdown: byMethod,
loyaltyPointsIssued:
completed.fold(0, (s, t) => s + t.pointsEarned),
loyaltyPointsRedeemed:
completed.fold(0, (s, t) => s + t.pointsRedeemed),
firstBillAt: completed.isEmpty ? null : completed.first.createdAt,
lastBillAt: completed.isEmpty ? null : completed.last.createdAt,
);
}
/// The JSON body that would be sent to the back office.
Map<String, Object?> toPayload() => {
'business_date': businessDate.toIso8601String().substring(0, 10),
'terminal_id': terminalId,
'cashier': cashierName,
'bill_count': billCount,
'item_count': itemCount,
'gross_sales': grossSales,
'net_of_tax': netOfTax,
'tax_collected': taxCollected,
'discount_given': discountGiven,
'round_off': roundOff,
'average_basket': averageBasket,
'loyalty_points_issued': loyaltyPointsIssued,
'loyalty_points_redeemed': loyaltyPointsRedeemed,
'first_bill_at': firstBillAt?.toIso8601String(),
'last_bill_at': lastBillAt?.toIso8601String(),
'payments': {
for (final e in paymentBreakdown.entries) e.key.name: e.value,
},
};
@override
List<Object?> get props =>
[businessDate, terminalId, billCount, grossSales];
}

View File

@@ -0,0 +1,63 @@
import 'package:equatable/equatable.dart';
/// What a staff member is allowed to do.
enum StaffRole {
admin('Admin', 'Full access to every module'),
manager('Manager', 'Sales, inventory and reports'),
cashier('Cashier', 'Billing and customers only');
const StaffRole(this.label, this.description);
final String label;
final String description;
bool get canVoidSale => this != StaffRole.cashier;
bool get canEditPricing => this == StaffRole.admin;
bool get canViewReports => this != StaffRole.cashier;
}
/// A person who signs in at the terminal.
class StaffUser extends Equatable {
const StaffUser({
required this.id,
required this.name,
required this.role,
required this.pin,
});
final String id;
final String name;
final StaffRole role;
/// Four-digit quick-unlock code. Never rendered.
final String pin;
@override
List<Object?> get props => [id, name, role];
}
/// The registered outlet this terminal belongs to.
class StoreAccount extends Equatable {
const StoreAccount({
required this.id,
required this.name,
required this.email,
required this.address,
required this.gstin,
required this.phone,
required this.staff,
this.plan = 'Business',
});
final String id;
final String name;
final String email;
final String address;
final String gstin;
final String phone;
final List<StaffUser> staff;
final String plan;
@override
List<Object?> get props => [id, email];
}

View File

@@ -0,0 +1,85 @@
import 'package:equatable/equatable.dart';
/// The two moments this terminal talks to the server.
enum SyncEventType {
catalogueImport('Catalogue Import', 'Pulled products from the server'),
shiftReport('Shift Report', 'Pushed the day\'s takings to the server');
const SyncEventType(this.label, this.description);
final String label;
final String description;
bool get isInbound => this == SyncEventType.catalogueImport;
}
enum SyncStatus {
/// Held locally, not yet sent. Nothing is ever discarded in this state.
pending('Pending'),
syncing('Syncing'),
synced('Synced'),
failed('Failed');
const SyncStatus(this.label);
final String label;
bool get isTerminal => this == SyncStatus.synced;
bool get needsAttention => this == SyncStatus.failed || this == SyncStatus.pending;
}
/// A durable record of one sync attempt.
///
/// Events are never deleted on failure — a failed push stays queued so the
/// day's takings survive a dropped connection.
class SyncEvent extends Equatable {
const SyncEvent({
required this.id,
required this.type,
required this.status,
required this.createdAt,
required this.summary,
this.payload = const {},
this.syncedAt,
this.error,
this.attempts = 0,
});
final String id;
final SyncEventType type;
final SyncStatus status;
final DateTime createdAt;
/// One-line description shown in the events log.
final String summary;
/// What would be transmitted. Kept so a retry needs no recomputation.
final Map<String, Object?> payload;
final DateTime? syncedAt;
final String? error;
final int attempts;
SyncEvent copyWith({
SyncStatus? status,
DateTime? syncedAt,
String? error,
bool clearError = false,
int? attempts,
}) {
return SyncEvent(
id: id,
type: type,
status: status ?? this.status,
createdAt: createdAt,
summary: summary,
payload: payload,
syncedAt: syncedAt ?? this.syncedAt,
error: clearError ? null : (error ?? this.error),
attempts: attempts ?? this.attempts,
);
}
@override
List<Object?> get props => [id, status, attempts, syncedAt];
}

View File

@@ -0,0 +1,133 @@
import 'package:equatable/equatable.dart';
import '../../core/utils/extensions.dart';
import 'cart.dart';
import 'customer.dart';
enum PaymentMethod {
cash('Cash', '💵', true),
card('Card', '💳', false),
upi('UPI', '📱', false),
wallet('Wallet', '👛', false),
giftCard('Gift Card', '🎁', false),
loyalty('Loyalty Points', '', false);
const PaymentMethod(this.label, this.emoji, this.needsChange);
final String label;
final String emoji;
/// Only cash tenders can be over-paid and produce change.
final bool needsChange;
/// Non-cash tenders normally capture a reference number.
bool get needsReference =>
this == PaymentMethod.card ||
this == PaymentMethod.upi ||
this == PaymentMethod.giftCard;
}
/// A single tender against a bill. A split payment holds several of these.
class PaymentSplit extends Equatable {
const PaymentSplit({
required this.method,
required this.amount,
this.tendered,
this.reference,
});
final PaymentMethod method;
/// Amount settled by this tender.
final double amount;
/// Cash handed over — may exceed [amount].
final double? tendered;
/// Card approval code, UPI txn id, gift card number.
final String? reference;
double get change {
if (!method.needsChange || tendered == null) return 0;
final diff = tendered! - amount;
return diff > 0 ? diff.asMoney : 0;
}
@override
List<Object?> get props => [method, amount, tendered, reference];
}
enum TransactionStatus { completed, parked, voided, refunded }
/// An immutable record of a finished sale.
class SaleTransaction extends Equatable {
const SaleTransaction({
required this.id,
required this.invoiceNumber,
required this.cart,
required this.payments,
required this.createdAt,
required this.cashierName,
this.status = TransactionStatus.completed,
this.terminalId = 'TERM-01',
});
final String id;
final String invoiceNumber;
final Cart cart;
final List<PaymentSplit> payments;
final DateTime createdAt;
final String cashierName;
final TransactionStatus status;
final String terminalId;
Customer? get customer => cart.customer;
double get total => cart.grandTotal;
double get amountPaid =>
payments.fold(0.0, (sum, p) => sum + p.amount).asMoney;
double get amountTendered => payments
.fold(0.0, (sum, p) => sum + (p.tendered ?? p.amount))
.asMoney;
double get changeDue =>
payments.fold(0.0, (sum, p) => sum + p.change).asMoney;
double get balanceDue => (total - amountPaid).clamp(0, double.infinity);
bool get isFullySettled => balanceDue <= 0.001;
bool get isSplit => payments.length > 1;
int get pointsEarned => cart.pointsEarned;
int get pointsRedeemed => cart.pointsRedeemed;
String get paymentSummary =>
payments.map((p) => p.method.label).toSet().join(' + ');
@override
List<Object?> get props => [id, invoiceNumber, createdAt, status];
}
/// A bill set aside so the cashier can serve the next shopper.
class ParkedBill extends Equatable {
const ParkedBill({
required this.id,
required this.cart,
required this.parkedAt,
this.label,
});
final String id;
final Cart cart;
final DateTime parkedAt;
final String? label;
String get displayLabel =>
label ?? cart.customer?.name ?? 'Walk-in #${id.substring(0, 4)}';
@override
List<Object?> get props => [id, parkedAt];
}

View File

@@ -0,0 +1,24 @@
import '../entities/customer.dart';
abstract class CustomerRepository {
/// Primary lookup on the Existing Customer screen.
Future<Customer?> findByMobile(String mobile);
Future<Customer?> findById(String id);
Future<Customer> create(Customer customer);
Future<Customer> update(Customer customer);
/// Applies loyalty and lifetime-spend changes once a sale completes.
Future<Customer> recordSale({
required String customerId,
required double amount,
required int pointsEarned,
required int pointsRedeemed,
});
Future<List<Customer>> search(String query);
Future<List<Customer>> recent({int limit = 20});
}

View File

@@ -0,0 +1,22 @@
import '../entities/product.dart';
/// Contract for catalogue access. Implemented in the data layer so the
/// presentation layer never depends on Hive, HTTP or any other detail.
abstract class ProductRepository {
Future<List<Product>> getAll();
Future<List<Product>> getByCategory(ProductCategory category);
/// Exact barcode lookup — the hot path for scanner billing.
Future<Product?> findByBarcode(String barcode);
Future<Product?> findById(String id);
/// Fuzzy search across name, barcode, SKU, brand and category.
Future<List<Product>> search(String query);
/// Decrements stock after a completed sale.
Future<void> decrementStock(Map<String, double> quantitiesByProductId);
Future<void> upsert(Product product);
}

View File

@@ -0,0 +1,38 @@
import '../entities/shift_report.dart';
import '../entities/sync_event.dart';
/// The terminal's two network touchpoints, plus the durable event log.
abstract class SyncRepository {
/// True once products have been pulled onto this terminal.
bool get hasCatalogue;
DateTime? get lastImportAt;
String? get catalogueRevision;
/// Pulls the catalogue and writes it locally.
///
/// Records a [SyncEventType.catalogueImport] event whether or not it
/// succeeds, so the log reflects every attempt.
Future<SyncEvent> importCatalogue({
void Function(double progress, String stage)? onProgress,
});
/// Builds the day's report from locally stored sales.
ShiftReport buildShiftReport({
required DateTime businessDate,
required String terminalId,
required String cashierName,
});
/// Queues the report and attempts to push it.
///
/// On failure the event is kept in [SyncStatus.failed] so nothing is lost.
Future<SyncEvent> pushShiftReport(ShiftReport report);
/// Retries a previously failed or pending push.
Future<SyncEvent> retry(String eventId);
List<SyncEvent> get events;
bool get hasUnsyncedEvents;
}

View File

@@ -0,0 +1,20 @@
import '../entities/transaction.dart';
abstract class TransactionRepository {
Future<SaleTransaction> save(SaleTransaction transaction);
Future<List<SaleTransaction>> history({int limit = 50});
Future<SaleTransaction?> findByInvoice(String invoiceNumber);
/// Next invoice sequence for the current month.
Future<int> nextInvoiceSequence();
Future<void> park(ParkedBill bill);
Future<List<ParkedBill>> parkedBills();
Future<void> removeParked(String id);
Future<double> salesTotalForDay(DateTime day);
}

View File

@@ -0,0 +1,139 @@
import 'package:uuid/uuid.dart';
import '../../core/constants/app_constants.dart';
import '../../core/utils/formatters.dart';
import '../entities/cart.dart';
import '../entities/customer.dart';
import '../entities/transaction.dart';
import '../repositories/customer_repository.dart';
import '../repositories/product_repository.dart';
import '../repositories/transaction_repository.dart';
/// Raised when a sale cannot be completed. Carries a cashier-readable message.
class CheckoutFailure implements Exception {
const CheckoutFailure(this.message);
final String message;
@override
String toString() => message;
}
/// Result of a successful checkout.
class CheckoutResult {
const CheckoutResult({required this.transaction, this.updatedCustomer});
final SaleTransaction transaction;
final Customer? updatedCustomer;
}
/// Completes a sale end to end.
///
/// Validates tenders, persists the transaction, decrements stock and applies
/// loyalty movement. Everything the cashier's Complete Sale button needs lives
/// here rather than in the UI, so the flow is unit-testable in isolation.
class CheckoutSale {
const CheckoutSale({
required ProductRepository productRepository,
required CustomerRepository customerRepository,
required TransactionRepository transactionRepository,
}) : _products = productRepository,
_customers = customerRepository,
_transactions = transactionRepository;
final ProductRepository _products;
final CustomerRepository _customers;
final TransactionRepository _transactions;
static const _uuid = Uuid();
Future<CheckoutResult> call({
required Cart cart,
required List<PaymentSplit> payments,
required String cashierName,
}) async {
_validate(cart, payments);
final now = DateTime.now();
final sequence = await _transactions.nextInvoiceSequence();
final transaction = SaleTransaction(
id: _uuid.v4(),
invoiceNumber: Formatters.invoiceNumber(sequence, now),
cart: cart,
payments: payments,
createdAt: now,
cashierName: cashierName,
);
await _transactions.save(transaction);
await _products.decrementStock({
for (final line in cart.lines) line.product.id: line.quantity,
});
Customer? updatedCustomer;
final customer = cart.customer;
if (customer != null) {
updatedCustomer = await _customers.recordSale(
customerId: customer.id,
amount: cart.grandTotal,
pointsEarned: cart.pointsEarned,
pointsRedeemed: cart.pointsRedeemed,
);
}
return CheckoutResult(
transaction: transaction,
updatedCustomer: updatedCustomer,
);
}
void _validate(Cart cart, List<PaymentSplit> payments) {
if (cart.isEmpty) {
throw const CheckoutFailure('Add at least one item before charging.');
}
if (payments.isEmpty) {
throw const CheckoutFailure('Select a payment method.');
}
for (final line in cart.lines) {
if (line.quantity <= 0) {
throw CheckoutFailure('${line.product.name} has an invalid quantity.');
}
if (line.exceedsStock) {
throw CheckoutFailure(
'Only ${line.product.stock.toStringAsFixed(0)} '
'${line.product.unit.symbol} of ${line.product.name} in stock.',
);
}
}
if (cart.pointsRedeemed > 0) {
final available = cart.customer?.loyaltyPoints ?? 0;
if (cart.pointsRedeemed > available) {
throw const CheckoutFailure('Not enough loyalty points to redeem.');
}
}
final paid = payments.fold(0.0, (sum, p) => sum + p.amount);
final shortfall = cart.grandTotal - paid;
if (shortfall > 0.01) {
throw CheckoutFailure(
'${AppConstants.currencySymbol}${shortfall.toStringAsFixed(2)} '
'still due on this bill.',
);
}
for (final p in payments) {
if (p.amount <= 0) {
throw CheckoutFailure('${p.method.label} amount must be positive.');
}
if (p.method.needsChange &&
p.tendered != null &&
p.tendered! < p.amount) {
throw const CheckoutFailure('Cash tendered is less than the amount due.');
}
}
}
}