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 <noreply@anthropic.com>
This commit is contained in:
Lazy Bone
2026-01-13 16:27:39 +08:00
committed by GitHub
parent 8b6392e1d1
commit 62dabb6971
5 changed files with 243 additions and 45 deletions

View File

@@ -20,14 +20,33 @@ class Transaction {
});
factory Transaction.fromJson(Map<String, dynamic> 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(),
);
}

View File

@@ -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<void> _handleClearLocalData(BuildContext context) async {
final confirmed = await showDialog<bool>(
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<void> _handleLogout(BuildContext context) async {
final confirmed = await showDialog<bool>(
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),

View File

@@ -171,18 +171,31 @@ class OfflineStorageService {
Future<void> syncTransactionsFromServer(List<Transaction> 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<void> 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');
}
}

View File

@@ -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<dynamic>?)
?.cast<Transaction>() ?? [];
List<Transaction> 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<dynamic>?)
?.cast<Transaction>() ?? [];
_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<String, dynamic>?;
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(

View File

@@ -75,10 +75,24 @@ class TransactionsService {
Future<Map<String, dynamic>> getTransactions({
required String accessToken,
String? accountId,
int? page,
int? perPage,
}) async {
final Map<String, String> 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<dynamic> transactionsJson;
Map<String, dynamic>? 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 {