diff --git a/AGENTS.md b/AGENTS.md index 80ba4a1..665a60e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,15 +20,15 @@ with the architecture below. - `lib/main.dart` bootstraps the app and tabs. - `lib/application/app_dependencies.dart` wires repositories, use cases, and - external clients; dispose `YahooFinanceCurrencyClient` when done. + external clients; dispose `ProxyCurrencyRatesClient` when done. ## Storage and services - Drift database in `lib/infrastructure/persistence/database.dart`, stored as `subctrl.db` in the app documents directory; `schemaVersion` is 1. - Currency seeds: `lib/infrastructure/persistence/seeds/currency_seed_data.dart`. -- External rates: `YahooFinanceCurrencyClient` and - `SubscriptionCurrencyRatesClient`. +- Currency rates backend lives in `backend/` (Cloudflare Workers); the Flutter + app uses `ProxyCurrencyRatesClient` and `SubscriptionCurrencyRatesClient`. - Notifications: `LocalNotificationsService` (timezone aware). ## Repo map @@ -37,6 +37,7 @@ with the architecture below. - `lib/application/` use cases and DI - `lib/domain/` entities/repositories/services - `lib/infrastructure/` persistence/currency/platform/repositories +- `backend/` Cloudflare Worker for currency rates - `test/` mirrors layers ## Coding and testing diff --git a/README.md b/README.md index fcb8f52..cbe0d69 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ sends optional local reminders before renewals hit. ## Features - Track subscriptions with local storage backed by Drift (`subctrl.db`). -- Automatic currency conversion via Yahoo Finance rates with historical seeds to keep analytics stable offline. +- Automatic currency conversion via a proxy rates service with historical seeds to keep analytics stable offline. - Analytics tabs for monthly burn, category totals, and long-term spend trends. - Local, timezone-aware notifications driven by `LocalNotificationsService`. - Clean Architecture split into presentation, application, domain, and @@ -38,22 +38,6 @@ sends optional local reminders before renewals hit. Analytics overview

