cashumit/lib/services/firefly_api_service.dart

714 lines
24 KiB
Dart

// 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<List<FireflyAccount>> 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<String, dynamic> &&
responseBody.containsKey('data')) {
final List accountsJson = responseBody['data'] as List;
final List<FireflyAccount> 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<List<String>> 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<String, dynamic> &&
responseBody.containsKey('data')) {
final List tagsJson = responseBody['data'] as List;
final List<String> tags = tagsJson
.map((tagObj) {
if (tagObj is Map<String, dynamic> &&
tagObj.containsKey('attributes')) {
final attributes = tagObj['attributes'] as Map<String, dynamic>;
return attributes['tag']?.toString();
}
return null;
})
.whereType<String>()
.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<bool> 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<String?> 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<String>? 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<String, dynamic> &&
responseBody.containsKey('data')) {
final dynamic data = responseBody['data'];
// Handle case where data is a single object with an 'id' field
if (data is Map<String, dynamic> && 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<String, dynamic>) {
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<Map<String, String>?> 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<String>? 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<String, dynamic> &&
responseBody.containsKey('data')) {
final dynamic data = responseBody['data'];
Map<String, dynamic>? txObj;
if (data is Map<String, dynamic>) {
txObj = data;
} else if (data is List && data.isNotEmpty) {
txObj = data[0] as Map<String, dynamic>?;
}
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<String, dynamic>) {
final txList = attrs['transactions'];
if (txList is List && txList.isNotEmpty) {
final first = txList[0];
if (first is Map<String, dynamic>) {
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<String, dynamic> && 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': <journal pertama>,
/// 'journalIds': [...]}`. Melempar Exception berisi pesan dari Firefly III
/// bila request ditolak.
static Future<Map<String, dynamic>?> createSplitTransaction({
required String baseUrl,
required String accessToken,
required List<Map<String, dynamic>> 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 = <String>[];
try {
final dynamic responseBody = json.decode(response.body);
if (responseBody is Map<String, dynamic> &&
responseBody.containsKey('data')) {
final dynamic data = responseBody['data'];
Map<String, dynamic>? txObj;
if (data is Map<String, dynamic>) {
txObj = data;
} else if (data is List && data.isNotEmpty) {
txObj = data[0] as Map<String, dynamic>?;
}
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<String, dynamic>) {
final txList = attrs['transactions'];
if (txList is List) {
for (final t in txList) {
if (t is Map<String, dynamic>) {
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<String, dynamic> && 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<String, dynamic>) {
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<String?> 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 = <String, dynamic>{
'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<String, dynamic> && 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<bool> uploadAttachment({
required String baseUrl,
required String accessToken,
required String attachmentId,
required List<int> 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<bool> 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<bool> 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<List<ReportTransaction>> 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<String, dynamic> && body.containsKey('data')) {
final List dataList = body['data'] as List;
final List<ReportTransaction> result = [];
for (final item in dataList) {
if (item is Map<String, dynamic> && item.containsKey('attributes')) {
final attrs = item['attributes'] as Map<String, dynamic>;
final String groupId = (item['id'] ?? '').toString();
final txList = attrs['transactions'];
if (txList is List) {
for (final tx in txList) {
if (tx is Map<String, dynamic>) {
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<String> 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<String?> updateTransaction({
required String baseUrl,
required String accessToken,
required String transactionGroupId,
String? transactionJournalId,
required DateTime date,
required String description,
required double amount,
required List<String> tags,
}) async {
try {
final uri = Uri.parse('$baseUrl/api/v1/transactions/$transactionGroupId');
final txData = <String, dynamic>{
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<String, dynamic> && 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';
}
}
}