diff --git a/.github/workflows/deploy-prod.yaml b/.github/workflows/deploy-prod.yaml index 3591be9..dbc419d 100644 --- a/.github/workflows/deploy-prod.yaml +++ b/.github/workflows/deploy-prod.yaml @@ -99,6 +99,7 @@ jobs: SMTP_USE_TLS: ${{ secrets.SMTP_USE_TLS }} SMTP_USE_SSL: ${{ secrets.SMTP_USE_SSL }} SMTP_FROM: ${{ secrets.SMTP_FROM }} + UNIRATE_API_KEY: ${{ secrets.UNIRATE_API_KEY }} run: | helm upgrade --install myapp ./7project/charts/myapp-chart \ -n prod --create-namespace \ @@ -125,4 +126,5 @@ jobs: --set-string smtp.password="$SMTP_PASSWORD" \ --set-string smtp.tls="$SMTP_USE_TLS" \ --set-string smtp.ssl="$SMTP_USE_SSL" \ - --set-string smtp.from="$SMTP_FROM" \ No newline at end of file + --set-string smtp.from="$SMTP_FROM" \ + --set-string unirate.key="$UNIRATE_API_KEY" \ No newline at end of file diff --git a/7project/backend/app/api/exchange_rates.py b/7project/backend/app/api/exchange_rates.py new file mode 100644 index 0000000..d8038b1 --- /dev/null +++ b/7project/backend/app/api/exchange_rates.py @@ -0,0 +1,66 @@ +import os +from typing import List + +import httpx +from fastapi import APIRouter, HTTPException, Query, status + +router = APIRouter(prefix="/exchange-rates", tags=["exchange-rates"]) + + +@router.get("/", status_code=status.HTTP_200_OK) +async def get_exchange_rates(symbols: str = Query("EUR,USD,NOK", description="Comma-separated currency codes to fetch vs CZK")): + """ + Fetch exchange rates from UniRate API on the backend and return CZK-per-target rates. + - Always requests CZK in addition to requested symbols to compute conversion from USD-base. + - Returns a list of {currencyCode, rate} where rate is CZK per 1 unit of the target currency. + """ + api_key = os.getenv("UNIRATE_API_KEY") + if not api_key: + raise HTTPException(status_code=500, detail="Server is not configured with UNIRATE_API_KEY") + + # Ensure CZK is included for conversion + requested = [s.strip().upper() for s in symbols.split(",") if s.strip()] + if "CZK" not in requested: + requested.append("CZK") + query_symbols = ",".join(sorted(set(requested))) + + url = f"https://unirateapi.com/api/rates?api_key={api_key}&symbols={query_symbols}" + + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(15.0)) as client: + resp = await client.get(url) + if resp.status_code != httpx.codes.OK: + raise HTTPException(status_code=502, detail=f"Upstream UniRate error: HTTP {resp.status_code}") + data = resp.json() + except httpx.HTTPError as e: + raise HTTPException(status_code=502, detail=f"Failed to contact UniRate: {str(e)}") + + # Validate response structure + rates = data.get("rates") if isinstance(data, dict) else None + base = data.get("base") if isinstance(data, dict) else None + if not rates or base != "USD" or "CZK" not in rates: + # Prefer upstream message when available + detail = data.get("message") if isinstance(data, dict) else None + if not detail and isinstance(data, dict): + err = data.get("error") + if isinstance(err, dict): + detail = err.get("info") + raise HTTPException(status_code=502, detail=detail or "Invalid response from UniRate API") + + czk_per_usd = rates["CZK"] + + # Build result excluding CZK itself + result = [] + for code in requested: + if code == "CZK": + continue + target_per_usd = rates.get(code) + if target_per_usd in (None, 0): + # Skip unavailable or invalid + continue + czk_per_target = czk_per_usd / target_per_usd + result.append({"currencyCode": code, "rate": czk_per_target}) + + return result + + diff --git a/7project/backend/app/app.py b/7project/backend/app/app.py index 4ea49f1..f312bb7 100644 --- a/7project/backend/app/app.py +++ b/7project/backend/app/app.py @@ -21,6 +21,7 @@ from app.api.auth import router as auth_router from app.api.csas import router as csas_router from app.api.categories import router as categories_router from app.api.transactions import router as transactions_router +from app.api.exchange_rates import router as exchange_rates_router from app.services.user_service import auth_backend, current_active_verified_user, fastapi_users, get_oauth_provider, \ UserManager, get_jwt_strategy from app.core.security import extract_bearer_token, is_token_revoked, decode_and_verify_jwt @@ -74,6 +75,7 @@ if not os.getenv("PYTEST_RUN_CONFIG"): fastApi.include_router(auth_router) fastApi.include_router(categories_router) fastApi.include_router(transactions_router) +fastApi.include_router(exchange_rates_router) for h in list(logging.root.handlers): logging.root.removeHandler(h) diff --git a/7project/charts/myapp-chart/templates/app-deployment.yaml b/7project/charts/myapp-chart/templates/app-deployment.yaml index 876e6f9..3c41c05 100644 --- a/7project/charts/myapp-chart/templates/app-deployment.yaml +++ b/7project/charts/myapp-chart/templates/app-deployment.yaml @@ -90,6 +90,11 @@ spec: secretKeyRef: name: prod key: CSAS_CLIENT_SECRET + - name: UNIRATE_API_KEY + valueFrom: + secretKeyRef: + name: prod + key: UNIRATE_API_KEY - name: DOMAIN value: {{ required "Set .Values.domain" .Values.domain | quote }} - name: DOMAIN_SCHEME diff --git a/7project/charts/myapp-chart/templates/prod.yaml b/7project/charts/myapp-chart/templates/prod.yaml index 01f76ef..ba0df7a 100644 --- a/7project/charts/myapp-chart/templates/prod.yaml +++ b/7project/charts/myapp-chart/templates/prod.yaml @@ -26,3 +26,4 @@ stringData: SMTP_USE_TLS: {{ .Values.smtp.tls | default false | quote }} SMTP_USE_SSL: {{ .Values.smtp.ssl | default false | quote }} SMTP_FROM: {{ .Values.smtp.from | default "" | quote }} + UNIRATE_API_KEY: {{ .Values.unirate.key | default "" | quote }} diff --git a/7project/charts/myapp-chart/values.yaml b/7project/charts/myapp-chart/values.yaml index cbffb51..f3c2dc5 100644 --- a/7project/charts/myapp-chart/values.yaml +++ b/7project/charts/myapp-chart/values.yaml @@ -13,6 +13,9 @@ deployment: "" domain: "" domain_scheme: "" +unirate: + key: "" + frontend_domain: "" frontend_domain_scheme: "" diff --git a/7project/frontend/src/pages/Dashboard.tsx b/7project/frontend/src/pages/Dashboard.tsx index 4aa2166..83bf582 100644 --- a/7project/frontend/src/pages/Dashboard.tsx +++ b/7project/frontend/src/pages/Dashboard.tsx @@ -6,7 +6,7 @@ import BalanceChart from './BalanceChart'; import ManualManagement from './ManualManagement'; import CategoryPieChart from './CategoryPieChart'; import MockBankModal, { type MockGenerationOptions } from './MockBankModal'; -import { BACKEND_URL, VITE_UNIRATE_API_KEY } from '../config'; +import { BACKEND_URL } from '../config'; function formatAmount(n: number) { return new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n); @@ -45,49 +45,20 @@ function CurrencyRates() { setLoading(true); setError(null); - const API_KEY = VITE_UNIRATE_API_KEY; - - // We need to get the CZK rate as well, to use it for conversion - const allSymbols = [...TARGET_CURRENCIES, 'CZK'].join(','); - - // We remove the `base` param, as the API seems to force base=USD - const UNIRATE_API_URL = `https://unirateapi.com/api/rates?api_key=${API_KEY}&symbols=${allSymbols}`; - try { - const res = await fetch(UNIRATE_API_URL); - const data: UnirateApiResponse = await res.json(); - - // --- THIS IS THE NEW, CORRECTED LOGIC --- - - // 1. Check if the 'rates' object exists. If not, it's an error. - if (!data.rates) { - let errorMessage = data.message || (data.error ? data.error.info : 'Invalid API response'); - throw new Error(errorMessage || 'Could not load rates'); - } - - // 2. Check that we got the base currency (USD) and our conversion currency (CZK) - if (data.base !== 'USD' || !data.rates.CZK) { - throw new Error('API response is missing required data for conversion (USD or CZK)'); - } - - // 3. Get our main conversion factor - const czkPerUsd = data.rates.CZK; // e.g., 23.0 - - // 4. Calculate the rates for our target currencies - const formattedRates = TARGET_CURRENCIES.map(code => { - const targetPerUsd = data.rates[code]; // e.g., 0.9 for EUR - - // This calculates: (CZK per USD) / (TARGET per USD) = CZK per TARGET - // e.g. (23.0 CZK / 1 USD) / (0.9 EUR / 1 USD) = 25.55 CZK / 1 EUR - const rate = czkPerUsd / targetPerUsd; - - return { - currencyCode: code, - rate: rate, - }; + const base = BACKEND_URL.replace(/\/$/, ''); + const url = `${base}/exchange-rates?symbols=${TARGET_CURRENCIES.join(',')}`; + const token = localStorage.getItem('token'); + const res = await fetch(url, { + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + credentials: 'include', }); - - setRates(formattedRates); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `Failed to load rates (${res.status})`); + } + const data: RateData[] = await res.json(); + setRates(data); } catch (err: any) { setError(err.message || 'Could not load rates'); } finally {