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.
-## 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));
- });
-}