cashumit/lib/services/receipt_service.dart

525 lines
17 KiB
Dart

// 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';
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<Map<String, String>?> 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<List<Map<String, dynamic>>> 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 = <Map<String, dynamic>>[];
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<Map<String, dynamic>> 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<ReceiptItem> 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<List<String>> submitTransaction({
required List<ReceiptGroup> groups,
required DateTime transactionDate,
required String destinationAccountId,
required List<Map<String, dynamic>> accounts,
required String baseUrl,
required String accessToken,
double paymentAmount = 0.0,
String? tipSourceAccountId,
double totalAmount = 0.0,
}) 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 = <String>[];
// 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),
);
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),
);
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<GroupSubmissionResult> submitSingleGroup({
required ReceiptGroup group,
required String destinationAccountId,
required DateTime transactionDate,
required String baseUrl,
required String accessToken,
}) 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),
);
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<GroupSubmissionResult> submitTip({
required String tipSourceAccountId,
required String tipSourceAccountName,
required double tipAmount,
required String destinationAccountId,
required DateTime transactionDate,
required String baseUrl,
required String accessToken,
}) 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),
);
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<void> saveAccountsToMirror(
List<Map<String, dynamic>> 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<List<Map<String, dynamic>>> 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<List<Map<String, dynamic>>> 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<void> updateAccountMirror({
required String baseUrl,
required String accessToken,
}) 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<List<Map<String, dynamic>>> 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 [];
}
}
// ============ 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<SubmissionTask> buildTasksFromReceipt({
required List<ReceiptGroup> groups,
required String destinationAccountId,
required String destinationAccountName,
required DateTime transactionDate,
String? tipSourceAccountId,
String? tipSourceAccountName,
double? tipAmount,
}) {
final tasks = <SubmissionTask>[];
for (final g in groups.where((g) => g.items.isNotEmpty)) {
tasks.add(SubmissionTask(
taskId: g.id,
group: g,
destinationAccountId: destinationAccountId,
destinationAccountName: destinationAccountName,
transactionDate: transactionDate,
));
}
if (tipSourceAccountId != null && tipAmount != null && tipAmount > 0) {
tasks.add(SubmissionTask(
taskId: 'tip',
tipAmount: tipAmount,
tipSourceAccountId: tipSourceAccountId,
tipSourceAccountName: tipSourceAccountName,
destinationAccountId: destinationAccountId,
destinationAccountName: destinationAccountName,
transactionDate: transactionDate,
));
}
return tasks;
}
/// Bangun daftar [SubmissionTask] dari semua local receipt yang belum
/// tersubmit. Tiap receipt menghasilkan task per-grup + tip jika ada.
static List<SubmissionTask> buildTasksFromAllUnsubmittedReceipts(
List<LocalReceipt> receipts) {
final tasks = <SubmissionTask>[];
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,
));
}
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,
));
}
}
return tasks;
}
}