714 lines
24 KiB
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';
|
|
}
|
|
}
|
|
}
|