second commit
This commit is contained in:
238
lib/domain/entities/cart.dart
Normal file
238
lib/domain/entities/cart.dart
Normal 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];
|
||||
}
|
||||
123
lib/domain/entities/customer.dart
Normal file
123
lib/domain/entities/customer.dart
Normal 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];
|
||||
}
|
||||
134
lib/domain/entities/product.dart
Normal file
134
lib/domain/entities/product.dart
Normal 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];
|
||||
}
|
||||
152
lib/domain/entities/shift_report.dart
Normal file
152
lib/domain/entities/shift_report.dart
Normal 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];
|
||||
}
|
||||
63
lib/domain/entities/store_account.dart
Normal file
63
lib/domain/entities/store_account.dart
Normal 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];
|
||||
}
|
||||
85
lib/domain/entities/sync_event.dart
Normal file
85
lib/domain/entities/sync_event.dart
Normal 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];
|
||||
}
|
||||
133
lib/domain/entities/transaction.dart
Normal file
133
lib/domain/entities/transaction.dart
Normal 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];
|
||||
}
|
||||
24
lib/domain/repositories/customer_repository.dart
Normal file
24
lib/domain/repositories/customer_repository.dart
Normal 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});
|
||||
}
|
||||
22
lib/domain/repositories/product_repository.dart
Normal file
22
lib/domain/repositories/product_repository.dart
Normal 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);
|
||||
}
|
||||
38
lib/domain/repositories/sync_repository.dart
Normal file
38
lib/domain/repositories/sync_repository.dart
Normal 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;
|
||||
}
|
||||
20
lib/domain/repositories/transaction_repository.dart
Normal file
20
lib/domain/repositories/transaction_repository.dart
Normal 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);
|
||||
}
|
||||
139
lib/domain/usecases/checkout_sale.dart
Normal file
139
lib/domain/usecases/checkout_sale.dart
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user