Files
sure/mobile/lib/services/offline_storage_service.dart
ghost f7be206c55 fix(mobile): redact sensitive diagnostic logs (#2199)
* fix(mobile): redact sensitive diagnostic logs

* fix(mobile): tighten diagnostic log redaction

* fix(mobile): harden sanitized diagnostics

* fix(mobile): clarify offline fallback diagnostics

* fix(mobile): refine diagnostic log redaction

* fix(mobile): tighten diagnostic log redaction

* fix(mobile): harden auth diagnostic failures
2026-06-06 16:47:30 +02:00

356 lines
11 KiB
Dart

import 'package:uuid/uuid.dart';
import '../models/offline_transaction.dart';
import '../models/transaction.dart';
import '../models/account.dart';
import 'database_helper.dart';
import 'log_service.dart';
class OfflineStorageService {
final DatabaseHelper _dbHelper = DatabaseHelper.instance;
final Uuid _uuid = const Uuid();
final LogService _log = LogService.instance;
// Transaction operations
Future<OfflineTransaction> saveTransaction({
required String accountId,
required String name,
required String date,
required String amount,
required String currency,
required String nature,
String? notes,
String? categoryId,
String? categoryName,
String? merchantId,
String? merchantName,
List<String> tagIds = const [],
List<String> tagNames = const [],
String? serverId,
SyncStatus syncStatus = SyncStatus.pending,
}) async {
final localId = _uuid.v4();
_log.info(
'OfflineStorage',
'saveTransaction called with syncStatus=$syncStatus',
);
final transaction = OfflineTransaction(
id: serverId,
localId: localId,
accountId: accountId,
name: name,
date: date,
amount: amount,
currency: currency,
nature: nature,
notes: notes,
categoryId: categoryId,
categoryName: categoryName,
merchantId: merchantId,
merchantName: merchantName,
tagIds: tagIds,
tagNames: tagNames,
syncStatus: syncStatus,
);
try {
await _dbHelper.insertTransaction(transaction.toDatabaseMap());
_log.info('OfflineStorage', 'Transaction saved successfully');
return transaction;
} catch (e) {
_log.error('OfflineStorage', 'Failed to save transaction: $e');
rethrow;
}
}
Future<List<OfflineTransaction>> getTransactions({String? accountId}) async {
_log.debug(
'OfflineStorage',
'getTransactions called${accountId != null ? " with account filter" : ""}',
);
final transactionMaps =
await _dbHelper.getTransactions(accountId: accountId);
_log.debug('OfflineStorage',
'Retrieved ${transactionMaps.length} transaction maps from database');
final transactions = transactionMaps
.map((map) => OfflineTransaction.fromDatabaseMap(map))
.toList();
_log.debug(
'OfflineStorage', 'Returning ${transactions.length} transactions');
return transactions;
}
Future<OfflineTransaction?> getTransactionByLocalId(String localId) async {
final map = await _dbHelper.getTransactionByLocalId(localId);
return map != null ? OfflineTransaction.fromDatabaseMap(map) : null;
}
Future<OfflineTransaction?> getTransactionByServerId(String serverId) async {
final map = await _dbHelper.getTransactionByServerId(serverId);
return map != null ? OfflineTransaction.fromDatabaseMap(map) : null;
}
Future<List<OfflineTransaction>> getPendingTransactions() async {
final transactionMaps = await _dbHelper.getPendingTransactions();
return transactionMaps
.map((map) => OfflineTransaction.fromDatabaseMap(map))
.toList();
}
Future<List<OfflineTransaction>> getPendingDeletes() async {
final transactionMaps = await _dbHelper.getPendingDeletes();
return transactionMaps
.map((map) => OfflineTransaction.fromDatabaseMap(map))
.toList();
}
Future<void> updateTransactionSyncStatus({
required String localId,
required SyncStatus syncStatus,
String? serverId,
}) async {
final existing = await getTransactionByLocalId(localId);
if (existing == null) return;
final updated = existing.copyWith(
syncStatus: syncStatus,
id: serverId ?? existing.id,
updatedAt: DateTime.now(),
);
await _dbHelper.updateTransaction(localId, updated.toDatabaseMap());
}
Future<void> deleteTransaction(String localId) async {
await _dbHelper.deleteTransaction(localId);
}
Future<void> deleteTransactionByServerId(String serverId) async {
await _dbHelper.deleteTransactionByServerId(serverId);
}
/// Mark a transaction for pending deletion (offline delete)
Future<void> markTransactionForDeletion(String serverId) async {
_log.info('OfflineStorage', 'Marking transaction for pending deletion');
// Find the transaction by server ID
final existing = await getTransactionByServerId(serverId);
if (existing == null) {
_log.warning(
'OfflineStorage',
'Transaction not found, cannot mark for deletion',
);
return;
}
// Update its sync status to pendingDelete
final updated = existing.copyWith(
syncStatus: SyncStatus.pendingDelete,
updatedAt: DateTime.now(),
);
await _dbHelper.updateTransaction(
existing.localId, updated.toDatabaseMap());
_log.info('OfflineStorage', 'Transaction marked as pending_delete');
}
/// Undo a pending transaction operation (either pending create or pending delete)
Future<bool> undoPendingTransaction(
String localId, SyncStatus currentStatus) async {
_log.info(
'OfflineStorage',
'Undoing pending transaction with status $currentStatus',
);
final existing = await getTransactionByLocalId(localId);
if (existing == null) {
_log.warning('OfflineStorage', 'Transaction not found, cannot undo');
return false;
}
if (currentStatus == SyncStatus.pending) {
// For pending creates: delete the transaction completely
_log.info('OfflineStorage', 'Deleting pending create transaction');
await deleteTransaction(localId);
return true;
} else if (currentStatus == SyncStatus.pendingDelete) {
// For pending deletes: restore to synced status
_log.info(
'OfflineStorage',
'Restoring pending delete transaction to synced',
);
final updated = existing.copyWith(
syncStatus: SyncStatus.synced,
updatedAt: DateTime.now(),
);
await _dbHelper.updateTransaction(localId, updated.toDatabaseMap());
return true;
}
_log.warning(
'OfflineStorage', 'Cannot undo transaction with status $currentStatus');
return false;
}
Future<void> syncTransactionsFromServer(
List<Transaction> serverTransactions) async {
_log.info('OfflineStorage',
'syncTransactionsFromServer called with ${serverTransactions.length} transactions from server');
// Use upsert logic instead of clear + insert to preserve recently uploaded transactions
_log.info('OfflineStorage',
'Upserting all transactions from server (preserving pending/failed)');
int upsertedCount = 0;
int emptyAccountIdCount = 0;
for (final transaction in serverTransactions) {
if (transaction.id != null) {
if (transaction.accountId.isEmpty) {
emptyAccountIdCount++;
}
await upsertTransactionFromServer(transaction);
upsertedCount++;
}
}
_log.info(
'OfflineStorage', 'Upserted $upsertedCount transactions from server');
if (emptyAccountIdCount > 0) {
_log.error('OfflineStorage',
'WARNING: $emptyAccountIdCount transactions had EMPTY accountId!');
}
}
Future<void> upsertTransactionFromServer(
Transaction transaction, {
String? accountId,
}) async {
if (transaction.id == null) {
_log.warning('OfflineStorage', 'Skipping transaction with null ID');
return;
}
// If accountId is provided and transaction.accountId is empty, use the provided one
final effectiveAccountId =
transaction.accountId.isEmpty && accountId != null
? accountId
: transaction.accountId;
// Log if transaction has empty accountId
if (transaction.accountId.isEmpty) {
final fallbackApplied = accountId != null && accountId.isNotEmpty;
_log.warning(
'OfflineStorage',
'Transaction has empty accountId from server; fallbackApplied=$fallbackApplied',
);
}
// Check if we already have this transaction
final existing = await getTransactionByServerId(transaction.id!);
if (existing != null) {
// Update existing transaction, preserving its accountId if effectiveAccountId is empty
final finalAccountId =
effectiveAccountId.isEmpty ? existing.accountId : effectiveAccountId;
if (finalAccountId.isEmpty) {
_log.error(
'OfflineStorage',
'CRITICAL: Updating transaction with EMPTY accountId; operation=update fallbackApplied=false existingAccountIdPresent=false',
);
}
final updated = existing.mergeServerTransaction(
transaction,
accountId: finalAccountId,
);
await _dbHelper.updateTransaction(
existing.localId, updated.toDatabaseMap());
} else {
// Insert new transaction
if (effectiveAccountId.isEmpty) {
_log.error(
'OfflineStorage',
'CRITICAL: Inserting transaction with EMPTY accountId; operation=insert fallbackApplied=false',
);
}
final offlineTransaction = OfflineTransaction(
id: transaction.id,
localId: _uuid.v4(),
accountId: effectiveAccountId,
name: transaction.name,
date: transaction.date,
amount: transaction.amount,
currency: transaction.currency,
nature: transaction.nature,
notes: transaction.notes,
categoryId: transaction.categoryId,
categoryName: transaction.categoryName,
merchantId: transaction.merchantId,
merchantName: transaction.merchantName,
tagIds: transaction.tagIds,
tagNames: transaction.tagNames,
syncStatus: SyncStatus.synced,
);
await _dbHelper.insertTransaction(offlineTransaction.toDatabaseMap());
}
}
Future<void> clearTransactions() async {
await _dbHelper.clearTransactions();
}
// Account operations (for caching)
Future<void> saveAccount(Account account) async {
final accountMap = {
'id': account.id,
'name': account.name,
'balance': account.balance,
'currency': account.currency,
'classification': account.classification,
'account_type': account.accountType,
'synced_at': DateTime.now().toIso8601String(),
};
await _dbHelper.insertAccount(accountMap);
}
Future<void> saveAccounts(List<Account> accounts) async {
final accountMaps = accounts
.map((account) => {
'id': account.id,
'name': account.name,
'balance': account.balance,
'currency': account.currency,
'classification': account.classification,
'account_type': account.accountType,
'synced_at': DateTime.now().toIso8601String(),
})
.toList();
await _dbHelper.insertAccounts(accountMaps);
}
Future<List<Account>> getAccounts() async {
final accountMaps = await _dbHelper.getAccounts();
return accountMaps.map((map) => Account.fromJson(map)).toList();
}
Future<Account?> getAccountById(String id) async {
final map = await _dbHelper.getAccountById(id);
return map != null ? Account.fromJson(map) : null;
}
Future<void> clearAccounts() async {
await _dbHelper.clearAccounts();
}
// Utility methods
Future<void> clearAllData() async {
await _dbHelper.clearAllData();
}
}