diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ba4ce97 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +EXCHANGE_RATE_API_KEY=REPLACE_WITH_YOUR_EXCHANGE_RATE_API_KEY \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3820a95..b3b69f2 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,7 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Local runtime configuration +.env +!.env.example diff --git a/README.md b/README.md index ee110f2..3796834 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,10 @@ Simple Flutter app to track US rental income and convert to SEK for Swedish tax ## Setup -1. Set your FX API app key in `lib/state/rent_controller.dart`: +1. Create `.env` in the project root with your FX API key: -```dart -const String forexRateApiKey = 'REPLACE_WITH_YOUR_FOREXRATE_API_KEY'; +```env +EXCHANGE_RATE_API_KEY=REPLACE_WITH_YOUR_EXCHANGE_RATE_API_KEY ``` 1. Install dependencies: diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 222276c..1c7e581 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -21,6 +21,7 @@ class HomeScreen extends StatelessWidget { symbol: 'SEK ', decimalDigits: 2, ); + final rateFormat = NumberFormat('0.000000'); final totalUsd = rows.isEmpty ? 0.0 : rows.last.runningUsd; final totalSek = rows.isEmpty ? 0.0 : rows.last.runningSek; @@ -80,6 +81,7 @@ class HomeScreen extends StatelessWidget { DataColumn(label: Text('Status')), DataColumn(label: Text('USD')), DataColumn(label: Text('SEK')), + DataColumn(label: Text('Rate USD->SEK')), DataColumn(label: Text('Running USD')), DataColumn(label: Text('Running SEK')), ], @@ -110,6 +112,15 @@ class HomeScreen extends StatelessWidget { : sekCurrency.format(row.sekAmount), ), ), + DataCell( + Text( + row.effectiveUsdToSekRate == null + ? '-' + : rateFormat.format( + row.effectiveUsdToSekRate, + ), + ), + ), DataCell( Text(usdCurrency.format(row.runningUsd)), ), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 8080682..9e4ad22 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -91,6 +91,10 @@ class _SettingsScreenState extends State { content: Text('Backfill finished for last year.'), ), ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(controller.errorMessage!)), + ); } }, icon: const Icon(Icons.history), @@ -112,7 +116,7 @@ class _SettingsScreenState extends State { ), const SizedBox(height: 16), const Text( - 'ForexRateAPI key is configured in code constant forexRateApiKey.', + 'ExchangeratesAPI key is loaded from .env (EXCHANGE_RATE_API_KEY).', ), ], ), diff --git a/lib/services/open_exchange_service.dart b/lib/services/open_exchange_service.dart index e12be88..878ccac 100644 --- a/lib/services/open_exchange_service.dart +++ b/lib/services/open_exchange_service.dart @@ -1,5 +1,7 @@ import 'dart:convert'; +import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:intl/intl.dart'; @@ -16,79 +18,129 @@ class ForexRateApiService { final http.Client httpClient; final String apiKey; + void _log(String message) { + if (kDebugMode) { + debugPrint('[ForexRateApiService] $message'); + } + } + + String _redactedUri(Uri uri) { + final redactedQuery = {...uri.queryParameters}; + if (redactedQuery.containsKey('access_key')) { + redactedQuery['access_key'] = '***REDACTED***'; + } + return uri.replace(queryParameters: redactedQuery).toString(); + } + + bool _isRateLimitedResponse(http.Response response) { + if (response.statusCode == 429) { + return true; + } + + try { + final parsed = jsonDecode(response.body) as Map; + final error = parsed['error'] as Map?; + final code = error?['code']?.toString().toLowerCase(); + return code == 'rate_limit_reached'; + } catch (_) { + return false; + } + } + + Future _getWithRateLimitRetry( + Uri uri, { + required String operation, + }) async { + const maxAttempts = 3; + + for (var attempt = 1; attempt <= maxAttempts; attempt++) { + final response = await httpClient.get(uri); + _log('$operation HTTP status: ${response.statusCode}'); + _log('$operation raw response: ${response.body}'); + + final shouldRetry = _isRateLimitedResponse(response); + if (!shouldRetry || attempt == maxAttempts) { + return response; + } + + final delayMs = 1200 * attempt; + _log( + '$operation rate limited; retrying in ${delayMs}ms (attempt ${attempt + 1}/$maxAttempts)', + ); + await Future.delayed(Duration(milliseconds: delayMs)); + } + + throw StateError('Unexpected rate-limit retry flow for $operation.'); + } + Future fetchUsdToSekRate({required DateTime estDate}) async { - if (apiKey == 'REPLACE_WITH_YOUR_FOREXRATE_API_KEY') { - throw StateError('ForexRateAPI key is not configured.'); + if (apiKey == 'REPLACE_WITH_YOUR_EXCHANGE_RATE_API_KEY') { + throw StateError('ExchangeratesAPI key is not configured.'); } final date = DateFormat('yyyy-MM-dd').format(estDate); - final uri = Uri.https('api.forexrateapi.com', '/v1/$date', { - 'api_key': apiKey, - 'base': 'USD', - 'currencies': 'SEK', - }); + final uri = Uri.https( + 'api.exchangeratesapi.io', + '/v1/$date', + // Free-tier plans can restrict custom base currencies. + // We request EUR-based USD and SEK rates and derive USD->SEK via cross-rate. + {'access_key': apiKey, 'symbols': 'USD,SEK'}, + ); - final response = await httpClient.get(uri); + _log('Daily rate request: ${_redactedUri(uri)}'); + + final response = await _getWithRateLimitRetry(uri, operation: 'Daily rate'); if (response.statusCode != 200) { - throw StateError('ForexRateAPI request failed (${response.statusCode}).'); + throw StateError( + 'ExchangeratesAPI request failed (${response.statusCode}).', + ); } final json = jsonDecode(response.body) as Map; final success = json['success'] as bool? ?? false; if (!success) { - throw StateError('ForexRateAPI returned success=false.'); + final error = json['error']; + _log('Daily rate success=false. error=$error'); + throw StateError('ExchangeratesAPI returned success=false. error=$error'); } final rates = json['rates'] as Map?; - final sek = rates?['SEK']; - if (sek == null) { - throw StateError('SEK rate was missing in API response.'); + final usdPerEur = rates?['USD']; + final sekPerEur = rates?['SEK']; + if (usdPerEur == null || sekPerEur == null) { + throw StateError('USD/SEK rates were missing in API response.'); } - return (sek as num).toDouble(); + final usdPerEurValue = (usdPerEur as num).toDouble(); + final sekPerEurValue = (sekPerEur as num).toDouble(); + if (usdPerEurValue == 0) { + throw StateError('USD rate was zero; cannot derive USD->SEK.'); + } + + final usdToSek = sekPerEurValue / usdPerEurValue; + + _log( + 'Daily rate parsed usdPerEur=$usdPerEurValue sekPerEur=$sekPerEurValue derivedUsdToSek=$usdToSek', + ); + + return usdToSek; } Future convertUsdToSek({ required DateTime estDate, required double amount, }) async { - if (apiKey == 'REPLACE_WITH_YOUR_FOREXRATE_API_KEY') { - throw StateError('ForexRateAPI key is not configured.'); + if (apiKey == 'REPLACE_WITH_YOUR_EXCHANGE_RATE_API_KEY') { + throw StateError('ExchangeratesAPI key is not configured.'); } - final date = DateFormat('yyyy-MM-dd').format(estDate); - final uri = - Uri.https('api.forexrateapi.com', '/v1/convert', { - 'api_key': apiKey, - 'from': 'USD', - 'to': 'SEK', - 'amount': amount.toStringAsFixed(2), - 'date': date, - }); + final quote = await fetchUsdToSekRate(estDate: estDate); + final result = quote * amount; - final response = await httpClient.get(uri); - if (response.statusCode != 200) { - throw StateError('ForexRateAPI convert failed (${response.statusCode}).'); - } - - final json = jsonDecode(response.body) as Map; - final success = json['success'] as bool? ?? false; - if (!success) { - throw StateError('ForexRateAPI convert returned success=false.'); - } - - final info = json['info'] as Map?; - final quote = info?['quote']; - final result = json['result']; - if (quote == null || result == null) { - throw StateError( - 'ForexRateAPI convert response was missing quote/result.', - ); - } - - return ForexConversionResult( - quote: (quote as num).toDouble(), - result: (result as num).toDouble(), + _log( + 'Computed conversion from daily rate quote=$quote result=$result amount=$amount date=${DateFormat('yyyy-MM-dd').format(estDate)}', ); + + return ForexConversionResult(quote: quote, result: result); } } diff --git a/lib/state/rent_controller.dart b/lib/state/rent_controller.dart index d2c7a32..10aa3c3 100644 --- a/lib/state/rent_controller.dart +++ b/lib/state/rent_controller.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:http/http.dart' as http; import 'package:intl/intl.dart'; import 'package:path_provider/path_provider.dart'; @@ -13,8 +14,6 @@ import 'package:rental_income_tracker/services/storage_service.dart'; import 'package:rental_income_tracker/services/time_service.dart'; import 'package:timezone/timezone.dart' as tz; -const String forexRateApiKey = 'REPLACE_WITH_YOUR_FOREXRATE_API_KEY'; - class RentTableRow { RentTableRow({ required this.year, @@ -23,6 +22,7 @@ class RentTableRow { required this.status, required this.usdAmount, required this.sekAmount, + required this.effectiveUsdToSekRate, required this.runningUsd, required this.runningSek, }); @@ -33,6 +33,7 @@ class RentTableRow { final PaymentStatus status; final double? usdAmount; final double? sekAmount; + final double? effectiveUsdToSekRate; final double runningUsd; final double runningSek; } @@ -46,11 +47,24 @@ class RentController extends ChangeNotifier { exchangeService ?? ForexRateApiService( httpClient: http.Client(), - apiKey: forexRateApiKey, + apiKey: _exchangeRateApiKeyFromEnv(), ) { _notificationService = NotificationService(_onNotificationAction); } + static String _exchangeRateApiKeyFromEnv() { + final key = dotenv.env['EXCHANGE_RATE_API_KEY']?.trim() ?? ''; + if (key.isEmpty) { + if (kDebugMode) { + debugPrint( + '[RentController] EXCHANGE_RATE_API_KEY missing in .env, API calls will fail until set.', + ); + } + return 'REPLACE_WITH_YOUR_EXCHANGE_RATE_API_KEY'; + } + return key; + } + final StorageService _storageService; final ForexRateApiService _exchangeService; late NotificationService _notificationService; @@ -138,6 +152,12 @@ class RentController extends ChangeNotifier { _errorMessage = null; _setLoading(true); + if (kDebugMode) { + debugPrint( + '[RentController] markMonthPaid start year=$year month=$month source=$source assumeOnTime=$assumeOnTime', + ); + } + try { if (_isNotOccupiedMonth(year, month)) { throw StateError('Cannot mark a non-occupied month as paid.'); @@ -148,6 +168,11 @@ class RentController extends ChangeNotifier { final key = TimeService.monthKey(year, month); if (_records.containsKey(key)) { + if (kDebugMode) { + debugPrint( + '[RentController] markMonthPaid skipped API call; cached record exists for key=$key', + ); + } await _notificationService.cancelForMonth(year, month); await _syncNotificationScheduleForCurrentMonth(); return; @@ -161,6 +186,11 @@ class RentController extends ChangeNotifier { estDate: estDate, amount: usd, ); + if (kDebugMode) { + debugPrint( + '[RentController] markMonthPaid conversion success quote=${conversion.quote} result=${conversion.result} usd=$usd estDate=$estDate', + ); + } final onTime = assumeOnTime || TimeService.isOnTimeByDeadline(paidAt, year, month); @@ -178,9 +208,19 @@ class RentController extends ChangeNotifier { _records[record.key] = record; await _storageService.saveRecords(_records); + if (kDebugMode) { + debugPrint( + '[RentController] markMonthPaid stored record key=${record.key}', + ); + } + await _notificationService.cancelForMonth(year, month); await _syncNotificationScheduleForCurrentMonth(); - } catch (err) { + } catch (err, stackTrace) { + if (kDebugMode) { + debugPrint('[RentController] markMonthPaid failed: $err'); + debugPrint(stackTrace.toString()); + } _errorMessage = err.toString(); } finally { _setLoading(false); @@ -191,48 +231,93 @@ class RentController extends ChangeNotifier { _errorMessage = null; _setLoading(true); + if (kDebugMode) { + debugPrint('[RentController] backfillLastYear start'); + } + try { if (_settings.rentUsd <= 0) { throw StateError('Set rent amount in Settings before backfill.'); } final year = DateTime.now().year - 1; + var savedMonths = 0; + var pausedByRateLimit = false; + for (var month = 1; month <= 12; month++) { final key = TimeService.monthKey(year, month); if (_records.containsKey(key)) { + if (kDebugMode) { + debugPrint('[RentController] backfill skip cached key=$key'); + } continue; } - final usd = _settings.rentUsd; - final estDate = DateTime(year, month, 1); - final conversion = await _exchangeService.convertUsdToSek( - estDate: estDate, - amount: usd, - ); + try { + final usd = _settings.rentUsd; + final estDate = DateTime(year, month, 1); + final conversion = await _exchangeService.convertUsdToSek( + estDate: estDate, + amount: usd, + ); - final paidAtUtc = tz.TZDateTime( - TimeService.est, - year, - month, - 1, - 12, - ).toUtc(); + if (kDebugMode) { + debugPrint( + '[RentController] backfill conversion year=$year month=$month quote=${conversion.quote} result=${conversion.result}', + ); + } - _records[key] = MonthlyRentRecord( - year: year, - month: month, - usdAmount: usd, - sekAmount: conversion.result, - usdToSekRate: conversion.quote, - paidAtUtc: paidAtUtc, - onTime: true, - source: 'backfill', - ); + final paidAtUtc = tz.TZDateTime( + TimeService.est, + year, + month, + 1, + 12, + ).toUtc(); + + _records[key] = MonthlyRentRecord( + year: year, + month: month, + usdAmount: usd, + sekAmount: conversion.result, + usdToSekRate: conversion.quote, + paidAtUtc: paidAtUtc, + onTime: true, + source: 'backfill', + ); + await _storageService.saveRecords(_records); + savedMonths++; + } catch (err) { + final errorText = err.toString(); + if (errorText.contains('429') || + errorText.toLowerCase().contains('rate_limit_reached')) { + pausedByRateLimit = true; + _errorMessage = + 'Backfill paused by API rate limits after $savedMonths new month(s). Run backfill again in a minute to continue.'; + if (kDebugMode) { + debugPrint( + '[RentController] backfill paused by rate limit after $savedMonths month(s)', + ); + } + break; + } + rethrow; + } } - await _storageService.saveRecords(_records); + if (kDebugMode) { + debugPrint( + pausedByRateLimit + ? '[RentController] backfillLastYear paused by rate limit' + : '[RentController] backfillLastYear completed', + ); + } notifyListeners(); - } catch (err) { + } catch (err, stackTrace) { + if (kDebugMode) { + debugPrint('[RentController] backfillLastYear failed: $err'); + debugPrint(stackTrace.toString()); + } _errorMessage = err.toString(); } finally { _setLoading(false); @@ -280,10 +365,12 @@ class RentController extends ChangeNotifier { double? usd; double? sek; + double? rate; if (record != null) { usd = record.usdAmount; sek = record.sekAmount; + rate = record.usdToSekRate; runningUsd += usd; runningSek += sek; } @@ -296,6 +383,7 @@ class RentController extends ChangeNotifier { status: status, usdAmount: usd, sekAmount: sek, + effectiveUsdToSekRate: rate, runningUsd: runningUsd, runningSek: runningSek, ), diff --git a/pubspec.yaml b/pubspec.yaml index e2b9317..c3bc36c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,6 +9,7 @@ environment: dependencies: flutter: sdk: flutter + flutter_dotenv: ^5.2.1 flutter_local_notifications: ^19.5.0 flutter_timezone: ^4.1.1 http: ^1.5.0 @@ -25,3 +26,5 @@ dev_dependencies: flutter: uses-material-design: true + assets: + - ./.env