Files
app-grosir/lib/state/app_state.dart

946 lines
28 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/product.dart';
import '../models/sale.dart';
import '../models/user_profile.dart';
import '../services/offline_queue_service.dart';
import '../utils/format.dart';
import '../config.dart';
class CartLine {
final Product product;
int qty;
bool isReseller;
CartLine({
required this.product,
this.qty = 1,
this.isReseller = false,
});
double get price => isReseller ? product.priceReseller : product.price;
}
class AppState extends ChangeNotifier {
final SupabaseClient _supabase = Supabase.instance.client;
// Stream notifikasi transaksi baru (dipakai UI untuk banner+suara+getar).
// Diisi setiap createSale sukses DAN setiap realtime INSERT dari HP lain.
final StreamController<Sale> newSaleNotifications =
StreamController<Sale>.broadcast();
final Set<String> _notifiedSaleIds = {};
// Jumlah transaksi baru yang belum dilihat admin (badge di tab Riwayat).
int unseenSalesCount = 0;
void markSalesSeen() {
if (unseenSalesCount != 0) {
unseenSalesCount = 0;
notifyListeners();
}
}
Future<void> retryProducts() => refresh();
// Client dengan service_role key untuk operasi admin (buat/edit/hapus akun kasir).
// PERINGATAN: key ini bypass RLS — hanya untuk app POS di device terpercaya.
final SupabaseClient _admin = SupabaseClient(supabaseUrl, supabaseServiceKey);
static const Duration _timeout = Duration(seconds: 30);
static const String _currentStoreId = '00000000-0000-0000-0000-000000000000';
Future<T> _withTimeout<T>(Future<T> future, [String? operation]) async {
try {
return await future.timeout(
_timeout,
onTimeout: () => throw TimeoutException('${operation ?? 'Operasi'} timeout setelah $_timeout'),
);
} on TimeoutException catch (e) {
debugPrint('Supabase timeout: $e');
rethrow;
}
}
List<Product> products = [];
List<Sale> sales = [];
List<CartLine> cart = [];
List<Map<String, dynamic>> kasirAccounts = [];
bool loading = true;
String? error;
bool dataReady = false;
bool productsLoading = false;
String? productsError;
static const int _pageSize = 30;
int _visibleProductCount = _pageSize;
int _visibleSalesCount = _pageSize;
List<Product> get displayedProducts =>
products.take(_visibleProductCount).toList();
List<Sale> get displayedSales =>
sales.take(_visibleSalesCount).toList();
int get visibleProductCount => _visibleProductCount;
int get visibleSalesCount => _visibleSalesCount;
int get pageSize => _pageSize;
bool get hasMoreProducts => _visibleProductCount < products.length;
bool get hasMoreSales => _visibleSalesCount < sales.length;
void resetPagination() {
_visibleProductCount = _pageSize;
_visibleSalesCount = _pageSize;
notifyListeners();
}
void loadMoreProducts() {
if (!hasMoreProducts) return;
_visibleProductCount = (_visibleProductCount + _pageSize).clamp(0, products.length);
notifyListeners();
}
void loadMoreSales() {
if (!hasMoreSales) return;
_visibleSalesCount = (_visibleSalesCount + _pageSize).clamp(0, sales.length);
notifyListeners();
}
String customerType = 'umum';
bool resellerEnabled = true;
String? userId;
String? userRole;
UserProfile? _profile;
/// True jika session auth mati total (refresh token pun kedaluwarsa).
/// UI (kasir) memakai ini untuk menampilkan info "sesi habis" +
/// tombol login ulang, bukan list kosong yang membingungkan.
bool _sessionExpired = false;
bool get sessionExpired => _sessionExpired;
final Map<String, String> _profileNames = {};
double _discount = 0;
bool get isAdmin => userRole == 'admin';
bool get isKasir => userRole == 'kasir';
bool get isLoggedIn => userId != null;
String getProfileName(String? uid) {
if (uid == null) return 'Tidak diketahui';
if (uid == userId) return _profile?.fullName ?? 'Admin';
return _profileNames[uid] ?? 'Kasir';
}
AppState() {
_init();
}
Timer? _pollTimer;
static const Duration _pollInterval = Duration(minutes: 1);
DateTime? _lastManualRefresh;
void recordManualRefresh() {
_lastManualRefresh = DateTime.now();
}
void startPolling() {
stopPolling();
_pollTimer = Timer.periodic(_pollInterval, (_) => _poll());
}
void stopPolling() {
_pollTimer?.cancel();
_pollTimer = null;
}
Future<void> _poll() async {
if (_lastManualRefresh != null) {
final elapsed = DateTime.now().difference(_lastManualRefresh!);
if (elapsed < const Duration(seconds: 15)) return;
}
await refresh();
_checkNewSales();
}
bool _salesFirstLoad = true;
/// Deteksi transaksi baru dari polling/refresh dan kirim ke stream
/// notifikasi. Load pertama dilewati agar tidak spam saat start.
void _checkNewSales() {
// Hanya admin yang perlu notifikasi — kasir tidak (mereka yang
// transaksi, jadi sudah tahu).
if (userRole != 'admin') {
for (final s in sales) {
_notifiedSaleIds.add(s.id);
}
_salesFirstLoad = false;
return;
}
final now = DateTime.now();
bool newSeen = false;
for (final s in sales) {
if (!_notifiedSaleIds.contains(s.id)) {
_notifiedSaleIds.add(s.id);
// Lewati transaksi lama (>5 menit) agar tidak spam notif saat start.
// Lewati juga transaksi sendiri — admin tahu kalau dia yang jualan.
if (!_salesFirstLoad &&
s.userId != userId &&
now.difference(s.date).inMinutes.abs() <= 5) {
newSeen = true;
unseenSalesCount++;
if (!newSaleNotifications.isClosed) {
newSaleNotifications.add(s);
}
}
}
}
if (newSeen) notifyListeners();
_salesFirstLoad = false;
}
/// Refresh produk + riwayat dengan retry terbatas untuk kegagalan sementara.
/// Nyalakan indikator loading hanya saat data pertama belum siap, supaya
/// Kasir bisa menampilkan status memuat/gagal/kosong dengan jelas.
/// Nonaktifkan indikator apa pun yang menyebabkan masalah refresh berulang.
/// Kirim sinyal hanya pada pemuatan awal; polling latar tidak mengganggu.
Future<void> refresh({bool notifyLoading = true, int retries = 1}) async {
final authOk = await _ensureSessionAndAuth();
if (!authOk) {
// Auth mati: jangan lanjut query (pasti 401). Cukup notify agar
// AuthGate redirect ke LoginScreen.
notifyListeners();
return;
}
final showProductsLoading = notifyLoading && !dataReady;
if (showProductsLoading) {
productsLoading = true;
notifyListeners();
}
try {
await _loadProducts();
await _loadSales();
dataReady = true;
// Server terbukti reachable → coba kirim antrean transaksi offline.
await _offlineQueue.retryNow();
} catch (e) {
productsError ??= e.toString();
if (retries > 0) {
// Coba sekali lagi untuk kegagalan sementara (misal koneksi putus).
productsError = null;
await Future<void>.delayed(const Duration(seconds: 1));
await refresh(notifyLoading: notifyLoading, retries: retries - 1);
return;
}
debugPrint('Gagal refresh: $e');
} finally {
if (showProductsLoading) {
productsLoading = false;
}
if (notifyLoading) {
notifyListeners();
}
}
}
final OfflineQueueService _offlineQueue = OfflineQueueService();
/// ID transaksi yang masih dalam antrean offline (belum terkirim ke server).
Set<String> get pendingSaleIds => _offlineQueue.pendingSaleIds;
/// True jika transaksi dengan [saleId] masih menunggu sinkronisasi.
bool isSalePending(String? saleId) => _offlineQueue.isPending(saleId);
/// Jumlah transaksi offline yang belum tersinkron (badge di Riwayat).
int get pendingOfflineCount => _offlineQueue.pendingSaleIds.length;
double get subtotal =>
cart.fold(0, (sum, line) => sum + line.price * line.qty);
double get discount => _discount;
double get total => (subtotal - _discount).clamp(0, double.infinity);
bool get canCheckout => cart.isNotEmpty && total > 0;
/* ---------- Init & Realtime ---------- */
Future<void> _init() async {
try {
final authOk = await _ensureSessionAndAuth();
if (!authOk) {
// Session mati total (refresh token ikut kadaluwarsa). Jangan lanjut:
// _loadProducts() akan timeout 30s + 401, bakal menjebak loading walau
// produk sendiri masih ada. Tandai sessionExpired, jalankan polling
// saja (real-time masih bisa update), dan biarkan UI tampil
// "login ulang".
_sessionExpired = true;
startPolling();
error = 'Sesi auth belum tersedia, silakan login ulang.';
} else {
await _loadProducts();
await _loadSales();
await loadKasirAccounts();
await _loadProfileNames();
await _loadSettings();
_subscribe();
await _seedAdminIfEmpty();
await _offlineQueue.init();
startPolling();
}
} catch (e) {
error = e.toString();
}
loading = false;
notifyListeners();
}
Future<void> _loadSettings() async {
final prefs = await SharedPreferences.getInstance();
resellerEnabled = prefs.getBool('reseller_enabled') ?? true;
notifyListeners();
}
Future<void> setResellerEnabled(bool value) async {
resellerEnabled = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('reseller_enabled', value);
if (!value) {
setCustomerType('umum');
for (final line in cart) {
if (line.isReseller) toggleLineMode(line.product.id);
}
}
notifyListeners();
}
Future<void> _loadProfile() async {
if (userId == null) return;
try {
final data = await _supabase
.from('profiles')
.select()
.eq('id', userId!)
.maybeSingle();
if (data != null) {
_profile = UserProfile.fromJson(data);
userRole = _profile!.role;
}
} catch (_) {}
}
Future<void> _seedAdminIfEmpty() async {
try {
final count = await _withTimeout(
_admin.from('profiles').select().count(),
'Check admin profiles',
);
if (count.count == 0) {
await _withTimeout(
_admin.auth.admin.createUser(
AdminUserAttributes(email: 'admin@posgrosir.local', password: 'admin123'),
),
'Create default admin',
);
}
} catch (_) {}
}
Future<String?> login(String email, String password) async {
try {
final res = await _withTimeout(
_supabase.auth.signInWithPassword(
email: email,
password: password,
),
'Login',
);
userId = res.user?.id;
_sessionExpired = false;
dataReady = false;
productsLoading = true;
productsError = null;
notifyListeners();
await _loadProfile();
await refresh();
return null;
} on AuthException catch (e) {
productsLoading = false;
notifyListeners();
return e.message;
} catch (e) {
productsLoading = false;
notifyListeners();
return e.toString();
}
}
// Reserved for future self-service signup flow.
// Currently unused per PRD §5.3: all devices must be pre-provisioned by admin.
Future<String?> signup(String email, String password) async {
try {
final res = await _withTimeout(
_supabase.auth.signUp(
email: email,
password: password,
),
'Signup',
);
userId = res.user?.id;
await _loadProfile();
notifyListeners();
return null;
} on AuthException catch (e) {
return e.message;
} catch (e) {
return e.toString();
}
}
Future<void> logout() async {
await _withTimeout(_supabase.auth.signOut(), 'Logout');
userId = null;
userRole = null;
_profile = null;
cart.clear();
_discount = 0;
products = [];
dataReady = false;
productsLoading = false;
productsError = null;
notifyListeners();
}
Future<String?> createKasir(String email, String password, String? name) async {
try {
final res = await _withTimeout(
_admin.auth.admin.createUser(
AdminUserAttributes(email: email, password: password, emailConfirm: true),
),
'Create kasir user',
);
final newUserId = res.user?.id;
if (newUserId != null) {
await _withTimeout(
_admin.from('profiles').upsert({
'id': newUserId,
'role': 'kasir',
'full_name': name,
}),
'Create kasir profile',
);
await loadKasirAccounts();
}
return null;
} on AuthException catch (e) {
return e.message;
} catch (e) {
return e.toString();
}
}
Future<void> deleteKasir(String id) async {
try {
await _withTimeout(
_admin.from('profiles').delete().eq('id', id),
'Delete kasir profile',
);
await _withTimeout(
_admin.auth.admin.deleteUser(id),
'Delete kasir user',
);
await loadKasirAccounts();
} catch (_) {}
}
Future<String?> updateKasir(String id, String email, String? name, String? password) async {
try {
final updateData = <String, dynamic>{
'full_name': name,
};
if (password != null && password.isNotEmpty) {
await _withTimeout(
_admin.auth.admin.updateUserById(
id,
attributes: AdminUserAttributes(password: password),
),
'Update kasir password',
);
}
await _withTimeout(
_admin.from('profiles').update(updateData).eq('id', id),
'Update kasir profile',
);
await loadKasirAccounts();
return null;
} on AuthException catch (e) {
return e.message;
} catch (e) {
return e.toString();
}
}
Future<void> loadKasirAccounts() async {
try {
final rows = await _withTimeout(
_admin
.from('profiles')
.select()
.eq('role', 'kasir'),
'Load kasir accounts',
);
Map<String, String> emails = {};
try {
final usersRes = await _withTimeout(
_admin.auth.admin.listUsers(),
'List auth users',
);
for (final u in usersRes) {
if (u.email != null && u.email!.isNotEmpty) {
emails[u.id] = u.email!;
}
}
} catch (_) {}
kasirAccounts = rows.map((r) {
final map = Map<String, dynamic>.from(r);
if ((map['email'] == null || map['email'].toString().isEmpty) &&
emails.containsKey(map['id'])) {
map['email'] = emails[map['id']];
}
if ((map['full_name'] == null || map['full_name'].toString().isEmpty) &&
map['email'] != null &&
map['email'].toString().isNotEmpty) {
map['full_name'] = map['email'].toString().split('@').first;
}
return map;
}).toList();
notifyListeners();
} catch (_) {}
}
Future<void> _loadProfileNames() async {
try {
final rows = await _withTimeout(
_admin.from('profiles').select('id,full_name,email'),
'Load profile names',
);
_profileNames.clear();
for (final r in rows) {
final map = Map<String, dynamic>.from(r);
final name = (map['full_name']?.toString().trim().isEmpty == true
? map['email']?.toString().split('@').first
: map['full_name']?.toString().trim()) ??
'';
if (name.isNotEmpty) {
_profileNames[map['id'] as String] = name;
}
}
} catch (_) {}
}
void _subscribe() {
_supabase.auth.onAuthStateChange.listen((event) {
if (event.session != null) {
userId = event.session!.user.id;
_sessionExpired = false;
_loadProfile();
} else {
// Sesi mati total (logout / refresh token ikut kedaluwarsa):
// tandai agar UI bisa menampilkan pesan "sesi habis", bukan diam.
userId = null;
userRole = null;
_profile = null;
_sessionExpired = true;
}
notifyListeners();
});
_supabase.from('products').stream(primaryKey: ['id']).listen(
(rows) {
products = rows.map((e) => Product.fromJson(e)).toList();
notifyListeners();
},
onError: (e) {
debugPrint('REALTIME products stream error: $e');
},
);
_supabase.from('sales').stream(primaryKey: ['id']).listen(
(rows) {
sales = rows.map((e) => Sale.fromJson(e)).toList();
sales.sort((a, b) => b.date.compareTo(a.date));
notifyListeners();
},
onError: (e) {
debugPrint('REALTIME sales stream error: $e');
},
);
}
Future<void> _loadProducts() async {
try {
final rows = await _withTimeout(
_supabase.from('products').select().eq('store_id', _currentStoreId).order('name'),
'Load products',
);
final loaded = rows.map((e) => Product.fromJson(e)).toList();
// Hasil kosong dari server JANGAN menimpa data lama — itu tanda
// query gagal diam-diam (misal JWT expired), bukan toko kehabisan produk.
if (loaded.isNotEmpty) {
products = loaded;
} else if (products.isEmpty) {
_seed();
}
} catch (e) {
// Jangan crash refresh: produk lama tetap ditampilkan, error tercatat di log.
debugPrint('Gagal load products: $e');
}
}
/// Cek langkah demi langkah sampai session valid atau mati total.
/// Stop segera kalau auth mati, jangan tunggu timeout 30s per request.
Future<bool> _ensureSessionAndAuth() async {
final session = _supabase.auth.currentSession;
if (session != null && !session.isExpired) {
return true;
}
// Session kadaluwarsa: coba refresh token dulu.
try {
final res = await _supabase.auth.refreshSession();
if (res.session != null && !res.session!.isExpired) {
return true;
}
// Refresh jalan tapi session tetap tak valid → auth benar-benar mati.
_sessionExpired = true;
} on AuthException {
// Token refresh ditolak server (invalid/kedaluwarsa) → auth mati.
_sessionExpired = true;
} catch (_) {
// Error jaringan/timeout: JANGAN tandai sessionExpired (bisa network
// blip). Data gagal load sementara, session belum dianggap mati.
}
return false;
}
Future<void> _loadSales() async {
final rows = await _withTimeout(
_supabase
.from('sales')
.select()
.eq('store_id', _currentStoreId)
.order('created_at', ascending: false),
'Load sales',
);
sales = rows.map((e) => Sale.fromJson(e)).toList();
sales.sort((a, b) => b.date.compareTo(a.date));
}
Future<void> _seed() async {
final seeds = [
Product(id: uid(), name: 'Beras 5kg', price: 58000, priceReseller: 55000, stock: 50),
Product(id: uid(), name: 'Minyak 2L', price: 32000, priceReseller: 30000, stock: 40),
Product(id: uid(), name: 'Gula 1kg', price: 14000, priceReseller: 13000, stock: 60),
Product(id: uid(), name: 'Teh Botol', price: 3600, priceReseller: 3200, stock: 120),
Product(id: uid(), name: 'Sabun Cuci', price: 8500, priceReseller: 7800, stock: 35),
Product(id: uid(), name: 'Indomie Dus', price: 95000, priceReseller: 90000, stock: 25),
];
await _withTimeout(
_supabase.from('products').insert(seeds.map((e) => e.toJson()..['store_id'] = _currentStoreId).toList()),
'Seed products',
);
products = seeds;
notifyListeners();
}
/* ---------- Tipe Pembeli ---------- */
void setCustomerType(String type) {
customerType = type;
final reseller = type == 'reseller';
for (final c in cart) {
c.isReseller = reseller;
}
notifyListeners();
}
/* ---------- Produk ---------- */
Future<String?> addProduct(String name, String barcode, double price,
double priceReseller, int stock, int minStock, double hargaPokok,
{String? imagePath}) async {
try {
final trimmed = barcode.trim();
if (trimmed.isNotEmpty &&
products.any((p) => p.barcode.trim().toLowerCase() == trimmed.toLowerCase())) {
return 'Barcode sudah digunakan oleh produk lain';
}
final product = Product(
id: uid(),
name: name,
barcode: trimmed,
price: price,
priceReseller: priceReseller,
stock: stock,
minStock: minStock,
hargaPokok: hargaPokok,
imagePath: imagePath,
);
await _withTimeout(
_supabase.from('products').insert(product.toJson()..['store_id'] = _currentStoreId),
'Add product',
);
} catch (e) {
return e.toString();
}
try {
await _loadProducts();
} catch (_) {}
notifyListeners();
return null;
}
Future<String?> updateProduct(String id, String name, String barcode,
double price, double priceReseller, int stock, int minStock, double hargaPokok,
{String? imagePath}) async {
try {
final trimmed = barcode.trim();
if (trimmed.isNotEmpty &&
products.any((p) => p.id != id && p.barcode.trim().toLowerCase() == trimmed.toLowerCase())) {
return 'Barcode sudah digunakan oleh produk lain';
}
await _withTimeout(
_supabase.from('products').update({
'name': name,
'barcode': trimmed,
'price': price,
'price_reseller': priceReseller,
'stock': stock,
'min_stock': minStock,
'harga_pokok': hargaPokok,
'store_id': _currentStoreId,
'image_path': imagePath,
}).eq('id', id),
'Update product',
);
} catch (e) {
return e.toString();
}
try {
await _loadProducts();
} catch (_) {}
notifyListeners();
return null;
}
Future<String?> deleteProduct(String id) async {
try {
cart.removeWhere((c) => c.product.id == id);
await _withTimeout(
_supabase.from('products').delete().eq('id', id).eq('store_id', _currentStoreId),
'Delete product',
);
} catch (e) {
return e.toString();
}
try {
await _loadProducts();
} catch (_) {}
notifyListeners();
return null;
}
/* ---------- Cart ---------- */
void addToCart(Product product) {
if (product.stock <= 0) return;
final line = cart.firstWhere(
(c) => c.product.id == product.id,
orElse: () => CartLine(product: product),
);
if (cart.contains(line)) {
if (line.qty < product.stock) line.qty++;
} else {
line.isReseller = customerType == 'reseller';
cart.add(line);
}
notifyListeners();
}
void changeQty(String productId, int delta) {
final line = cart.firstWhere((c) => c.product.id == productId);
line.qty += delta;
if (line.qty <= 0) {
cart.remove(line);
} else if (line.qty > line.product.stock) {
line.qty = line.product.stock;
}
notifyListeners();
}
void toggleLineMode(String productId) {
final line = cart.firstWhere((c) => c.product.id == productId);
line.isReseller = !line.isReseller;
notifyListeners();
}
void setDiscount(double value) {
_discount = value < 0 ? 0 : value.clamp(0, subtotal);
notifyListeners();
}
void clearCart() {
cart.clear();
_discount = 0;
notifyListeners();
}
/* ---------- Checkout ---------- */
Future<Sale> checkout(double paid, {String paymentMethod = 'tunai', Map<String, double>? splitPayment, String paymentStatus = 'paid', double dueAmount = 0.0, String? customerPhone}) async {
final totalCost = cart.fold(0.0, (sum, c) => sum + (c.product.hargaPokok * c.qty));
final sale = Sale(
id: uid(),
date: DateTime.now(),
userId: userId,
items: cart
.map((c) => SaleItem(
productId: c.product.id,
name: c.product.name,
price: c.price,
qty: c.qty,
isReseller: c.isReseller,
))
.toList(),
subtotal: subtotal,
discount: _discount,
total: total,
paid: paid,
change: paymentStatus == 'paid' ? (paid - total).clamp(0, double.infinity) : 0,
totalCost: totalCost,
paymentMethod: paymentMethod,
paymentStatus: paymentStatus,
dueAmount: dueAmount,
customerPhone: customerPhone,
splitPayment: splitPayment,
);
try {
await _withTimeout(
_supabase.from('sales').insert(sale.toJson()..['store_id'] = _currentStoreId),
'Create sale',
);
for (final line in cart) {
if (line.product.id.startsWith('manual-')) continue;
try {
final current =
products.firstWhere((p) => p.id == line.product.id);
await _withTimeout(
_supabase
.from('products')
.update({'stock': current.stock - line.qty})
.eq('id', line.product.id)
.eq('store_id', _currentStoreId),
'Update stock',
);
} catch (_) {}
}
await _loadProducts();
await _loadSales();
cart.clear();
_discount = 0;
notifyListeners();
// Tandai ID transaksi lokal agar tidak di-notify lagi via polling
// (admin yang jualan sendiri tidak perlu notif transaksinya sendiri).
_notifiedSaleIds.add(sale.id);
return sale;
} catch (e) {
await _offlineQueue.addToQueue({
'sale': sale.toJson(),
'items': cart.map((c) {
final lineTotalCost = c.product.hargaPokok * c.qty;
return {
'product_id': c.product.id,
'qty': c.qty,
'price': c.price,
'total_cost': lineTotalCost,
};
}).toList(),
});
cart.clear();
_discount = 0;
notifyListeners();
rethrow;
}
}
/* ---------- Retur / Pembatalan ---------- */
Future<String?> cancelSale(String saleId) async {
try {
final sale = sales.firstWhere((s) => s.id == saleId);
await _withTimeout(
_supabase.from('sales').update({'is_cancelled': true}).eq('id', saleId).eq('store_id', _currentStoreId),
'Cancel sale',
);
for (final item in sale.items) {
final product = products.firstWhere(
(p) => p.id == item.productId,
orElse: () => Product(id: '', name: '', price: 0, stock: 0, hargaPokok: 0),
);
if (product.id.isNotEmpty) {
await _withTimeout(
_supabase
.from('products')
.update({'stock': product.stock + item.qty})
.eq('id', product.id)
.eq('store_id', _currentStoreId),
'Restore stock',
);
}
}
await _loadProducts();
await _loadSales();
notifyListeners();
return null;
} on PostgrestException catch (e) {
return e.message;
} catch (e) {
return e.toString();
}
}
Future<String?> recordPayment(String saleId, double amount) async {
try {
final sale = sales.firstWhere((s) => s.id == saleId);
final newPaid = sale.paid + amount;
final newDue = (sale.dueAmount - amount).clamp(0, double.infinity);
final newStatus = newDue <= 0 ? 'paid' : 'partial';
await _withTimeout(
_supabase.from('sales').update({
'paid': newPaid,
'due_amount': newDue,
'payment_status': newStatus,
'change': (newPaid - sale.total).clamp(0, double.infinity),
}).eq('id', saleId).eq('store_id', _currentStoreId),
'Record payment',
);
await _loadSales();
notifyListeners();
return null;
} on PostgrestException catch (e) {
return e.message;
} catch (e) {
return e.toString();
}
}
}