diff --git a/lib/services/open_exchange_service.dart b/lib/services/open_exchange_service.dart index 878ccac..4088cc7 100644 --- a/lib/services/open_exchange_service.dart +++ b/lib/services/open_exchange_service.dart @@ -1,5 +1,5 @@ -import 'dart:convert'; import 'dart:async'; +import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; @@ -143,4 +143,75 @@ class ForexRateApiService { return ForexConversionResult(quote: quote, result: result); } + + /// Fetches USD->SEK cross-rates for every day of [year] in a single timeseries + /// API call. Returns a map of 'yyyy-MM-dd' -> rate, or null if the timeseries + /// endpoint is not available on the current subscription plan. + Future?> tryFetchYearRates(int year) async { + if (apiKey == 'REPLACE_WITH_YOUR_EXCHANGE_RATE_API_KEY') { + throw StateError('ExchangeratesAPI key is not configured.'); + } + + final uri = + Uri.https('api.exchangeratesapi.io', '/v1/timeseries', { + 'access_key': apiKey, + 'start_date': '$year-01-01', + 'end_date': '$year-12-31', + 'symbols': 'USD,SEK', + }); + + _log('Timeseries request: ${_redactedUri(uri)}'); + + final response = await _getWithRateLimitRetry(uri, operation: 'Timeseries'); + + if (response.statusCode == 403) { + _log( + 'Timeseries endpoint not available on current plan; falling back to daily requests.', + ); + return null; + } + + if (response.statusCode != 200) { + throw StateError( + 'ExchangeratesAPI timeseries failed (${response.statusCode}).', + ); + } + + final json = jsonDecode(response.body) as Map; + final success = json['success'] as bool? ?? false; + if (!success) { + final error = json['error'] as Map?; + final code = error?['code']?.toString() ?? ''; + if (code == 'function_access_restricted' || + code == 'subscription_plan_not_support_endpoint') { + _log( + 'Timeseries endpoint not available on current plan; falling back to daily requests.', + ); + return null; + } + _log('Timeseries success=false. error=$error'); + throw StateError( + 'ExchangeratesAPI timeseries returned success=false. error=$error', + ); + } + + final rates = json['rates'] as Map?; + if (rates == null) { + throw StateError('Timeseries response missing rates object.'); + } + + final result = {}; + for (final entry in rates.entries) { + final date = entry.key; + final dayRates = entry.value as Map?; + final usdPerEur = (dayRates?['USD'] as num?)?.toDouble(); + final sekPerEur = (dayRates?['SEK'] as num?)?.toDouble(); + if (usdPerEur != null && sekPerEur != null && usdPerEur != 0) { + result[date] = sekPerEur / usdPerEur; + } + } + + _log('Timeseries parsed ${result.length} rate entries for year $year'); + return result; + } } diff --git a/lib/state/rent_controller.dart b/lib/state/rent_controller.dart index 10aa3c3..71790f0 100644 --- a/lib/state/rent_controller.dart +++ b/lib/state/rent_controller.dart @@ -241,8 +241,20 @@ class RentController extends ChangeNotifier { } final year = DateTime.now().year - 1; + + // Attempt a single timeseries call covering the whole year. + final batchRates = await _exchangeService.tryFetchYearRates(year); + if (kDebugMode) { + debugPrint( + batchRates != null + ? '[RentController] backfill using batch timeseries (${batchRates.length} dates)' + : '[RentController] backfill falling back to individual daily requests', + ); + } + var savedMonths = 0; var pausedByRateLimit = false; + var isFirstIndividualFetch = true; for (var month = 1; month <= 12; month++) { final key = TimeService.monthKey(year, month); @@ -256,10 +268,29 @@ class RentController extends ChangeNotifier { try { final usd = _settings.rentUsd; final estDate = DateTime(year, month, 1); - final conversion = await _exchangeService.convertUsdToSek( - estDate: estDate, - amount: usd, - ); + + final ForexConversionResult conversion; + if (batchRates != null) { + final dateStr = DateFormat('yyyy-MM-dd').format(estDate); + final quote = batchRates[dateStr]; + if (quote == null) { + throw StateError('Batch rates missing entry for $dateStr.'); + } + conversion = ForexConversionResult( + quote: quote, + result: quote * usd, + ); + } else { + // Pace individual requests to avoid triggering the rate limit. + if (!isFirstIndividualFetch) { + await Future.delayed(const Duration(milliseconds: 700)); + } + isFirstIndividualFetch = false; + conversion = await _exchangeService.convertUsdToSek( + estDate: estDate, + amount: usd, + ); + } if (kDebugMode) { debugPrint(