mirror of
https://github.com/we-promise/sure.git
synced 2026-04-11 16:24:51 +00:00
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:
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user