From 62dabb6971cec8bfb0fad48cfbc5010384a569d0 Mon Sep 17 00:00:00 2001 From: Lazy Bone <89256478+dwvwdv@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:27:39 +0800 Subject: [PATCH] Fix: Transaction Sync Issues & Enhanced Debugging (#632) * Fix mobile app to fetch all transactions with pagination The mobile app was only fetching 25 transactions per account because: 1. TransactionsService didn't pass pagination parameters to the API 2. The backend defaults to 25 records per page when no per_page is specified 3. SyncService didn't implement pagination to fetch all pages Changes: - Updated TransactionsService.getTransactions() to accept page and perPage parameters - Modified the method to extract and return pagination metadata from API response - Updated SyncService.syncFromServer() to fetch all pages (up to 100 per page) - Added pagination loop to continue fetching until all pages are retrieved - Enhanced logging to show pagination progress This ensures users see all their transactions in the mobile app, not just the first 25. * Add clear local data feature and enhanced sync logging Added features: 1. Clear Local Data button in Settings - Allows users to clear all cached transactions and accounts - Shows confirmation dialog before clearing - Displays success/error feedback 2. Enhanced sync logging for debugging - Added detailed logs in syncFromServer to track pagination - Shows page-by-page progress with transaction counts - Logs pagination metadata (total pages, total count, etc.) - Tracks upsert progress every 50 transactions - Added clear section markers for easier log reading 3. Simplified upsertTransactionFromServer logging - Removed verbose debug logs to reduce noise - Keeps only essential error/warning logs This will help users troubleshoot sync issues by: - Clearing stale data and forcing a fresh sync - Providing detailed logs to identify where sync might fail * Fix transaction accountId parsing from API response The mobile app was only showing 25 transactions per account because: - The backend API returns account info in nested format: {"account": {"id": "xxx"}} - The mobile Transaction model expected flat format: {"account_id": "xxx"} - When parsing, accountId was always empty, so database queries by account_id returned incomplete results Changes: 1. Updated Transaction.fromJson to handle both formats: - New format: {"account": {"id": "xxx", "name": "..."}} - Old format: {"account_id": "xxx"} (for backward compatibility) 2. Fixed classification/nature field parsing: - Backend sends "classification" field (income/expense) - Mobile uses "nature" field - Now handles both fields correctly 3. Added debug logging to identify empty accountId issues: - Logs first transaction's accountId when syncing - Counts and warns about transactions with empty accountId - Shows critical errors when trying to save with empty accountId This ensures all transactions from the server are correctly associated with their accounts in the local database. --------- Co-authored-by: Claude --- mobile/lib/models/transaction.dart | 23 +++- mobile/lib/screens/settings_screen.dart | 86 ++++++++++++ .../lib/services/offline_storage_service.dart | 31 ++++- mobile/lib/services/sync_service.dart | 123 +++++++++++++----- mobile/lib/services/transactions_service.dart | 25 +++- 5 files changed, 243 insertions(+), 45 deletions(-) diff --git a/mobile/lib/models/transaction.dart b/mobile/lib/models/transaction.dart index 8d2e93b3a..291f571c0 100644 --- a/mobile/lib/models/transaction.dart +++ b/mobile/lib/models/transaction.dart @@ -20,14 +20,33 @@ class Transaction { }); factory Transaction.fromJson(Map json) { + // Handle both API formats: + // 1. New format: {"account": {"id": "xxx", "name": "..."}} + // 2. Old format: {"account_id": "xxx"} + String accountId = ''; + if (json['account'] != null && json['account'] is Map) { + accountId = json['account']['id']?.toString() ?? ''; + } else if (json['account_id'] != null) { + accountId = json['account_id']?.toString() ?? ''; + } + + // Handle classification (from backend) or nature (from mobile) + String nature = 'expense'; + if (json['classification'] != null) { + final classification = json['classification']?.toString().toLowerCase() ?? ''; + nature = classification == 'income' ? 'income' : 'expense'; + } else if (json['nature'] != null) { + nature = json['nature']?.toString() ?? 'expense'; + } + return Transaction( id: json['id']?.toString(), - accountId: json['account_id']?.toString() ?? '', + accountId: accountId, name: json['name']?.toString() ?? '', date: json['date']?.toString() ?? '', amount: json['amount']?.toString() ?? '0', currency: json['currency']?.toString() ?? '', - nature: json['nature']?.toString() ?? 'expense', + nature: nature, notes: json['notes']?.toString(), ); } diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index 5ff79ae69..0f5518c8c 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -1,10 +1,73 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/auth_provider.dart'; +import '../services/offline_storage_service.dart'; +import '../services/log_service.dart'; class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); + Future _handleClearLocalData(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear Local Data'), + content: const Text( + 'This will delete all locally cached transactions and accounts. ' + 'Your data on the server will not be affected. ' + 'Are you sure you want to continue?' + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: const Text('Clear Data'), + ), + ], + ), + ); + + if (confirmed == true && context.mounted) { + try { + final offlineStorage = OfflineStorageService(); + final log = LogService.instance; + + log.info('Settings', 'Clearing all local data...'); + await offlineStorage.clearAllData(); + log.info('Settings', 'Local data cleared successfully'); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Local data cleared successfully. Pull to refresh to sync from server.'), + backgroundColor: Colors.green, + duration: Duration(seconds: 3), + ), + ); + } + } catch (e) { + final log = LogService.instance; + log.error('Settings', 'Failed to clear local data: $e'); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to clear local data: $e'), + backgroundColor: Colors.red, + duration: const Duration(seconds: 3), + ), + ); + } + } + } + } + Future _handleLogout(BuildContext context) async { final confirmed = await showDialog( context: context, @@ -102,6 +165,29 @@ class SettingsScreen extends StatelessWidget { const Divider(), + // Data Management Section + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + 'Data Management', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.grey, + ), + ), + ), + + // Clear local data button + ListTile( + leading: const Icon(Icons.delete_outline), + title: const Text('Clear Local Data'), + subtitle: const Text('Remove all cached transactions and accounts'), + onTap: () => _handleClearLocalData(context), + ), + + const Divider(), + // Sign out button Padding( padding: const EdgeInsets.all(16), diff --git a/mobile/lib/services/offline_storage_service.dart b/mobile/lib/services/offline_storage_service.dart index f6df78743..9f5567dc6 100644 --- a/mobile/lib/services/offline_storage_service.dart +++ b/mobile/lib/services/offline_storage_service.dart @@ -171,18 +171,31 @@ class OfflineStorageService { Future syncTransactionsFromServer(List serverTransactions) async { _log.info('OfflineStorage', 'syncTransactionsFromServer called with ${serverTransactions.length} transactions from server'); + // Log first transaction's accountId for debugging + if (serverTransactions.isNotEmpty) { + final firstTx = serverTransactions.first; + _log.info('OfflineStorage', 'First transaction: id=${firstTx.id}, accountId="${firstTx.accountId}", name="${firstTx.name}"'); + } + // 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 upsertTransactionFromServer( @@ -199,15 +212,22 @@ class OfflineStorageService { ? accountId : transaction.accountId; - _log.debug('OfflineStorage', 'Upserting transaction ${transaction.id}: accountId="${transaction.accountId}" -> effective="$effectiveAccountId"'); + // Log if transaction has empty accountId + if (transaction.accountId.isEmpty) { + _log.warning('OfflineStorage', 'Transaction ${transaction.id} has empty accountId from server! Provided accountId: $accountId, effective: $effectiveAccountId'); + } // Check if we already have this transaction final existing = await getTransactionByServerId(transaction.id!); if (existing != null) { - _log.debug('OfflineStorage', 'Updating existing transaction (localId: ${existing.localId}, was ${existing.syncStatus})'); // 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 ${transaction.id} with EMPTY accountId!'); + } + final updated = OfflineTransaction( id: transaction.id, localId: existing.localId, @@ -221,10 +241,12 @@ class OfflineStorageService { syncStatus: SyncStatus.synced, ); await _dbHelper.updateTransaction(existing.localId, updated.toDatabaseMap()); - _log.debug('OfflineStorage', 'Transaction updated successfully with accountId="$finalAccountId"'); } else { - _log.debug('OfflineStorage', 'Inserting new transaction with accountId="$effectiveAccountId"'); // Insert new transaction + if (effectiveAccountId.isEmpty) { + _log.error('OfflineStorage', 'CRITICAL: Inserting transaction ${transaction.id} with EMPTY accountId!'); + } + final offlineTransaction = OfflineTransaction( id: transaction.id, localId: _uuid.v4(), @@ -238,7 +260,6 @@ class OfflineStorageService { syncStatus: SyncStatus.synced, ); await _dbHelper.insertTransaction(offlineTransaction.toDatabaseMap()); - _log.debug('OfflineStorage', 'Transaction inserted successfully'); } } diff --git a/mobile/lib/services/sync_service.dart b/mobile/lib/services/sync_service.dart index e7c8f328d..82abbf575 100644 --- a/mobile/lib/services/sync_service.dart +++ b/mobile/lib/services/sync_service.dart @@ -217,49 +217,100 @@ class SyncService with ChangeNotifier { String? accountId, }) async { try { - _log.debug('SyncService', 'Fetching transactions from server (accountId: $accountId)'); - final result = await _transactionsService.getTransactions( - accessToken: accessToken, - accountId: accountId, - ); + _log.info('SyncService', '========== SYNC FROM SERVER START =========='); + _log.info('SyncService', 'Fetching transactions from server (accountId: ${accountId ?? "ALL"})'); - if (result['success'] == true) { - final transactions = (result['transactions'] as List?) - ?.cast() ?? []; + List allTransactions = []; + int currentPage = 1; + int totalPages = 1; + const int perPage = 100; // Use maximum allowed by backend - _log.info('SyncService', 'Received ${transactions.length} transactions from server'); + // Fetch all pages + while (currentPage <= totalPages) { + _log.info('SyncService', '>>> Fetching page $currentPage of $totalPages (perPage: $perPage)'); - // Update local cache with server data - if (accountId == null) { - _log.debug('SyncService', 'Full sync - clearing and replacing all transactions'); - // Full sync - replace all transactions - await _offlineStorage.syncTransactionsFromServer(transactions); + final result = await _transactionsService.getTransactions( + accessToken: accessToken, + accountId: accountId, + page: currentPage, + perPage: perPage, + ); + + _log.debug('SyncService', 'API call completed for page $currentPage, success: ${result['success']}'); + + if (result['success'] == true) { + final pageTransactions = (result['transactions'] as List?) + ?.cast() ?? []; + + _log.info('SyncService', 'Page $currentPage returned ${pageTransactions.length} transactions'); + allTransactions.addAll(pageTransactions); + _log.info('SyncService', 'Total transactions accumulated: ${allTransactions.length}'); + + // Extract pagination info if available + final pagination = result['pagination'] as Map?; + if (pagination != null) { + final prevTotalPages = totalPages; + totalPages = pagination['total_pages'] as int? ?? 1; + final totalCount = pagination['total_count'] as int? ?? 0; + final currentPageFromApi = pagination['page'] as int? ?? currentPage; + final perPageFromApi = pagination['per_page'] as int? ?? perPage; + + _log.info('SyncService', 'Pagination info: page=$currentPageFromApi/$totalPages, per_page=$perPageFromApi, total_count=$totalCount'); + + if (prevTotalPages != totalPages) { + _log.info('SyncService', 'Total pages updated from $prevTotalPages to $totalPages'); + } + } else { + // No pagination info means this is the only page + _log.warning('SyncService', 'No pagination info in response - assuming single page'); + totalPages = currentPage; + } + + _log.info('SyncService', 'Moving to next page (current: $currentPage, total: $totalPages)'); + currentPage++; } else { - _log.debug('SyncService', 'Partial sync - upserting ${transactions.length} transactions for account $accountId'); - // Partial sync - upsert transactions - for (final transaction in transactions) { - _log.debug('SyncService', 'Upserting transaction ${transaction.id} (accountId from server: "${transaction.accountId}", provided: "$accountId")'); - await _offlineStorage.upsertTransactionFromServer( - transaction, - accountId: accountId, - ); + _log.error('SyncService', 'Server returned error on page $currentPage: ${result['error']}'); + return SyncResult( + success: false, + error: result['error'] as String? ?? 'Failed to sync from server', + ); + } + } + + _log.info('SyncService', '>>> Pagination loop completed. Fetched ${currentPage - 1} pages'); + _log.info('SyncService', '>>> Received total of ${allTransactions.length} transactions from server'); + + // Update local cache with server data + _log.info('SyncService', '========== UPDATING LOCAL CACHE =========='); + if (accountId == null) { + _log.info('SyncService', 'Full sync - clearing and replacing all transactions'); + // Full sync - replace all transactions + await _offlineStorage.syncTransactionsFromServer(allTransactions); + } else { + _log.info('SyncService', 'Partial sync - upserting ${allTransactions.length} transactions for account $accountId'); + // Partial sync - upsert transactions + int upsertCount = 0; + for (final transaction in allTransactions) { + await _offlineStorage.upsertTransactionFromServer( + transaction, + accountId: accountId, + ); + upsertCount++; + if (upsertCount % 50 == 0) { + _log.info('SyncService', 'Upserted $upsertCount/${allTransactions.length} transactions'); } } - - _lastSyncTime = DateTime.now(); - notifyListeners(); - - return SyncResult( - success: true, - syncedCount: transactions.length, - ); - } else { - _log.error('SyncService', 'Server returned error: ${result['error']}'); - return SyncResult( - success: false, - error: result['error'] as String? ?? 'Failed to sync from server', - ); + _log.info('SyncService', 'Completed upserting $upsertCount transactions'); } + + _log.info('SyncService', '========== SYNC FROM SERVER COMPLETE =========='); + _lastSyncTime = DateTime.now(); + notifyListeners(); + + return SyncResult( + success: true, + syncedCount: allTransactions.length, + ); } catch (e) { _log.error('SyncService', 'Exception in syncFromServer: $e'); return SyncResult( diff --git a/mobile/lib/services/transactions_service.dart b/mobile/lib/services/transactions_service.dart index 93c65c6c4..e22c6d964 100644 --- a/mobile/lib/services/transactions_service.dart +++ b/mobile/lib/services/transactions_service.dart @@ -75,10 +75,24 @@ class TransactionsService { Future> getTransactions({ required String accessToken, String? accountId, + int? page, + int? perPage, }) async { + final Map queryParams = {}; + + if (accountId != null) { + queryParams['account_id'] = accountId; + } + if (page != null) { + queryParams['page'] = page.toString(); + } + if (perPage != null) { + queryParams['per_page'] = perPage.toString(); + } + final baseUri = Uri.parse('${ApiConfig.baseUrl}/api/v1/transactions'); - final url = accountId != null - ? baseUri.replace(queryParameters: {'account_id': accountId}) + final url = queryParams.isNotEmpty + ? baseUri.replace(queryParameters: queryParams) : baseUri; try { @@ -96,10 +110,16 @@ class TransactionsService { // Handle both array and object responses List transactionsJson; + Map? pagination; + if (responseData is List) { transactionsJson = responseData; } else if (responseData is Map && responseData.containsKey('transactions')) { transactionsJson = responseData['transactions']; + // Extract pagination metadata if present + if (responseData.containsKey('pagination')) { + pagination = responseData['pagination']; + } } else { transactionsJson = []; } @@ -111,6 +131,7 @@ class TransactionsService { return { 'success': true, 'transactions': transactions, + if (pagination != null) 'pagination': pagination, }; } else if (response.statusCode == 401) { return {