-## Architecture at a Glance - -``` -lib/ -├─ presentation/ # UI, view models, localization -├─ application/ # Use cases and dependency wiring -├─ domain/ # Pure business logic + repository interfaces -└─ infrastructure/ # Drift database, currency clients, platform services -``` - -- `lib/main.dart` boots the tabbed UI and wires dependencies. -- `lib/application/app_dependencies.dart` registers repositories, use cases, and - disposes the `YahooFinanceCurrencyClient`. -- Data lives in `lib/infrastructure/persistence/database.dart` (schema version 1 - stored as `subctrl.db` in the app documents directory). - ## Getting Started 1. **Prereqs**: Flutter 3.38.5 (matches CI), Dart SDK ^3.10.3, Xcode for iOS @@ -69,26 +53,6 @@ lib/ 4. **Configure notifications** (optional): ensure the iOS simulator/device has notification permissions enabled so local reminders can fire. -## GitHub Pages Policies - -Static policy/support pages for App Store review live in `docs/`. The folder now -contains a minimal Jekyll setup (`_config.yml`, `_layouts`, and `assets`) so -Markdown pages gain HTML wrappers when GitHub Pages builds the `gh-pages` -branch. The `Publish Docs` workflow pushes the folder to `gh-pages` only when a -commit touching `docs/` lands on `master`, so publishing simply means editing -Markdown with the required front matter (`layout`, `title`, `permalink`) and -pushing your change. Preview the pages locally with any static server (they -render as plain Markdown locally) or let GitHub Pages handle the Jekyll build. - -## Testing - -All unit and widget tests run through `flutter test --coverage`, and coverage -must stay above 70%. Run locally with: - -```bash -flutter test --coverage -``` - ## License MIT © Denis Dementev diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index a0164ad..c0cb72b 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -7,6 +7,7 @@ docs_dir: pages site_dir: site nav: - Home: index.md + - Currency Rates Backend: rates-backend.md - Policy: - Privacy Policy (EN): policy-en.md - Политика конфиденциальности (RU): policy-ru.md diff --git a/docs/pages/rates-backend.md b/docs/pages/rates-backend.md new file mode 100644 index 0000000..15c1e27 --- /dev/null +++ b/docs/pages/rates-backend.md @@ -0,0 +1,88 @@ +# Currency Rates Backend + +Subctrl runs its own backend service for currency rates. The Flutter app talks +to it through `ProxyCurrencyRatesClient`. + +## API Contract + +`GET /v1/rates` + +Query parameters: + +- `base`: 3-letter ISO code (example: `USD`). +- `quotes`: comma-separated 3-letter codes (example: `EUR,GBP`). + +Response (`200`): + +```json +{ + "base": "USD", + "rates": [ + { + "quote": "EUR", + "rate": 1.08, + "fetched_at": "2026-01-23T12:00:00Z" + } + ], + "provider": "yahoo_finance", + "as_of": "2026-01-23T12:00:05Z" +} +``` + +Errors: + +- `400` validation or unsupported currencies. +- `429` rate limit exceeded. +- `502` upstream fetch failure. + +Error body: + +```json +{ + "error": { + "code": "validation_error", + "message": "...", + "details": {} + } +} +``` + +Caching: + +- Responses include `Cache-Control` and `ETag` headers. +- Send `If-None-Match` to receive `304 Not Modified`. + +Refresh cadence defaults to 5 minutes (`CACHE_MAX_AGE_SECONDS=300`). + +Supported currencies: + +- By default the backend accepts any 3-letter ISO code. +- Set `SUPPORTED_CURRENCY_CODES` (comma-separated) to restrict supported codes. + +## Configuration + +Environment variables: + +- `CACHE_MAX_AGE_SECONDS` (default `300`) +- `RATE_LIMIT_MAX` (default `60`) +- `RATE_LIMIT_WINDOW_SECONDS` (default `60`) +- `UPSTREAM_TIMEOUT_SECONDS` (default `6`) + +Flutter client override: + +- `SUBCTRL_RATES_URL` (compile-time Dart define) to point the app at the backend base URL. + +## Cloudflare Workers Deployment + +From `backend/`: + +```bash +wrangler deploy +``` + +Required secrets/vars: + +- `CF_API_TOKEN` and `CF_ACCOUNT_ID` configured in CI. +- Configure the environment variables above via `wrangler.toml` or the Cloudflare dashboard. + +CI deploys the worker on pushes to `master` when `backend/` changes. diff --git a/docs/pyproject.toml b/docs/pyproject.toml index 0cbc089..cad0b7d 100644 --- a/docs/pyproject.toml +++ b/docs/pyproject.toml @@ -1,7 +1,7 @@ [project] -name = "safex-docs" +name = "subctrl-docs" version = "0.1.0" -description = "Safex docs" +description = "Subctrl docs" readme = "README.md" requires-python = ">=3.12" dependencies = [ diff --git a/docs/uv.lock b/docs/uv.lock index d097fd5..5552693 100644 --- a/docs/uv.lock +++ b/docs/uv.lock @@ -27,11 +27,11 @@ wheels = [ [[package]] name = "certifi" -version = "2025.11.12" +version = "2026.1.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, ] [[package]] @@ -147,11 +147,11 @@ wheels = [ [[package]] name = "markdown" -version = "3.10" +version = "3.10.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/7dd27d9d863b3376fcf23a5a13cb5d024aed1db46f963f1b5735ae43b3be/markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e", size = 364931, upload-time = "2025-11-03T19:51:15.007Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/b1/af95bcae8549f1f3fd70faacb29075826a0d689a27f232e8cee315efa053/markdown-3.10.1.tar.gz", hash = "sha256:1c19c10bd5c14ac948c53d0d762a04e2fa35a6d58a6b7b1e6bfcbe6fefc0001a", size = 365402, upload-time = "2026-01-21T18:09:28.206Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/81/54e3ce63502cd085a0c556652a4e1b919c45a446bd1e5300e10c44c8c521/markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c", size = 107678, upload-time = "2025-11-03T19:51:13.887Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/6ef961f543593969d25b2afe57a3564200280528caa9bd1082eecdd7b3bc/markdown-3.10.1-py3-none-any.whl", hash = "sha256:867d788939fe33e4b736426f5b9f651ad0c0ae0ecf89df0ca5d1176c70812fe3", size = 107684, upload-time = "2026-01-21T18:09:27.203Z" }, ] [[package]] @@ -297,11 +297,11 @@ wheels = [ [[package]] name = "packaging" -version = "25.0" +version = "26.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] [[package]] @@ -315,11 +315,11 @@ wheels = [ [[package]] name = "pathspec" -version = "0.12.1" +version = "1.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/b2/bb8e495d5262bfec41ab5cb18f522f1012933347fb5d9e62452d446baca2/pathspec-1.0.3.tar.gz", hash = "sha256:bac5cf97ae2c2876e2d25ebb15078eb04d76e4b98921ee31c6f85ade8b59444d", size = 130841, upload-time = "2026-01-09T15:46:46.009Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, + { url = "https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c", size = 55021, upload-time = "2026-01-09T15:46:44.652Z" }, ] [[package]] @@ -342,15 +342,15 @@ wheels = [ [[package]] name = "pymdown-extensions" -version = "10.20" +version = "10.20.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3e/35/e3814a5b7df295df69d035cfb8aab78b2967cdf11fcfae7faed726b66664/pymdown_extensions-10.20.tar.gz", hash = "sha256:5c73566ab0cf38c6ba084cb7c5ea64a119ae0500cce754ccb682761dfea13a52", size = 852774, upload-time = "2025-12-31T19:59:42.211Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/6c/9e370934bfa30e889d12e61d0dae009991294f40055c238980066a7fbd83/pymdown_extensions-10.20.1.tar.gz", hash = "sha256:e7e39c865727338d434b55f1dd8da51febcffcaebd6e1a0b9c836243f660740a", size = 852860, upload-time = "2026-01-24T05:56:56.758Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/10/47caf89cbb52e5bb764696fd52a8c591a2f0e851a93270c05a17f36000b5/pymdown_extensions-10.20-py3-none-any.whl", hash = "sha256:ea9e62add865da80a271d00bfa1c0fa085b20d133fb3fc97afdc88e682f60b2f", size = 268733, upload-time = "2025-12-31T19:59:40.652Z" }, + { url = "https://files.pythonhosted.org/packages/40/6d/b6ee155462a0156b94312bdd82d2b92ea56e909740045a87ccb98bf52405/pymdown_extensions-10.20.1-py3-none-any.whl", hash = "sha256:24af7feacbca56504b313b7b418c4f5e1317bb5fea60f03d57be7fcc40912aa0", size = 268768, upload-time = "2026-01-24T05:56:54.537Z" }, ] [[package]] @@ -439,7 +439,16 @@ wheels = [ ] [[package]] -name = "safex-docs" +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "subctrl-docs" version = "0.1.0" source = { virtual = "." } dependencies = [ @@ -453,22 +462,13 @@ requires-dist = [ { name = "mkdocs-material", specifier = ">=9.7.1" }, ] -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, -] - [[package]] name = "urllib3" -version = "2.6.2" +version = "2.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" }, + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] [[package]] diff --git a/lib/application/app_dependencies.dart b/lib/application/app_dependencies.dart index 47719b9..7f29521 100644 --- a/lib/application/app_dependencies.dart +++ b/lib/application/app_dependencies.dart @@ -34,8 +34,8 @@ import 'package:subctrl/application/tags/create_tag_use_case.dart'; import 'package:subctrl/application/tags/delete_tag_use_case.dart'; import 'package:subctrl/application/tags/update_tag_use_case.dart'; import 'package:subctrl/application/tags/watch_tags_use_case.dart'; +import 'package:subctrl/infrastructure/currency/proxy_currency_client.dart'; import 'package:subctrl/infrastructure/currency/subscription_currency_rates_client.dart'; -import 'package:subctrl/infrastructure/currency/yahoo_finance_client.dart'; import 'package:subctrl/infrastructure/persistence/daos/currencies_dao.dart'; import 'package:subctrl/infrastructure/persistence/daos/currency_rates_dao.dart'; import 'package:subctrl/infrastructure/persistence/daos/settings_dao.dart'; @@ -88,8 +88,8 @@ class AppDependencies { required this.scheduleNotificationsUseCase, required this.requestNotificationPermissionUseCase, required this.openNotificationSettingsUseCase, - required YahooFinanceCurrencyClient yahooFinanceCurrencyClient, - }) : _yahooFinanceCurrencyClient = yahooFinanceCurrencyClient; + required ProxyCurrencyRatesClient proxyCurrencyRatesClient, + }) : _proxyCurrencyRatesClient = proxyCurrencyRatesClient; factory AppDependencies() { final database = AppDatabase(); @@ -107,9 +107,9 @@ class AppDependencies { ); final tagRepository = DriftTagRepository(tagsDao); final settingsRepository = DriftSettingsRepository(settingsDao); - final yahooFinanceClient = YahooFinanceCurrencyClient(); + final proxyRatesClient = ProxyCurrencyRatesClient(); final subscriptionRatesClient = SubscriptionCurrencyRatesClient( - yahooFinanceCurrencyClient: yahooFinanceClient, + proxyCurrencyClient: proxyRatesClient, currencyRepository: currencyRepository, ); final localNotificationsService = LocalNotificationsService(); @@ -182,19 +182,18 @@ class AppDependencies { getPendingNotificationsUseCase: GetPendingNotificationsUseCase( localNotificationsService, ), - cancelNotificationsUseCase: - CancelNotificationsUseCase(localNotificationsService), + cancelNotificationsUseCase: CancelNotificationsUseCase( + localNotificationsService, + ), scheduleNotificationsUseCase: ScheduleNotificationsUseCase( localNotificationsService, ), requestNotificationPermissionUseCase: - RequestNotificationPermissionUseCase( - notificationPermissionService, - ), + RequestNotificationPermissionUseCase(notificationPermissionService), openNotificationSettingsUseCase: OpenNotificationSettingsUseCase( notificationPermissionService, ), - yahooFinanceCurrencyClient: yahooFinanceClient, + proxyCurrencyRatesClient: proxyRatesClient, ); } @@ -242,9 +241,9 @@ class AppDependencies { requestNotificationPermissionUseCase; final OpenNotificationSettingsUseCase openNotificationSettingsUseCase; - final YahooFinanceCurrencyClient _yahooFinanceCurrencyClient; + final ProxyCurrencyRatesClient _proxyCurrencyRatesClient; void dispose() { - _yahooFinanceCurrencyClient.close(); + _proxyCurrencyRatesClient.close(); } } diff --git a/lib/infrastructure/currency/proxy_currency_client.dart b/lib/infrastructure/currency/proxy_currency_client.dart new file mode 100644 index 0000000..4bb3b03 --- /dev/null +++ b/lib/infrastructure/currency/proxy_currency_client.dart @@ -0,0 +1,104 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:subctrl/domain/entities/currency_rate.dart'; +import 'package:subctrl/domain/services/currency_rates_provider.dart'; + +class ProxyCurrencyRatesClient { + ProxyCurrencyRatesClient({String? baseUrl, http.Client? httpClient}) + : _baseUri = Uri.parse( + baseUrl ?? + const String.fromEnvironment( + 'SUBCTRL_RATES_URL', + defaultValue: 'https://subctrl.gonfff.com', + ), + ), + _httpClient = httpClient ?? http.Client(); + + final Uri _baseUri; + final http.Client _httpClient; + + Future> fetchRates({ + required String baseCode, + required Iterable quoteCodes, + }) async { + final normalizedBase = baseCode.toUpperCase(); + final normalizedQuotes = quoteCodes + .map((code) => code.toUpperCase()) + .where((code) => code != normalizedBase) + .toSet() + .toList(); + if (normalizedQuotes.isEmpty) { + return const []; + } + + final uri = _baseUri.replace( + path: _joinPath(_baseUri.path, '/v1/rates'), + query: _buildQuery(normalizedBase, normalizedQuotes), + ); + + final response = await _httpClient.get( + uri, + headers: const {'Accept': 'application/json'}, + ); + if (response.statusCode != 200) { + throw CurrencyRatesFetchException( + 'Proxy request failed with status ${response.statusCode}', + ); + } + + return _parseRates(response.body, normalizedBase); + } + + void close() => _httpClient.close(); + + List _parseRates(String body, String normalizedBase) { + final decoded = json.decode(body); + if (decoded is! Map) { + throw CurrencyRatesFetchException('Malformed proxy response.'); + } + final rates = decoded['rates']; + if (rates is! List) { + throw CurrencyRatesFetchException('Malformed proxy response.'); + } + final parsedRates = []; + for (final entry in rates) { + if (entry is! Map) continue; + final quote = entry['quote']; + final rate = entry['rate']; + final fetchedAtRaw = entry['fetched_at']; + if (quote is! String || rate is! num || fetchedAtRaw is! String) { + continue; + } + parsedRates.add( + CurrencyRate( + baseCode: normalizedBase, + quoteCode: quote.toUpperCase(), + rate: rate.toDouble(), + fetchedAt: DateTime.parse(fetchedAtRaw), + ), + ); + } + return parsedRates; + } + + String _joinPath(String basePath, String suffix) { + final trimmedBase = basePath.endsWith('/') + ? basePath.substring(0, basePath.length - 1) + : basePath; + if (trimmedBase.isEmpty) { + return suffix; + } + return '$trimmedBase$suffix'; + } + + String _buildQuery(String base, Iterable quotes) { + final buffer = StringBuffer('base='); + buffer.write(Uri.encodeQueryComponent(base)); + for (final quote in quotes) { + buffer.write('"es='); + buffer.write(Uri.encodeQueryComponent(quote)); + } + return buffer.toString(); + } +} diff --git a/lib/infrastructure/currency/subscription_currency_rates_client.dart b/lib/infrastructure/currency/subscription_currency_rates_client.dart index aab861a..35b00f0 100644 --- a/lib/infrastructure/currency/subscription_currency_rates_client.dart +++ b/lib/infrastructure/currency/subscription_currency_rates_client.dart @@ -2,16 +2,16 @@ import 'package:subctrl/domain/entities/currency_rate.dart'; import 'package:subctrl/domain/entities/subscription.dart'; import 'package:subctrl/domain/repositories/currency_repository.dart'; import 'package:subctrl/domain/services/currency_rates_provider.dart'; -import 'package:subctrl/infrastructure/currency/yahoo_finance_client.dart'; +import 'package:subctrl/infrastructure/currency/proxy_currency_client.dart'; class SubscriptionCurrencyRatesClient implements CurrencyRatesProvider { SubscriptionCurrencyRatesClient({ - required YahooFinanceCurrencyClient yahooFinanceCurrencyClient, + required ProxyCurrencyRatesClient proxyCurrencyClient, required CurrencyRepository currencyRepository, - }) : _yahooClient = yahooFinanceCurrencyClient, + }) : _proxyClient = proxyCurrencyClient, _currencyRepository = currencyRepository; - final YahooFinanceCurrencyClient _yahooClient; + final ProxyCurrencyRatesClient _proxyClient; final CurrencyRepository _currencyRepository; @override @@ -38,7 +38,7 @@ class SubscriptionCurrencyRatesClient implements CurrencyRatesProvider { if (quoteCodes.isEmpty) { return const []; } - return _yahooClient.fetchRates( + return _proxyClient.fetchRates( baseCode: normalizedBase, quoteCodes: quoteCodes, ); diff --git a/lib/infrastructure/currency/yahoo_finance_client.dart b/lib/infrastructure/currency/yahoo_finance_client.dart deleted file mode 100644 index 842ccc6..0000000 --- a/lib/infrastructure/currency/yahoo_finance_client.dart +++ /dev/null @@ -1,223 +0,0 @@ -import 'dart:convert'; -import 'dart:developer' as developer; - -import 'package:http/http.dart' as http; -import 'package:subctrl/domain/entities/currency_rate.dart'; -import 'package:subctrl/domain/services/currency_rates_provider.dart'; - -class YahooFinanceCurrencyClient { - YahooFinanceCurrencyClient({http.Client? httpClient}) - : _httpClient = httpClient ?? http.Client(); - - final http.Client _httpClient; - - static const _host = 'query1.finance.yahoo.com'; - static const _crumbEndpoint = '/v1/test/getcrumb'; - static const _quoteEndpoint = '/v7/finance/quote'; - - final String _userAgent = - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'; - - static String? _sharedCrumb; - static String? _sharedCookie; - static Future? _sharedSessionInitialization; - - Future _ensureSessionInitialized() async { - if (_sharedCrumb != null && _sharedCookie != null) { - return; - } - if (_sharedSessionInitialization != null) { - await _sharedSessionInitialization; - return; - } - _sharedSessionInitialization = _createSession(); - try { - await _sharedSessionInitialization; - } finally { - _sharedSessionInitialization = null; - } - } - - /// Retrieves Yahoo Finance authentication cookie and crumb. - Future _createSession() async { - try { - // First request to fc.yahoo.com to obtain the cookie. - _log('Fetching cookie from fc.yahoo.com...'); - final initResponse = await _httpClient.get( - Uri.parse('https://fc.yahoo.com'), - headers: _defaultHeaders(), - ); - _log('fc.yahoo.com response: ${initResponse.statusCode}'); - - // Extract cookie from response headers. - final setCookieHeader = initResponse.headers['set-cookie']; - if (setCookieHeader != null) { - _sharedCookie = setCookieHeader.split(';').first; - _log('Cookie extracted: ${_sharedCookie?.substring(0, 20)}...'); - } else { - _log('WARNING: No set-cookie header found'); - } - - // Fetch crumb token. - _log('Fetching crumb from query1.finance.yahoo.com...'); - final crumbResponse = await _httpClient.get( - Uri.parse('https://$_host$_crumbEndpoint'), - headers: { - ..._defaultHeaders(), - if (_sharedCookie != null) 'Cookie': _sharedCookie!, - }, - ); - _log('Crumb response: ${crumbResponse.statusCode}'); - - if (crumbResponse.statusCode == 200) { - _sharedCrumb = crumbResponse.body.trim(); - _log('Crumb received: $_sharedCrumb'); - } else { - _log( - 'ERROR: Crumb fetch failed with status ${crumbResponse.statusCode}', - ); - _log('Response body: ${crumbResponse.body}'); - throw CurrencyRatesFetchException( - 'Failed to fetch crumb: ${crumbResponse.statusCode}', - ); - } - } catch (e, stackTrace) { - _log('Exception in _fetchCrumb', error: e, stackTrace: stackTrace); - rethrow; - } - } - - Future> fetchRates({ - required String baseCode, - required Iterable quoteCodes, - }) async { - try { - _log('fetchRates called: base=$baseCode, quotes=$quoteCodes'); - - await _ensureSessionInitialized(); - - final normalizedBase = baseCode.toUpperCase(); - final normalizedQuotes = quoteCodes - .map((code) => code.toUpperCase()) - .where((code) => code != normalizedBase) - .toSet() - .toList(); - if (normalizedQuotes.isEmpty) { - _log('No quotes to fetch after normalization'); - return const []; - } - final symbolMap = { - for (final quote in normalizedQuotes) '$quote$normalizedBase=X': quote, - }; - _log('Symbol map: $symbolMap'); - - final uri = Uri.https(_host, _quoteEndpoint, { - 'symbols': symbolMap.keys.join(','), - 'region': 'US', - 'lang': 'en-US', - if (_sharedCrumb != null) 'crumb': _sharedCrumb!, - }); - _log('Request URI: $uri'); - - final response = await _httpClient.get(uri, headers: _quoteHeaders()); - _log('Response status: ${response.statusCode}'); - - if (response.statusCode != 200) { - _log('Request failed: ${response.body}'); - throw CurrencyRatesFetchException( - 'Yahoo Finance request failed with status ${response.statusCode}', - ); - } - - _log('Parsing first response...'); - return _parseRates(response.body, symbolMap, normalizedBase); - } catch (e, stackTrace) { - _log('Exception in fetchRates', error: e, stackTrace: stackTrace); - rethrow; - } - } - - /// Parses Yahoo Finance response and produces currency rates. - List _parseRates( - String responseBody, - Map symbolMap, - String normalizedBase, - ) { - _log('Parsing response body (length: ${responseBody.length})'); - final decoded = json.decode(responseBody); - if (decoded is! Map) { - _log('ERROR: Response is not a Map'); - throw CurrencyRatesFetchException('Malformed Yahoo Finance response.'); - } - final quoteResponse = - decoded['quoteResponse'] as Map? ?? const {}; - final results = quoteResponse['result'] as List? ?? const []; - _log('Found ${results.length} results in response'); - - final now = DateTime.now().toUtc(); - final rates = []; - for (final entry in results) { - if (entry is! Map) continue; - final symbol = entry['symbol'] as String?; - if (symbol == null) continue; - final quoteCode = symbolMap[symbol]; - if (quoteCode == null) { - _log('WARNING: Symbol $symbol not found in symbolMap'); - continue; - } - final priceValue = entry['regularMarketPrice']; - if (priceValue is! num) { - _log('WARNING: No valid price for $symbol'); - continue; - } - final timeValue = entry['regularMarketTime']; - final fetchedAt = timeValue is num - ? DateTime.fromMillisecondsSinceEpoch( - timeValue.toInt() * 1000, - isUtc: true, - ) - : now; - _log('Parsed rate: $quoteCode/$normalizedBase = $priceValue'); - rates.add( - CurrencyRate( - baseCode: normalizedBase, - quoteCode: quoteCode, - rate: priceValue.toDouble(), - fetchedAt: fetchedAt, - ), - ); - } - _log('Successfully parsed ${rates.length} rates'); - return rates; - } - - void close() => _httpClient.close(); - - Map _defaultHeaders() { - return { - 'User-Agent': _userAgent, - 'Accept': - 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', - 'Accept-Language': 'en-US,en;q=0.9', - 'Connection': 'keep-alive', - }; - } - - Map _quoteHeaders() { - return { - ..._defaultHeaders(), - 'Accept': 'application/json, text/plain, */*', - if (_sharedCookie != null) 'Cookie': _sharedCookie!, - if (_sharedCrumb != null) 'x-yahoo-request-id': _sharedCrumb!, - }; - } - - void _log(String message, {Object? error, StackTrace? stackTrace}) { - developer.log( - message, - name: 'YahooFinance', - error: error, - stackTrace: stackTrace, - ); - } -} diff --git a/lib/infrastructure/persistence/seeds/currency_seed_data.dart b/lib/infrastructure/persistence/seeds/currency_seed_data.dart index 096abfe..cb9ae09 100644 --- a/lib/infrastructure/persistence/seeds/currency_seed_data.dart +++ b/lib/infrastructure/persistence/seeds/currency_seed_data.dart @@ -6,7 +6,6 @@ class CurrencySeed { final String? symbol; } -/// Built-in currencies are limited to the codes supported by Yahoo Finance FX. const List currencySeeds = [ CurrencySeed(code: 'AED', name: 'United Arab Emirates Dirham', symbol: 'د.إ'), CurrencySeed(code: 'ARS', name: 'Argentine Peso', symbol: r'$'), diff --git a/test/infrastructure/currency/proxy_currency_client_test.dart b/test/infrastructure/currency/proxy_currency_client_test.dart new file mode 100644 index 0000000..fbedcdf --- /dev/null +++ b/test/infrastructure/currency/proxy_currency_client_test.dart @@ -0,0 +1,59 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:mocktail/mocktail.dart'; +import 'package:subctrl/infrastructure/currency/proxy_currency_client.dart'; + +class _MockHttpClient extends Mock implements http.Client {} + +void main() { + setUpAll(() { + registerFallbackValue(Uri.parse('https://example.com')); + }); + + late _MockHttpClient httpClient; + late ProxyCurrencyRatesClient client; + + setUp(() { + httpClient = _MockHttpClient(); + client = ProxyCurrencyRatesClient( + baseUrl: 'https://proxy.example.com', + httpClient: httpClient, + ); + }); + + tearDown(() { + client.close(); + }); + + test('fetchRates returns parsed rates from proxy response', () async { + final body = jsonEncode({ + 'base': 'USD', + 'rates': [ + {'quote': 'EUR', 'rate': 1.1, 'fetched_at': '2026-01-23T12:00:00Z'}, + ], + 'as_of': '2026-01-23T12:00:05Z', + }); + when( + () => httpClient.get(any(), headers: any(named: 'headers')), + ).thenAnswer((_) async => http.Response(body, 200)); + + final rates = await client.fetchRates( + baseCode: 'usd', + quoteCodes: const ['eur'], + ); + + expect(rates, hasLength(1)); + expect(rates.first.baseCode, 'USD'); + expect(rates.first.quoteCode, 'EUR'); + final captured = verify( + () => httpClient.get(captureAny(), headers: any(named: 'headers')), + ).captured; + expect(captured, hasLength(1)); + final requestUri = captured.first as Uri; + expect(requestUri.path, '/v1/rates'); + expect(requestUri.queryParameters['base'], 'USD'); + expect(requestUri.queryParametersAll['quotes'], ['EUR']); + }); +} diff --git a/test/infrastructure/currency/subscription_currency_rates_client_test.dart b/test/infrastructure/currency/subscription_currency_rates_client_test.dart index b6d9711..f6c4659 100644 --- a/test/infrastructure/currency/subscription_currency_rates_client_test.dart +++ b/test/infrastructure/currency/subscription_currency_rates_client_test.dart @@ -4,17 +4,17 @@ import 'package:subctrl/domain/entities/currency.dart'; import 'package:subctrl/domain/entities/currency_rate.dart'; import 'package:subctrl/domain/entities/subscription.dart'; import 'package:subctrl/domain/repositories/currency_repository.dart'; +import 'package:subctrl/infrastructure/currency/proxy_currency_client.dart'; import 'package:subctrl/infrastructure/currency/subscription_currency_rates_client.dart'; -import 'package:subctrl/infrastructure/currency/yahoo_finance_client.dart'; -class _MockYahooFinanceCurrencyClient extends Mock - implements YahooFinanceCurrencyClient {} +class _MockProxyCurrencyRatesClient extends Mock + implements ProxyCurrencyRatesClient {} class _MockCurrencyRepository extends Mock implements CurrencyRepository {} void main() { late SubscriptionCurrencyRatesClient client; - late _MockYahooFinanceCurrencyClient yahooClient; + late _MockProxyCurrencyRatesClient proxyClient; late _MockCurrencyRepository currencyRepository; setUpAll(() { @@ -22,74 +22,67 @@ void main() { }); setUp(() { - yahooClient = _MockYahooFinanceCurrencyClient(); + proxyClient = _MockProxyCurrencyRatesClient(); currencyRepository = _MockCurrencyRepository(); client = SubscriptionCurrencyRatesClient( - yahooFinanceCurrencyClient: yahooClient, + proxyCurrencyClient: proxyClient, currencyRepository: currencyRepository, ); }); - test( - 'fetchRates seeds currencies before requesting Yahoo Finance data', - () async { - when(currencyRepository.seedIfEmpty).thenAnswer((_) async {}); - when( - () => currencyRepository.getCurrencies(onlyEnabled: true), - ).thenAnswer( - (_) async => const [ - Currency( - code: 'USD', - name: 'US Dollar', - symbol: r'$', - isEnabled: true, - isCustom: false, - ), - Currency( - code: 'EUR', - name: 'Euro', - symbol: '€', - isEnabled: true, - isCustom: false, - ), - ], - ); - final expectedRates = [ - CurrencyRate( - baseCode: 'USD', - quoteCode: 'EUR', - rate: 1.12, - fetchedAt: DateTime.utc(2024, 1, 1), + test('fetchRates seeds currencies before requesting Finance data', () async { + when(currencyRepository.seedIfEmpty).thenAnswer((_) async {}); + when(() => currencyRepository.getCurrencies(onlyEnabled: true)).thenAnswer( + (_) async => const [ + Currency( + code: 'USD', + name: 'US Dollar', + symbol: r'$', + isEnabled: true, + isCustom: false, ), - ]; - when( - () => yahooClient.fetchRates( - baseCode: any(named: 'baseCode'), - quoteCodes: any(named: 'quoteCodes'), + Currency( + code: 'EUR', + name: 'Euro', + symbol: '€', + isEnabled: true, + isCustom: false, ), - ).thenAnswer((_) async => expectedRates); + ], + ); + final expectedRates = [ + CurrencyRate( + baseCode: 'USD', + quoteCode: 'EUR', + rate: 1.12, + fetchedAt: DateTime.utc(2024, 1, 1), + ), + ]; + when( + () => proxyClient.fetchRates( + baseCode: any(named: 'baseCode'), + quoteCodes: any(named: 'quoteCodes'), + ), + ).thenAnswer((_) async => expectedRates); - final result = await client.fetchRates( - baseCurrencyCode: 'usd', - subscriptions: [ - Subscription( - name: 'Test', - amount: 10, - currency: 'eur', - cycle: BillingCycle.monthly, - purchaseDate: DateTime(2024, 1, 1), - ), - ], - ); + final result = await client.fetchRates( + baseCurrencyCode: 'usd', + subscriptions: [ + Subscription( + name: 'Test', + amount: 10, + currency: 'eur', + cycle: BillingCycle.monthly, + purchaseDate: DateTime(2024, 1, 1), + ), + ], + ); - expect(result, equals(expectedRates)); - verify(currencyRepository.seedIfEmpty).called(1); - verify( - () => currencyRepository.getCurrencies(onlyEnabled: true), - ).called(1); - verify( - () => yahooClient.fetchRates(baseCode: 'USD', quoteCodes: {'EUR'}), - ).called(1); - }, - ); + expect(result, equals(expectedRates)); + verify(currencyRepository.seedIfEmpty).called(1); + verify(() => currencyRepository.getCurrencies(onlyEnabled: true)).called(1); + verify( + () => proxyClient.fetchRates(baseCode: 'USD', quoteCodes: {'EUR'}), + ).called(1); + }); } diff --git a/test/infrastructure/currency/yahoo_finance_client_test.dart b/test/infrastructure/currency/yahoo_finance_client_test.dart deleted file mode 100644 index 2a9f014..0000000 --- a/test/infrastructure/currency/yahoo_finance_client_test.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:http/http.dart' as http; -import 'package:mocktail/mocktail.dart'; -import 'package:subctrl/infrastructure/currency/yahoo_finance_client.dart'; - -class _MockHttpClient extends Mock implements http.Client {} - -void main() { - setUpAll(() { - registerFallbackValue(Uri.parse('https://example.com')); - }); - - late _MockHttpClient httpClient; - late YahooFinanceCurrencyClient client; - - setUp(() { - httpClient = _MockHttpClient(); - client = YahooFinanceCurrencyClient(httpClient: httpClient); - }); - - tearDown(() { - client.close(); - }); - - test('fetchRates returns parsed rates from Yahoo response', () async { - when( - () => httpClient.get(any(), headers: any(named: 'headers')), - ).thenAnswer((invocation) async { - final uri = invocation.positionalArguments.first as Uri; - if (uri.host == 'fc.yahoo.com') { - return http.Response( - '', - 200, - headers: {'set-cookie': 'session=ABCDEFGHIJKLMNOPQRST; Path=/'}, - ); - } - if (uri.path == '/v1/test/getcrumb') { - return http.Response('crumb-token', 200); - } - final body = jsonEncode({ - 'quoteResponse': { - 'result': [ - { - 'symbol': 'EURUSD=X', - 'regularMarketPrice': 1.1, - 'regularMarketTime': 1700000000, - }, - ], - }, - }); - return http.Response(body, 200); - }); - - final rates = await client.fetchRates( - baseCode: 'usd', - quoteCodes: const ['eur'], - ); - - expect(rates, hasLength(1)); - expect(rates.first.baseCode, 'USD'); - expect(rates.first.quoteCode, 'EUR'); - verify( - () => httpClient.get(any(), headers: any(named: 'headers')), - ).called(greaterThanOrEqualTo(3)); - }); -}