// lib/services/firefly_api_service.dart import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:intl/intl.dart'; import '../models/firefly_account.dart'; import '../models/report_transaction.dart'; class FireflyApiService { /// Mengambil daftar akun dari Firefly III API. /// /// [baseUrl] adalah URL dasar instance Firefly III (e.g., https://firefly.yourdomain.com). /// [accessToken] adalah Personal Access Token pengguna. /// [type] adalah filter opsional untuk tipe akun (e.g., 'asset', 'revenue'). static Future> fetchAccounts({ required String baseUrl, required String accessToken, String? type, }) async { final uri = type != null ? Uri.parse('$baseUrl/api/v1/accounts?type=$type') : Uri.parse('$baseUrl/api/v1/accounts'); final response = await http.get( uri, headers: { 'Authorization': 'Bearer $accessToken', 'Accept': 'application/json', }, ); if (response.statusCode == 200) { final dynamic responseBody = json.decode(response.body); if (responseBody is Map && responseBody.containsKey('data')) { final List accountsJson = responseBody['data'] as List; final List accounts = accountsJson .map((accountJson) => FireflyAccount.fromJson(accountJson)) .toList(); return accounts; } else { throw Exception('Format respons API tidak sesuai'); } } else { // Handle error - misalnya, lempar exception throw Exception('Gagal mengambil akun: ${response.statusCode}'); } } /// Mengambil daftar tag dari Firefly III API. static Future> fetchTags({ required String baseUrl, required String accessToken, }) async { final uri = Uri.parse('$baseUrl/api/v1/tags'); final response = await http.get( uri, headers: { 'Authorization': 'Bearer $accessToken', 'Accept': 'application/json', }, ); if (response.statusCode == 200) { final dynamic responseBody = json.decode(response.body); if (responseBody is Map && responseBody.containsKey('data')) { final List tagsJson = responseBody['data'] as List; final List tags = tagsJson .map((tagObj) { if (tagObj is Map && tagObj.containsKey('attributes')) { final attributes = tagObj['attributes'] as Map; return attributes['tag']?.toString(); } return null; }) .whereType() .where((tag) => tag.isNotEmpty) .toList(); return tags; } else { throw Exception('Format respons API tidak sesuai'); } } else { throw Exception('Gagal mengambil tag: ${response.statusCode}'); } } /// Menguji koneksi ke server Firefly III static Future testServerConnection({required String baseUrl}) async { try { final response = await http.get(Uri.parse(baseUrl)); return response.statusCode == 200; } catch (e) { print('Gagal menguji koneksi: $e'); return false; } } /// Mengirim transaksi dummy ke Firefly III API. /// /// [baseUrl] adalah URL dasar instance Firefly III. /// [accessToken] adalah Personal Access Token pengguna. /// [sourceId] dan [destinationId] adalah ID akun yang valid. /// [type] adalah tipe transaksi (default 'deposit'). static Future submitDummyTransaction({ required String baseUrl, required String accessToken, required String sourceId, required String destinationId, String type = 'deposit', String description = 'Pengeluaran Dummy via Flutter App', String date = '2025-08-19', // Gunakan format ISO 8601 String amount = '50.00', List? tags, }) async { final uri = Uri.parse('$baseUrl/api/v1/transactions'); final payload = jsonEncode({ "transactions": [ { "type": type, "date": date, "amount": amount, "description": description, "source_id": sourceId, "destination_id": destinationId, if (tags != null && tags.isNotEmpty) "tags": tags, } ] }); try { final response = await http.post( uri, headers: { 'Authorization': 'Bearer $accessToken', 'Accept': 'application/json', 'Content-Type': 'application/json', }, body: payload, ); if (response.statusCode == 200 || response.statusCode == 201) { // Parse response to get transaction ID try { final dynamic responseBody = json.decode(response.body); if (responseBody is Map && responseBody.containsKey('data')) { final dynamic data = responseBody['data']; // Handle case where data is a single object with an 'id' field if (data is Map && data.containsKey('id')) { final transactionId = data['id'].toString(); return transactionId; } // Handle case where data is a list with at least one object if (data is List && data.isNotEmpty) { final firstItem = data[0]; if (firstItem is Map) { if (firstItem.containsKey('id')) { final transactionId = firstItem['id'].toString(); return transactionId; } else if (firstItem.containsKey('transaction_id')) { final transactionId = firstItem['transaction_id'].toString(); return transactionId; } } } } } catch (e) { // Don't crash if parsing fails, just return success } // If we can't parse the ID, return a default success indicator return "success"; } else { return null; } } catch (e) { return null; } } /// Versi detail dari submitDummyTransaction yang juga mengembalikan /// transaction journal id (dibutuhkan untuk attachment). /// Return map: {'transactionId': ..., 'journalId': ...} atau null jika gagal. static Future?> createTransactionDetailed({ required String baseUrl, required String accessToken, required String sourceId, required String destinationId, String type = 'deposit', String description = 'Pengeluaran via Flutter App', String date = '2025-08-19', String amount = '50.00', List? tags, }) async { final uri = Uri.parse('$baseUrl/api/v1/transactions'); final payload = jsonEncode({ "transactions": [ { "type": type, "date": date, "amount": amount, "description": description, "source_id": sourceId, "destination_id": destinationId, if (tags != null && tags.isNotEmpty) "tags": tags, } ] }); try { final response = await http.post( uri, headers: { 'Authorization': 'Bearer $accessToken', 'Accept': 'application/json', 'Content-Type': 'application/json', }, body: payload, ); if (response.statusCode == 200 || response.statusCode == 201) { String? transactionId; String? journalId; try { final dynamic responseBody = json.decode(response.body); if (responseBody is Map && responseBody.containsKey('data')) { final dynamic data = responseBody['data']; Map? txObj; if (data is Map) { txObj = data; } else if (data is List && data.isNotEmpty) { txObj = data[0] as Map?; } if (txObj != null) { if (txObj.containsKey('id')) { transactionId = txObj['id'].toString(); } else if (txObj.containsKey('transaction_id')) { transactionId = txObj['transaction_id'].toString(); } // Coba ekstrak journal id dari attributes.transactions final attrs = txObj['attributes']; if (attrs is Map) { final txList = attrs['transactions']; if (txList is List && txList.isNotEmpty) { final first = txList[0]; if (first is Map) { final jid = first['transaction_journal_id']; if (jid != null) journalId = jid.toString(); } } } } } } catch (e) { // ignore } // Fallback: jika journalId tidak terparse, ambil via GET transaction if (transactionId != null && (journalId == null || journalId.isEmpty)) { try { final getResp = await http.get( Uri.parse('$baseUrl/api/v1/transactions/$transactionId'), headers: { 'Authorization': 'Bearer $accessToken', 'Accept': 'application/json', }, ); if (getResp.statusCode == 200) { final body = json.decode(getResp.body); if (body is Map && body.containsKey('data')) { final attrs = (body['data'] as Map?)?['attributes']; final txList = attrs is Map ? attrs['transactions'] : null; if (txList is List && txList.isNotEmpty) { final jid = (txList[0] as Map?)?['transaction_journal_id']; if (jid != null) journalId = jid.toString(); } } } } catch (_) {} } return { 'transactionId': transactionId ?? 'success', 'journalId': journalId ?? transactionId ?? '', }; } else { return null; } } catch (e) { return null; } } /// Kirim beberapa pecahan (split) sekaligus dalam SATU transaksi Firefly III. /// /// [splits] berisi map per pecahan dengan field Firefly III: /// `type`, `source_id`, `destination_id`, `amount`, `description`, `tags`. /// Untuk deposit split, `destination_id` harus sama di semua pecahan. /// /// Return map: `{'transactionId': ..., 'journalId': , /// 'journalIds': [...]}`. Melempar Exception berisi pesan dari Firefly III /// bila request ditolak. static Future?> createSplitTransaction({ required String baseUrl, required String accessToken, required List> splits, required String date, String? groupTitle, }) async { if (splits.isEmpty) return null; final uri = Uri.parse('$baseUrl/api/v1/transactions'); final payload = jsonEncode({ if (groupTitle != null && groupTitle.trim().isNotEmpty) "group_title": groupTitle.trim(), "transactions": splits .map((s) => { "type": s['type'] ?? 'deposit', "date": date, "amount": s['amount'], "description": s['description'], "source_id": s['source_id'], "destination_id": s['destination_id'], if (s['tags'] is List && (s['tags'] as List).isNotEmpty) "tags": s['tags'], }) .toList(), }); final response = await http.post( uri, headers: { 'Authorization': 'Bearer $accessToken', 'Accept': 'application/json', 'Content-Type': 'application/json', }, body: payload, ); if (response.statusCode == 200 || response.statusCode == 201) { String? transactionId; final journalIds = []; try { final dynamic responseBody = json.decode(response.body); if (responseBody is Map && responseBody.containsKey('data')) { final dynamic data = responseBody['data']; Map? txObj; if (data is Map) { txObj = data; } else if (data is List && data.isNotEmpty) { txObj = data[0] as Map?; } if (txObj != null) { if (txObj.containsKey('id')) { transactionId = txObj['id'].toString(); } else if (txObj.containsKey('transaction_id')) { transactionId = txObj['transaction_id'].toString(); } final attrs = txObj['attributes']; if (attrs is Map) { final txList = attrs['transactions']; if (txList is List) { for (final t in txList) { if (t is Map) { final jid = t['transaction_journal_id']; if (jid != null) journalIds.add(jid.toString()); } } } } } } } catch (_) {} // Fallback: ambil semua journal id via GET transaction. if (transactionId != null && journalIds.isEmpty) { try { final getResp = await http.get( Uri.parse('$baseUrl/api/v1/transactions/$transactionId'), headers: { 'Authorization': 'Bearer $accessToken', 'Accept': 'application/json', }, ); if (getResp.statusCode == 200) { final body = json.decode(getResp.body); if (body is Map && body.containsKey('data')) { final attrs = (body['data'] as Map?)?['attributes']; final txList = attrs is Map ? attrs['transactions'] : null; if (txList is List) { for (final t in txList) { final jid = (t as Map?)?['transaction_journal_id']; if (jid != null) journalIds.add(jid.toString()); } } } } } catch (_) {} } return { 'transactionId': transactionId ?? 'success', 'journalId': journalIds.isNotEmpty ? journalIds.first : (transactionId ?? 'success'), 'journalIds': journalIds, }; } // Ambil pesan error asli dari Firefly III agar bisa ditampilkan ke user. String message = 'HTTP ${response.statusCode}'; try { final body = json.decode(response.body); if (body is Map) { final msg = body['message']?.toString(); if (msg != null && msg.isNotEmpty) message = msg; final errors = body['errors']; if (errors != null) message = '$message ${jsonEncode(errors)}'; } } catch (_) { if (response.body.isNotEmpty) { message = '$message: ${response.body}'; } } debugPrint('Firefly split gagal: $message'); throw Exception(message); } /// Buat attachment record di Firefly III (tanpa file). /// Return attachment id atau null. static Future createAttachment({ required String baseUrl, required String accessToken, required String attachableType, // 'TransactionJournal' required String attachableId, String? title, String? notes, String? filename, }) async { final uri = Uri.parse('$baseUrl/api/v1/attachments'); final body = { 'attachable_type': attachableType, 'attachable_id': attachableId, if (title != null) 'title': title, if (notes != null) 'notes': notes, if (filename != null) 'filename': filename, }; try { final response = await http.post( uri, headers: { 'Authorization': 'Bearer $accessToken', 'Accept': 'application/json', 'Content-Type': 'application/json', }, body: jsonEncode(body), ); if (response.statusCode == 200 || response.statusCode == 201) { final decoded = json.decode(response.body); if (decoded is Map && decoded.containsKey('data')) { final id = (decoded['data'] as Map?)?['id']; if (id != null) return id.toString(); } } else { print('createAttachment failed: ${response.statusCode} ${response.body}'); } } catch (e) { print('createAttachment exception: $e'); } return null; } /// Upload file bytes ke attachment yang sudah dibuat. static Future uploadAttachment({ required String baseUrl, required String accessToken, required String attachmentId, required List fileBytes, String filename = 'invoice.png', String contentType = 'image/png', }) async { final uri = Uri.parse('$baseUrl/api/v1/attachments/$attachmentId/upload'); try { final response = await http.post( uri, headers: { 'Authorization': 'Bearer $accessToken', 'Accept': 'application/json', 'Content-Type': 'application/octet-stream', }, body: fileBytes, ); if (response.statusCode == 200 || response.statusCode == 204) { return true; } else { print('uploadAttachment failed: ${response.statusCode} ${response.body}'); return false; } } catch (e) { print('uploadAttachment exception: $e'); return false; } } /// Menguji koneksi ke Firefly III. static Future testConnection({required String baseUrl}) async { try { final response = await http.get(Uri.parse(baseUrl)); return response.statusCode == 200; } catch (e) { return false; } } /// Menguji autentikasi dengan token. static Future testAuthentication( {required String baseUrl, required String accessToken}) async { try { final response = await http.get( Uri.parse('$baseUrl/api/v1/about/user'), headers: { 'Authorization': 'Bearer $accessToken', 'Accept': 'application/json', }, ); return response.statusCode == 200; } catch (e) { return false; } } /// Mengambil daftar transaksi akun untuk periode tertentu (keperluan Laporan). static Future> fetchAccountTransactions({ required String baseUrl, required String accessToken, required String accountId, required DateTime startDate, required DateTime endDate, }) async { final startStr = DateFormat('yyyy-MM-dd').format(startDate); final endStr = DateFormat('yyyy-MM-dd').format(endDate); final uri = Uri.parse( '$baseUrl/api/v1/accounts/$accountId/transactions?start=$startStr&end=$endStr&type=all'); final response = await http.get( uri, headers: { 'Authorization': 'Bearer $accessToken', 'Accept': 'application/json', }, ); if (response.statusCode == 200) { final dynamic body = json.decode(response.body); if (body is Map && body.containsKey('data')) { final List dataList = body['data'] as List; final List result = []; for (final item in dataList) { if (item is Map && item.containsKey('attributes')) { final attrs = item['attributes'] as Map; final String groupId = (item['id'] ?? '').toString(); final txList = attrs['transactions']; if (txList is List) { for (final tx in txList) { if (tx is Map) { final String journalId = (tx['transaction_journal_id'] ?? '').toString(); final String txId = journalId.isNotEmpty ? journalId : groupId; final DateTime date = DateTime.tryParse(tx['date']?.toString() ?? '') ?? startDate; final String desc = tx['description']?.toString() ?? '-'; final double amt = double.tryParse(tx['amount']?.toString() ?? '0') ?? 0.0; final String sourceId = tx['source_id']?.toString() ?? ''; final String destId = tx['destination_id']?.toString() ?? ''; final String sourceName = tx['source_name']?.toString() ?? 'Kas/Sumber'; final String destName = tx['destination_name']?.toString() ?? 'Tujuan'; final String txType = tx['type']?.toString().toLowerCase() ?? ''; List tags = []; if (tx['tags'] is List) { tags = (tx['tags'] as List).map((e) => e.toString()).toList(); } String direction; String counterparty; if (sourceId == accountId) { direction = 'pengeluaran'; counterparty = destName; } else if (destId == accountId) { direction = 'pemasukan'; counterparty = sourceName; } else if (txType == 'withdrawal') { direction = 'pengeluaran'; counterparty = destName; } else if (txType == 'deposit') { direction = 'pemasukan'; counterparty = sourceName; } else { direction = 'pengeluaran'; counterparty = destName; } result.add(ReportTransaction( id: txId, date: date, description: desc, amount: amt.abs(), direction: direction, counterpartyName: counterparty, tags: tags, transactionGroupId: groupId, transactionJournalId: journalId.isNotEmpty ? journalId : null, )); } } } } } // Urutkan berdasarkan tanggal ascending result.sort((a, b) => a.date.compareTo(b.date)); return result; } return []; } else { throw Exception('Gagal mengambil transaksi: ${response.statusCode}'); } } /// Memperbarui transaksi di Firefly III melalui endpoint PUT /api/v1/transactions/{id}. /// Mengembalikan null jika sukses, atau String pesan error jika gagal. static Future updateTransaction({ required String baseUrl, required String accessToken, required String transactionGroupId, String? transactionJournalId, required DateTime date, required String description, required double amount, required List tags, }) async { try { final uri = Uri.parse('$baseUrl/api/v1/transactions/$transactionGroupId'); final txData = { if (transactionJournalId != null && transactionJournalId.isNotEmpty) 'transaction_journal_id': transactionJournalId, 'date': date.toIso8601String(), 'description': description, 'amount': amount.toStringAsFixed(2), 'tags': tags, }; final payload = jsonEncode({ 'apply_rules': false, 'fire_webhooks': false, 'transactions': [txData], }); final response = await http.put( uri, headers: { 'Authorization': 'Bearer $accessToken', 'Accept': 'application/json', 'Content-Type': 'application/json', }, body: payload, ); if (response.statusCode == 200 || response.statusCode == 204) { return null; } else { try { final errorBody = json.decode(response.body); if (errorBody is Map && errorBody.containsKey('message')) { return errorBody['message'].toString(); } } catch (_) {} return 'Gagal memperbarui transaksi di Firefly III (Kode HTTP ${response.statusCode})'; } } catch (e) { return 'Koneksi error: $e'; } } }