// 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/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 id = await FireflyApiService.submitDummyTransaction( 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 (id == null) { return result.copyWith( status: SubmissionStatus.failed, error: 'Server mengembalikan respons null', ); } // 'success' berarti terkirim tapi ID tidak diparse; anggap sukses tanpa ID return result.copyWith( status: SubmissionStatus.success, transactionId: id == 'success' ? null : id, ); } 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 grup (yang punya item) + satu task tip jika ada. static List buildTasksFromReceipt({ required List groups, required String destinationAccountId, required String destinationAccountName, required DateTime transactionDate, String? tipSourceAccountId, String? tipSourceAccountName, double? tipAmount, List? tags, }) { final tasks = []; for (final g in groups.where((g) => g.items.isNotEmpty)) { tasks.add(SubmissionTask( taskId: g.id, group: g, destinationAccountId: destinationAccountId, destinationAccountName: destinationAccountName, transactionDate: transactionDate, tags: g.tags.isNotEmpty ? g.tags : tags, )); } if (tipSourceAccountId != null && tipAmount != null && tipAmount > 0) { tasks.add(SubmissionTask( taskId: 'tip', tipAmount: tipAmount, tipSourceAccountId: tipSourceAccountId, tipSourceAccountName: tipSourceAccountName, destinationAccountId: destinationAccountId, destinationAccountName: destinationAccountName, transactionDate: transactionDate, tags: tags, )); } return tasks; } /// Bangun daftar [SubmissionTask] dari semua local receipt yang belum /// tersubmit. Tiap receipt menghasilkan task per-grup + tip jika ada. static List buildTasksFromAllUnsubmittedReceipts( List receipts) { final tasks = []; for (final receipt in receipts.where((r) => !r.isSubmitted)) { if (receipt.destinationAccountId == null) continue; for (final g in receipt.groups.where((g) => g.items.isNotEmpty)) { tasks.add(SubmissionTask( taskId: '${receipt.id}_${g.id}', group: g, destinationAccountId: receipt.destinationAccountId!, destinationAccountName: receipt.destinationAccountName ?? '', transactionDate: receipt.transactionDate, tags: g.tags.isNotEmpty ? g.tags : receipt.tags, )); } if (receipt.hasTip) { tasks.add(SubmissionTask( taskId: '${receipt.id}_tip', tipAmount: receipt.tipAmount, tipSourceAccountId: receipt.tipSourceAccountId!, tipSourceAccountName: receipt.tipSourceAccountName, destinationAccountId: receipt.destinationAccountId!, destinationAccountName: receipt.destinationAccountName ?? '', transactionDate: receipt.transactionDate, tags: receipt.tags, )); } } return tasks; } }