// lib/services/receipt_service.dart import 'package:shared_preferences/shared_preferences.dart'; import 'package:cashumit/models/firefly_account.dart'; import 'package:cashumit/models/receipt_item.dart'; import 'package:cashumit/models/receipt_group.dart'; import 'package:cashumit/models/local_receipt.dart'; import 'package:cashumit/models/submission_task.dart'; import 'package:cashumit/models/group_submission_result.dart'; import 'package:cashumit/services/firefly_api_service.dart'; import 'package:cashumit/services/account_mirror_service.dart'; import 'package:cashumit/services/esc_pos_print_service.dart'; import 'package:cashumit/services/invoice_image_service.dart'; import 'package:cashumit/services/tag_cache_service.dart'; class ReceiptService { /// Memuat kredensial dari shared preferences. /// /// Mengembalikan Map dengan key 'url' dan 'token' jika kredensial ada dan valid, /// atau null jika tidak ditemukan atau tidak valid. static Future?> loadCredentials() async { final prefs = await SharedPreferences.getInstance(); final url = prefs.getString('firefly_url'); final token = prefs.getString('firefly_token'); if (url == null || token == null || url.isEmpty || token.isEmpty) { return null; } return {'url': url, 'token': token}; } /// Memuat daftar akun sumber (revenue) dan tujuan (asset) dari API. /// /// Mengembalikan daftar akun yang berisi akun revenue dan asset. /// Melempar exception jika terjadi kesalahan saat memuat akun. static Future>> loadAccounts({ required String baseUrl, required String accessToken, }) async { // Mengambil akun revenue final revenueAccounts = await FireflyApiService.fetchAccounts( baseUrl: baseUrl, accessToken: accessToken, type: 'revenue', ); // Mengambil akun asset final assetAccounts = await FireflyApiService.fetchAccounts( baseUrl: baseUrl, accessToken: accessToken, type: 'asset', ); // Menggabungkan akun revenue dan asset untuk dropdown final allAccounts = >[]; for (var account in revenueAccounts) { allAccounts.add({ 'id': account.id, 'name': account.name, 'type': account.type, }); } for (var account in assetAccounts) { allAccounts.add({ 'id': account.id, 'name': account.name, 'type': account.type, }); } return allAccounts; } /// Mencari ID akun berdasarkan nama dan tipe akun. static String? findAccountIdByName({ required String name, required String expectedType, required List> accounts, }) { if (name.isEmpty) return null; try { // Cari akun dengan nama yang cocok dan tipe yang diharapkan final account = accounts.firstWhere( (account) => account['name'].toString().toLowerCase() == name.toLowerCase() && account['type'] == expectedType, ); return account['id'] as String?; } catch (e) { // Jika tidak ditemukan, coba pencarian yang lebih fleksibel for (var account in accounts) { if (account['type'] == expectedType && account['name'] .toString() .toLowerCase() .contains(name.toLowerCase())) { return account['id'] as String?; } } // Jika masih tidak ditemukan, kembalikan null return null; } } /// Generates a transaction description based on item names static String generateTransactionDescription(List items) { if (items.isEmpty) { return 'Transaksi Struk Belanja'; } // Take the first 5 item descriptions final itemNames = items.take(5).map((item) => item.description).toList(); // If there are more than 5 items, append ', dll' to the last item if (items.length > 5) { itemNames[4] += ', dll'; } // Join the item names with ', ' return itemNames.join(', '); } /// Format tanggal untuk API Firefly III (ISO 8601 dengan jam) static String _formatFireflyDate(DateTime date) { String two(int v) => v.toString().padLeft(2, '0'); return '${date.year}-${two(date.month)}-${two(date.day)}T${two(date.hour)}:${two(date.minute)}:${two(date.second)}'; } /// Mengirim transaksi ke Firefly III. /// /// Setiap grup dengan item menjadi satu transaksi `deposit` terpisah /// (sumber grup -> tujuan bersama). Jika tip aktif (tipSourceAccountId /// tidak null dan paymentAmount > total), satu transaksi tip tambahan /// dibuat (sumber = tipSource, tujuan = tujuan bersama, amount = kembalian). /// /// Mengembalikan list ID transaksi (per grup + tip jika ada). /// Melempar exception jika ada error validasi. static Future> submitTransaction({ required List groups, required DateTime transactionDate, required String destinationAccountId, required List> accounts, required String baseUrl, required String accessToken, double paymentAmount = 0.0, String? tipSourceAccountId, double totalAmount = 0.0, List? tags, }) async { final groupsWithItems = groups.where((g) => g.items.isNotEmpty).toList(); if (groupsWithItems.isEmpty) { throw Exception('Tidak ada item untuk dikirim'); } // Validasi akun tujuan ada di daftar final destinationExists = accounts.any((a) => a['id'].toString() == destinationAccountId); if (!destinationExists) { throw Exception( 'Akun tujuan tidak ditemukan di daftar akun yang dimuat. Klik "Muat Ulang Akun" dan coba lagi.'); } final fireflyDate = _formatFireflyDate(transactionDate); final transactionIds = []; // Submit satu transaksi per grup for (final group in groupsWithItems) { if (!group.hasSource) { throw Exception('Setiap grup dengan item harus memiliki akun sumber'); } if (group.sourceAccountId == destinationAccountId) { throw Exception( 'Akun sumber grup "${group.sourceAccountName}" tidak boleh sama dengan akun tujuan'); } final groupTotal = group.total; final description = generateTransactionDescription(group.items); final id = await FireflyApiService.submitDummyTransaction( baseUrl: baseUrl, accessToken: accessToken, sourceId: group.sourceAccountId!, destinationId: destinationAccountId, type: 'deposit', description: description, date: fireflyDate, amount: groupTotal.toStringAsFixed(2), tags: group.tags.isNotEmpty ? group.tags : tags, ); if (id == null) { throw Exception( 'Gagal mengirim grup "${group.sourceAccountName}" ke Firefly III'); } if (id != 'success') { transactionIds.add(id); } } // Submit transaksi tip jika aktif final hasTip = tipSourceAccountId != null && paymentAmount > totalAmount; if (hasTip) { final tipAmount = paymentAmount - totalAmount; final tipSourceExists = accounts.any((a) => a['id'].toString() == tipSourceAccountId); if (!tipSourceExists) { throw Exception( 'Akun sumber tip tidak ditemukan di daftar akun yang dimuat.'); } if (tipSourceAccountId == destinationAccountId) { throw Exception('Akun sumber tip tidak boleh sama dengan akun tujuan'); } final tipId = await FireflyApiService.submitDummyTransaction( baseUrl: baseUrl, accessToken: accessToken, sourceId: tipSourceAccountId, destinationId: destinationAccountId, type: 'deposit', description: 'Tip dari kembalian', date: fireflyDate, amount: tipAmount.toStringAsFixed(2), tags: tags, ); if (tipId == null) { throw Exception('Gagal mengirim transaksi tip ke Firefly III'); } if (tipId != 'success') { transactionIds.add(tipId); } } return transactionIds; } /// Mengirim satu grup transaksi ke Firefly III tanpa melempar exception. /// Mengembalikan [GroupSubmissionResult] dengan status sukses/gagal. /// /// Dipakai oleh popup daftar transaksi untuk pengiriman per-item dengan /// retry. static Future submitSingleGroup({ required ReceiptGroup group, required String destinationAccountId, required DateTime transactionDate, required String baseUrl, required String accessToken, List? tags, }) async { final result = GroupSubmissionResult( groupId: group.id, label: group.sourceAccountName ?? 'Grup', amount: group.total, isTip: false, status: SubmissionStatus.pending, ); if (group.items.isEmpty) { return result.copyWith( status: SubmissionStatus.failed, error: 'Tidak ada item', ); } if (!group.hasSource) { return result.copyWith( status: SubmissionStatus.failed, error: 'Akun sumber belum dipilih', ); } if (group.sourceAccountId == destinationAccountId) { return result.copyWith( status: SubmissionStatus.failed, error: 'Akun sumber sama dengan tujuan', ); } try { final description = generateTransactionDescription(group.items); final idResult = await FireflyApiService.createTransactionDetailed( baseUrl: baseUrl, accessToken: accessToken, sourceId: group.sourceAccountId!, destinationId: destinationAccountId, type: 'deposit', description: description, date: _formatFireflyDate(transactionDate), amount: group.total.toStringAsFixed(2), tags: group.tags.isNotEmpty ? group.tags : tags, ); if (idResult == null) { return result.copyWith( status: SubmissionStatus.failed, error: 'Server mengembalikan respons null', ); } final txId = idResult['transactionId']; final jId = idResult['journalId']; // 'success' berarti terkirim tapi ID tidak diparse; anggap sukses tanpa ID return result.copyWith( status: SubmissionStatus.success, transactionId: (txId == 'success' || txId == null) ? null : txId, journalId: (jId == null || jId.isEmpty || jId == 'success') ? null : jId, ); } catch (e) { return result.copyWith( status: SubmissionStatus.failed, error: e.toString(), ); } } /// Mengirim transaksi tip ke Firefly III tanpa melempar exception. static Future submitTip({ required String tipSourceAccountId, required String tipSourceAccountName, required double tipAmount, required String destinationAccountId, required DateTime transactionDate, required String baseUrl, required String accessToken, List? tags, }) async { final result = GroupSubmissionResult( groupId: 'tip_${DateTime.now().millisecondsSinceEpoch}', label: 'Tip ($tipSourceAccountName)', amount: tipAmount, isTip: true, status: SubmissionStatus.pending, ); if (tipSourceAccountId == destinationAccountId) { return result.copyWith( status: SubmissionStatus.failed, error: 'Akun sumber tip sama dengan tujuan', ); } try { final id = await FireflyApiService.submitDummyTransaction( baseUrl: baseUrl, accessToken: accessToken, sourceId: tipSourceAccountId, destinationId: destinationAccountId, type: 'deposit', description: 'Tip dari kembalian', date: _formatFireflyDate(transactionDate), amount: tipAmount.toStringAsFixed(2), tags: tags, ); if (id == null) { return result.copyWith( status: SubmissionStatus.failed, error: 'Server mengembalikan respons null', ); } return result.copyWith( status: SubmissionStatus.success, transactionId: id == 'success' ? null : id, ); } catch (e) { return result.copyWith( status: SubmissionStatus.failed, error: e.toString(), ); } } /// Fungsi untuk menyimpan akun ke mirror static Future saveAccountsToMirror( List> accounts) async { final fireflyAccounts = accounts .map((map) => FireflyAccount( id: map['id'].toString(), name: map['name'].toString(), type: map['type'].toString(), )) .toList(); await AccountMirrorService.mirrorAccounts(fireflyAccounts); } /// Fungsi untuk mengambil akun dari mirror static Future>> getMirroredAccounts() async { final accounts = await AccountMirrorService.getMirroredAccounts(); return accounts .map((account) => { 'id': account.id, 'name': account.name, 'type': account.type, }) .toList(); } /// Fungsi untuk mendapatkan akun dengan tipe tertentu dari mirror static Future>> getMirroredAccountsWithType( String type) async { final accounts = await AccountMirrorService.getMirroredAccountsByType(type); return accounts .map((account) => { 'id': account.id, 'name': account.name, 'type': account.type, }) .toList(); } /// Fungsi untuk memperbarui mirror akun dari server static Future updateAccountMirror({ required String baseUrl, required String accessToken, List? tags, }) async { try { final accounts = await loadAccounts(baseUrl: baseUrl, accessToken: accessToken); final fireflyAccounts = accounts .map((map) => FireflyAccount( id: map['id'].toString(), name: map['name'].toString(), type: map['type'].toString(), )) .toList(); await AccountMirrorService.mirrorAccounts(fireflyAccounts); } catch (e) { print('Gagal memperbarui mirror akun: $e'); } } // Fungsi-fungsi untuk default account mapping telah dihapus sesuai permintaan /// Fungsi untuk memuat akun dengan fallback mekanisme yang lebih lengkap static Future>> loadAccountsWithFallback({ required String baseUrl, required String accessToken, }) async { try { // Coba ambil dari server terlebih dahulu final accounts = await loadAccounts( baseUrl: baseUrl, accessToken: accessToken, ); // Simpan ke mirror jika berhasil await updateAccountMirror(baseUrl: baseUrl, accessToken: accessToken); return accounts; } catch (serverError) { print('Gagal memuat akun dari server: $serverError'); // Jika gagal dari server, coba dari mirror try { final mirroredAccounts = await getMirroredAccounts(); if (mirroredAccounts.isNotEmpty) { return mirroredAccounts; } } catch (mirrorError) { print('Gagal memuat dari mirror: $mirrorError'); } // Jika semua fallback gagal, kembalikan list kosong return []; } } /// Fungsi untuk memuat tag dari Firefly III dengan caching fallback static Future> loadTagsWithFallback({ required String baseUrl, required String accessToken, }) async { return TagCacheService.getTagsWithFallback(() async { return await FireflyApiService.fetchTags( baseUrl: baseUrl, accessToken: accessToken, ); }); } // ============ TASK BUILDER UNTUK SUBMISSION DIALOG ============ /// Bangun daftar [SubmissionTask] dari struk saat ini (sebelum kirim). /// /// Satu task per struk: semua grup berisi item dikirim sebagai pecahan /// (split) dari satu transaksi, ditambah satu pecahan tip bila aktif. static List buildTasksFromReceipt({ required List groups, required String destinationAccountId, required String destinationAccountName, required DateTime transactionDate, String? tipSourceAccountId, String? tipSourceAccountName, double? tipAmount, List? tags, String? customerName, String? invoiceNumber, }) { final groupsWithItems = groups.where((g) => g.items.isNotEmpty).toList(); if (groupsWithItems.isEmpty) return []; return [ SubmissionTask( taskId: 'receipt_${DateTime.now().millisecondsSinceEpoch}', groups: groupsWithItems, tipAmount: tipAmount, tipSourceAccountId: tipSourceAccountId, tipSourceAccountName: tipSourceAccountName, destinationAccountId: destinationAccountId, destinationAccountName: destinationAccountName, transactionDate: transactionDate, tags: tags, customerName: customerName, invoiceNumber: invoiceNumber, ), ]; } /// Bangun daftar [SubmissionTask] dari semua local receipt yang belum /// tersubmit. Tiap receipt menjadi satu task (satu transaksi split). static List buildTasksFromAllUnsubmittedReceipts( List receipts) { final tasks = []; for (final receipt in receipts.where((r) => !r.isSubmitted)) { if (receipt.destinationAccountId == null) continue; final groupsWithItems = receipt.groups.where((g) => g.items.isNotEmpty).toList(); if (groupsWithItems.isEmpty) continue; tasks.add(SubmissionTask( taskId: receipt.id, groups: groupsWithItems, tipAmount: receipt.hasTip ? receipt.tipAmount : null, tipSourceAccountId: receipt.hasTip ? receipt.tipSourceAccountId : null, tipSourceAccountName: receipt.hasTip ? receipt.tipSourceAccountName : null, destinationAccountId: receipt.destinationAccountId!, destinationAccountName: receipt.destinationAccountName ?? '', transactionDate: receipt.transactionDate, tags: receipt.tags, customerName: receipt.customerName, )); } return tasks; } /// Kirim satu struk ke Firefly III sebagai satu transaksi split /// (satu pecahan per grup + satu pecahan tip jika aktif). /// /// Mengembalikan [GroupSubmissionResult] dengan status sukses/gagal. /// Transaksi bersifat atomic: berhasil semua atau gagal semua. static Future submitReceiptSplit({ required SubmissionTask task, required String baseUrl, required String accessToken, }) async { final result = GroupSubmissionResult( groupId: task.taskId, label: task.label, amount: task.amount, status: SubmissionStatus.pending, ); final groups = task.groups.where((g) => g.items.isNotEmpty).toList(); if (groups.isEmpty) { return result.copyWith( status: SubmissionStatus.failed, error: 'Tidak ada item', ); } for (final group in groups) { if (!group.hasSource) { return result.copyWith( status: SubmissionStatus.failed, error: 'Akun sumber grup "${group.sourceAccountName ?? ''}" belum dipilih', ); } if (group.sourceAccountId == task.destinationAccountId) { return result.copyWith( status: SubmissionStatus.failed, error: 'Akun sumber grup "${group.sourceAccountName ?? ''}" sama dengan tujuan', ); } if (group.total <= 0) { return result.copyWith( status: SubmissionStatus.failed, error: 'Total grup "${group.sourceAccountName ?? ''}" harus lebih dari 0', ); } } if (task.hasTip && task.tipSourceAccountId == task.destinationAccountId) { return result.copyWith( status: SubmissionStatus.failed, error: 'Akun sumber tip sama dengan tujuan', ); } final splits = >[]; for (final group in groups) { splits.add({ 'type': 'deposit', 'source_id': group.sourceAccountId, 'destination_id': task.destinationAccountId, 'amount': group.total.toStringAsFixed(2), 'description': generateTransactionDescription(group.items), 'tags': group.tags.isNotEmpty ? group.tags : task.tags, }); } if (task.hasTip) { splits.add({ 'type': 'deposit', 'source_id': task.tipSourceAccountId, 'destination_id': task.destinationAccountId, 'amount': task.tipAmount!.toStringAsFixed(2), 'description': 'Tip dari kembalian', 'tags': task.tags, }); } // Judul transaksi (group_title): nama pembeli saja. final customerName = task.customerName?.trim() ?? ''; final groupTitle = customerName.isNotEmpty ? customerName : (groups.first.items.isNotEmpty ? groups.first.items.first.description : 'Transaksi Struk Belanja'); try { final idResult = await FireflyApiService.createSplitTransaction( baseUrl: baseUrl, accessToken: accessToken, splits: splits, date: _formatFireflyDate(task.transactionDate), groupTitle: groupTitle, ); if (idResult == null) { return result.copyWith( status: SubmissionStatus.failed, error: 'Server mengembalikan respons null', ); } final txId = idResult['transactionId']?.toString(); final jId = idResult['journalId']?.toString(); return result.copyWith( status: SubmissionStatus.success, transactionId: (txId == null || txId == 'success') ? null : txId, journalId: (jId == null || jId.isEmpty || jId == 'success') ? null : jId, ); } catch (e) { return result.copyWith( status: SubmissionStatus.failed, error: e.toString(), ); } } /// Generate invoice gabungan (semua grup + tip) dan lampirkan sekali ke /// transaksi Firefly III (journal pecahan pertama). static Future attachInvoiceToReceipt({ required String baseUrl, required String accessToken, required String transactionId, required String? journalId, required List groups, required DateTime transactionDate, required String destinationAccountName, double tipAmount = 0.0, String? customerName, String? invoiceNumber, }) async { try { final store = await StoreConfig.fromSharedPreferences(); final items = groups.expand((g) => g.items).toList(); final resolvedCustomer = (customerName != null && customerName.trim().isNotEmpty) ? customerName.trim() : (groups.isNotEmpty ? (groups.first.sourceAccountName ?? destinationAccountName) : destinationAccountName); final bundle = await InvoiceImageService.prepare( storeName: store.name, storeAddress: store.address, adminName: store.adminName, adminPhone: store.adminPhone, logoPath: store.logoPath, qrisPath: store.qrisPath, disclaimer: store.disclaimer, thankYouText: store.thankYouText, pantunText: store.pantunText, customerName: resolvedCustomer, date: transactionDate, items: items, tipAmount: tipAmount, invoiceNumber: invoiceNumber, ); final result = await InvoiceImageService.generate(data: bundle.data); final attachableId = (journalId != null && journalId.isNotEmpty) ? journalId : transactionId; final attachmentId = await FireflyApiService.createAttachment( baseUrl: baseUrl, accessToken: accessToken, attachableType: 'TransactionJournal', attachableId: attachableId, title: 'Invoice ${bundle.invoiceNumber}', filename: 'invoice_${bundle.invoiceNumber}.png', ); if (attachmentId == null) return null; final uploaded = await FireflyApiService.uploadAttachment( baseUrl: baseUrl, accessToken: accessToken, attachmentId: attachmentId, fileBytes: result.bytes, filename: 'invoice_${bundle.invoiceNumber}.png', ); if (!uploaded) return null; return result.localPath; } catch (e) { print('attachInvoiceToReceipt error: $e'); return null; } } /// Generate invoice image dan lampirkan ke transaksi Firefly III. static Future attachInvoiceToTransaction({ required String baseUrl, required String accessToken, required String transactionId, required String? journalId, required ReceiptGroup group, required DateTime transactionDate, required String destinationAccountName, String? customerName, }) async { try { final store = await StoreConfig.fromSharedPreferences(); final bundle = await InvoiceImageService.prepare( storeName: store.name, storeAddress: store.address, adminName: store.adminName, adminPhone: store.adminPhone, logoPath: store.logoPath, qrisPath: store.qrisPath, disclaimer: store.disclaimer, thankYouText: store.thankYouText, pantunText: store.pantunText, customerName: customerName ?? (group.sourceAccountName ?? destinationAccountName), date: transactionDate, items: group.items, ); final result = await InvoiceImageService.generate(data: bundle.data); final attachableId = (journalId != null && journalId.isNotEmpty) ? journalId : transactionId; final attachmentId = await FireflyApiService.createAttachment( baseUrl: baseUrl, accessToken: accessToken, attachableType: 'TransactionJournal', attachableId: attachableId, title: 'Invoice ${bundle.invoiceNumber}', filename: 'invoice_${bundle.invoiceNumber}.png', ); if (attachmentId == null) return null; final uploaded = await FireflyApiService.uploadAttachment( baseUrl: baseUrl, accessToken: accessToken, attachmentId: attachmentId, fileBytes: result.bytes, filename: 'invoice_${bundle.invoiceNumber}.png', ); if (!uploaded) return null; return result.localPath; } catch (e) { print('attachInvoiceToTransaction error: $e'); return null; } } }