Compare commits

32 Commits

Author SHA1 Message Date
d6a913a896 feat(worker): add transaction saving to db 2025-10-29 18:11:53 +01:00
d8ea25943c feat(code): remove sentry debug endpoint 2025-10-29 14:32:25 +01:00
06dcccb321 fix(tests): add missing dependencies 2025-10-29 14:28:25 +01:00
e916a57e4e fix(tests): move requirements.txt 2025-10-29 14:25:18 +01:00
7d2e94e683 feat(database): add encryption key 2025-10-29 14:23:14 +01:00
3348e0a035 feat(database): encrypt transactions data 2025-10-29 14:17:53 +01:00
Dejan Ribarovski
4f6d46ba7e Merge pull request #37 from dat515-2025/36-add-mock-databases-or-services-to-fetch-mocked-transactions
Some checks failed
Deploy Prod / Run Python Tests (push) Has been cancelled
Deploy Prod / Build and push image (reusable) (push) Has been cancelled
Deploy Prod / Generate Production URLs (push) Has been cancelled
Run Python Tests / build-and-test (push) Has been cancelled
Deploy Prod / Frontend - Build and Deploy to Cloudflare Pages (prod) (push) Has been cancelled
Deploy Prod / Helm upgrade/install (prod) (push) Has been cancelled
feat(frontend): Added Mock Bank connection
2025-10-23 13:02:32 +02:00
Dejan Ribarovski
9fc8601e4d Update 7project/frontend/src/pages/Dashboard.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-23 12:53:39 +02:00
Dejan Ribarovski
e488771cc7 Update 7project/frontend/src/pages/MockBankModal.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-23 12:53:04 +02:00
ribardej
77992bab17 feat(docs): meeting update 2025-10-23 12:47:27 +02:00
ribardej
6972a03090 feat(frontend): Added Mock Bank connection 2025-10-23 12:08:28 +02:00
Dejan Ribarovski
6d7f834808 Merge pull request #35 from dat515-2025/34-improve-frontend-functionality
Some checks are pending
Deploy Prod / Run Python Tests (push) Waiting to run
Deploy Prod / Build and push image (reusable) (push) Waiting to run
Deploy Prod / Generate Production URLs (push) Waiting to run
Deploy Prod / Frontend - Build and Deploy to Cloudflare Pages (prod) (push) Blocked by required conditions
Deploy Prod / Helm upgrade/install (prod) (push) Blocked by required conditions
Run Python Tests / build-and-test (push) Waiting to run
34 improve frontend functionality
2025-10-23 10:02:57 +02:00
ribardej
d5611e3e92 fix(frontend): Fixed type error 2025-10-23 09:57:15 +02:00
ribardej
5ecfc62b02 feat(frontend): improved UI 2025-10-23 09:22:10 +02:00
d0cbec5fca update report
Some checks are pending
Run Python Tests / build-and-test (push) Waiting to run
2025-10-22 21:52:14 +02:00
ribardej
82eb34c6e6 feat(frontend): improved Dashboard.tsx, added transaction date 2025-10-22 17:37:11 +02:00
Dejan Ribarovski
cddc1d3a9f Merge pull request #31 from dat515-2025/30-create-tests-and-set-up-a-github-pipeline
Some checks are pending
Deploy Prod / Run Python Tests (push) Waiting to run
Deploy Prod / Build and push image (reusable) (push) Waiting to run
Deploy Prod / Generate Production URLs (push) Waiting to run
Deploy Prod / Frontend - Build and Deploy to Cloudflare Pages (prod) (push) Blocked by required conditions
Deploy Prod / Helm upgrade/install (prod) (push) Blocked by required conditions
Run Python Tests / build-and-test (push) Waiting to run
30 create tests and set up a GitHub pipeline
2025-10-22 14:59:54 +02:00
ribardej
e78b8c2e6b fix(tests): fixed a service test and one warning regarding 422 status
Some checks failed
Run Python Tests / build-and-test (push) Has been cancelled
2025-10-22 14:53:45 +02:00
ribardej
aade88beb9 fix(backend): added a default value for FRONTEND_DOMAIN_SCHEME so the tests dont crash 2025-10-22 14:48:56 +02:00
Dejan Ribarovski
5305531950 Merge branch 'main' into 30-create-tests-and-set-up-a-github-pipeline 2025-10-22 14:39:28 +02:00
ribardej
6d8a6a55c0 fix(backend): refactored app to fastApi to avoid CORS errors 2025-10-22 14:37:48 +02:00
396047574a fix(auth): increase timeout
Some checks are pending
Deploy Prod / Build and push image (reusable) (push) Waiting to run
Deploy Prod / Generate Production URLs (push) Waiting to run
Deploy Prod / Frontend - Build and Deploy to Cloudflare Pages (prod) (push) Blocked by required conditions
Deploy Prod / Helm upgrade/install (prod) (push) Blocked by required conditions
2025-10-21 22:43:30 +02:00
d926168ef9 fix(oauth): use prod MojeID 2025-10-21 22:24:39 +02:00
41956b8e0c Merge pull request #32 from dat515-2025/merge/fix_react_oauth
feat(oauth): add csas connection, allow oauth from react
2025-10-21 22:13:54 +02:00
9734895758 feat(oauth): add to env 2025-10-21 22:11:32 +02:00
91a32b2f10 feat(oauth): add to env 2025-10-21 22:08:00 +02:00
2b640fc6ac Update 7project/backend/app/oauth/csas.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-21 22:03:13 +02:00
3ebf47e371 feat(oauth): add csas connection, allow oauth from react 2025-10-21 22:01:09 +02:00
Dejan Ribarovski
40d07677bd Potential fix for code scanning alert no. 11: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-10-21 15:57:23 +02:00
ribardej
76eb2cce41 feat(tests): Added tests to PR and Prod workflows 2025-10-21 15:44:33 +02:00
ribardej
391e9da0c4 feat(tests): Implemented basic tests and github workflow 2025-10-21 15:36:49 +02:00
Dejan Ribarovski
be4a3b401a Merge pull request #28 from dat515-2025/merge/frontend_basics
Some checks are pending
Deploy Prod / Build and push image (reusable) (push) Waiting to run
Deploy Prod / Generate Production URLs (push) Waiting to run
Deploy Prod / Frontend - Build and Deploy to Cloudflare Pages (prod) (push) Blocked by required conditions
Deploy Prod / Helm upgrade/install (prod) (push) Blocked by required conditions
Merge/frontend basics
2025-10-21 13:34:53 +02:00
46 changed files with 2051 additions and 97 deletions

View File

@@ -9,6 +9,29 @@ permissions:
pull-requests: write pull-requests: write
jobs: jobs:
test:
name: Run Python Tests
if: github.event.action != 'closed'
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run tests with pytest
run: pytest
working-directory: ./7project/backend
build: build:
if: github.event.action != 'closed' if: github.event.action != 'closed'
name: Build and push image (reusable) name: Build and push image (reusable)
@@ -95,7 +118,8 @@ jobs:
--set frontend_domain_scheme="$FRONTEND_DOMAIN_SCHEME" \ --set frontend_domain_scheme="$FRONTEND_DOMAIN_SCHEME" \
--set image.digest="$DIGEST" \ --set image.digest="$DIGEST" \
--set-string rabbitmq.password="$RABBITMQ_PASSWORD" \ --set-string rabbitmq.password="$RABBITMQ_PASSWORD" \
--set-string database.password="$DB_PASSWORD" --set-string database.password="$DB_PASSWORD" \
--set-string database.encryptionSecret="$PR"
- name: Post preview URLs as PR comment - name: Post preview URLs as PR comment
uses: actions/github-script@v7 uses: actions/github-script@v7

View File

@@ -21,6 +21,29 @@ concurrency:
cancel-in-progress: false cancel-in-progress: false
jobs: jobs:
test:
name: Run Python Tests
if: github.event.action != 'closed'
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run tests with pytest
run: pytest
working-directory: ./7project/backend
build: build:
name: Build and push image (reusable) name: Build and push image (reusable)
uses: ./.github/workflows/build-image.yaml uses: ./.github/workflows/build-image.yaml
@@ -85,6 +108,8 @@ jobs:
BANKID_CLIENT_SECRET: ${{ secrets.BANKID_CLIENT_SECRET }} BANKID_CLIENT_SECRET: ${{ secrets.BANKID_CLIENT_SECRET }}
MOJEID_CLIENT_ID: ${{ secrets.MOJEID_CLIENT_ID }} MOJEID_CLIENT_ID: ${{ secrets.MOJEID_CLIENT_ID }}
MOJEID_CLIENT_SECRET: ${{ secrets.MOJEID_CLIENT_SECRET }} MOJEID_CLIENT_SECRET: ${{ secrets.MOJEID_CLIENT_SECRET }}
CSAS_CLIENT_ID: ${{ secrets.CSAS_CLIENT_ID }}
CSAS_CLIENT_SECRET: ${{ secrets.CSAS_CLIENT_SECRET }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
run: | run: |
helm upgrade --install myapp ./7project/charts/myapp-chart \ helm upgrade --install myapp ./7project/charts/myapp-chart \
@@ -102,4 +127,7 @@ jobs:
--set-string oauth.bankid.clientSecret="$BANKID_CLIENT_SECRET" \ --set-string oauth.bankid.clientSecret="$BANKID_CLIENT_SECRET" \
--set-string oauth.mojeid.clientId="$MOJEID_CLIENT_ID" \ --set-string oauth.mojeid.clientId="$MOJEID_CLIENT_ID" \
--set-string oauth.mojeid.clientSecret="$MOJEID_CLIENT_SECRET" \ --set-string oauth.mojeid.clientSecret="$MOJEID_CLIENT_SECRET" \
--set-string sentry_dsn="$SENTRY_DSN" \ --set-string oauth.csas.clientId="$CSAS_CLIENT_ID" \
--set-string oauth.csas.clientSecret="$CSAS_CLIENT_SECRET" \
--set-string sentry_dsn="$SENTRY_DSN" \
--set-string database.encryptionSecret="${{ secrets.PROD_DB_ENCRYPTION_KEY }}"

60
.github/workflows/run-tests.yml vendored Normal file
View File

@@ -0,0 +1,60 @@
name: Run Python Tests
permissions:
contents: read
# -----------------
# --- Triggers ----
# -----------------
# This section defines when the workflow will run.
on:
# Run on every push to the 'main' branch
push:
branches: [ "main", "30-create-tests-and-set-up-a-github-pipeline" ]
# Also run on every pull request that targets the 'main' branch
pull_request:
branches: [ "main" ]
# -----------------
# ------ Jobs -----
# -----------------
# A workflow is made up of one or more jobs that can run in parallel or sequentially.
jobs:
# A descriptive name for your job
build-and-test:
# Specifies the virtual machine to run the job on. 'ubuntu-latest' is a common and cost-effective choice.
runs-on: ubuntu-latest
# -----------------
# ----- Steps -----
# -----------------
# A sequence of tasks that will be executed as part of the job.
steps:
# Step 1: Check out your repository's code
# This action allows the workflow to access your code.
- name: Check out repository code
uses: actions/checkout@v4
# Step 2: Set up the Python environment
# This action installs a specific version of Python on the runner.
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11' # Use the Python version that matches your project
# Step 3: Install project dependencies
# Runs shell commands to install the libraries listed in your requirements.txt.
- name: Add test dependencies to requirements
run: |
echo "pytest==8.4.2" >> ./7project/backend/requirements.txt
echo "pytest-asyncio==1.2.0" >> ./7project/backend/requirements.txt
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r ./7project/backend/requirements.txt
# Step 4: Run your tests!
# Executes the pytest command to run your test suite.
- name: Run tests with pytest
run: pytest
working-directory: ./7project/backend

View File

@@ -0,0 +1,32 @@
"""add config to user
Revision ID: eabec90a94fe
Revises: 5ab2e654c96e
Create Date: 2025-10-21 18:56:42.085973
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'eabec90a94fe'
down_revision: Union[str, Sequence[str], None] = '5ab2e654c96e'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('user', sa.Column('config', sa.JSON(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('user', 'config')
# ### end Alembic commands ###

View File

@@ -0,0 +1,32 @@
"""add date to transaction
Revision ID: 1f2a3c4d5e6f
Revises: eabec90a94fe
Create Date: 2025-10-22 16:18:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import func
# revision identifiers, used by Alembic.
revision: str = '1f2a3c4d5e6f'
down_revision: Union[str, Sequence[str], None] = 'eabec90a94fe'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema by adding date column with server default current_date."""
op.add_column(
'transaction',
sa.Column('date', sa.Date(), nullable=False, server_default=sa.text('CURRENT_DATE'))
)
def downgrade() -> None:
"""Downgrade schema by removing date column."""
op.drop_column('transaction', 'date')

View File

@@ -0,0 +1,47 @@
"""Add encrypted type
Revision ID: 46b9e702e83f
Revises: 1f2a3c4d5e6f
Create Date: 2025-10-29 13:26:24.568523
"""
from typing import Sequence, Union
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = '46b9e702e83f'
down_revision: Union[str, Sequence[str], None] = '1f2a3c4d5e6f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('transaction', 'amount',
existing_type=mysql.FLOAT(),
type_=sqlalchemy_utils.types.encrypted.encrypted_type.EncryptedType(),
existing_nullable=False)
op.alter_column('transaction', 'description',
existing_type=mysql.VARCHAR(length=255),
type_=sqlalchemy_utils.types.encrypted.encrypted_type.EncryptedType(),
existing_nullable=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('transaction', 'description',
existing_type=sqlalchemy_utils.types.encrypted.encrypted_type.EncryptedType(),
type_=mysql.VARCHAR(length=255),
existing_nullable=True)
op.alter_column('transaction', 'amount',
existing_type=sqlalchemy_utils.types.encrypted.encrypted_type.EncryptedType(),
type_=mysql.FLOAT(),
existing_nullable=False)
# ### end Alembic commands ###

View File

@@ -5,7 +5,7 @@ from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.categories import Category from app.models.categories import Category
from app.schemas.category import CategoryCreate, CategoryRead from app.schemas.category import CategoryCreate, CategoryRead, CategoryUpdate
from app.services.db import get_async_session from app.services.db import get_async_session
from app.services.user_service import current_active_user from app.services.user_service import current_active_user
from app.models.user import User from app.models.user import User
@@ -43,6 +43,37 @@ async def list_categories(
return list(res.scalars()) return list(res.scalars())
@router.patch("/{category_id}", response_model=CategoryRead)
async def update_category(
category_id: int,
payload: CategoryUpdate,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
res = await session.execute(
select(Category).where(Category.id == category_id, Category.user_id == user.id)
)
category = res.scalar_one_or_none()
if not category:
raise HTTPException(status_code=404, detail="Category not found")
# If name changed, check uniqueness per user
if payload.name is not None and payload.name != category.name:
dup = await session.execute(
select(Category.id).where(Category.user_id == user.id, Category.name == payload.name)
)
if dup.scalar_one_or_none() is not None:
raise HTTPException(status_code=409, detail="Category with this name already exists")
category.name = payload.name
if payload.description is not None:
category.description = payload.description
await session.commit()
await session.refresh(category)
return category
@router.get("/{category_id}", response_model=CategoryRead) @router.get("/{category_id}", response_model=CategoryRead)
async def get_category( async def get_category(
category_id: int, category_id: int,

View File

@@ -0,0 +1,40 @@
import json
import os
from fastapi import APIRouter
from fastapi.params import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.oauth.csas import CSASOAuth
from app.services.db import get_async_session
from app.services.user_service import current_active_user
router = APIRouter(prefix="/auth/csas", tags=["csas"])
CLIENT_ID = os.getenv("CSAS_CLIENT_ID")
CLIENT_SECRET = os.getenv("CSAS_CLIENT_SECRET")
CSAS_OAUTH = CSASOAuth(CLIENT_ID, CLIENT_SECRET)
@router.get("/authorize")
async def csas_authorize():
return {"authorization_url":
await CSAS_OAUTH.get_authorization_url(os.getenv("FRONTEND_DOMAIN_SCHEME") + "/auth/csas/callback")}
@router.get("/callback")
async def csas_callback(code: str, session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user)):
response = await CSAS_OAUTH.get_access_token(code, os.getenv("FRONTEND_DOMAIN_SCHEME") + "/auth/csas/callback")
if not user.config:
user.config = {}
new_dict = user.config.copy()
new_dict["csas"] = json.dumps(response)
user.config = new_dict
await session.commit()
return "OK"

View File

@@ -1,7 +1,8 @@
from typing import List, Optional from typing import List, Optional
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select from sqlalchemy import select, and_, func
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.transaction import Transaction from app.models.transaction import Transaction
@@ -23,6 +24,7 @@ def _to_read_model(tx: Transaction) -> TransactionRead:
id=tx.id, id=tx.id,
amount=tx.amount, amount=tx.amount,
description=tx.description, description=tx.description,
date=tx.date,
category_ids=[c.id for c in (tx.categories or [])], category_ids=[c.id for c in (tx.categories or [])],
) )
@@ -33,7 +35,21 @@ async def create_transaction(
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), user: User = Depends(current_active_user),
): ):
tx = Transaction(amount=payload.amount, description=payload.description, user_id=user.id) # Build transaction; set `date` only if provided to let DB default apply otherwise
tx_kwargs = dict(
amount=payload.amount,
description=payload.description,
user_id=user.id,
)
if payload.date is not None:
parsed_date = payload.date
if isinstance(parsed_date, str):
try:
parsed_date = date.fromisoformat(parsed_date)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format, expected YYYY-MM-DD")
tx_kwargs["date"] = parsed_date
tx = Transaction(**tx_kwargs)
# Attach categories if provided (and owned by user) # Attach categories if provided (and owned by user)
if payload.category_ids: if payload.category_ids:
@@ -60,11 +76,18 @@ async def create_transaction(
@router.get("/", response_model=List[TransactionRead]) @router.get("/", response_model=List[TransactionRead])
async def list_transactions( async def list_transactions(
start_date: Optional[date] = None,
end_date: Optional[date] = None,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), user: User = Depends(current_active_user),
): ):
cond = [Transaction.user_id == user.id]
if start_date is not None:
cond.append(Transaction.date >= start_date)
if end_date is not None:
cond.append(Transaction.date <= end_date)
res = await session.execute( res = await session.execute(
select(Transaction).where(Transaction.user_id == user.id).order_by(Transaction.id) select(Transaction).where(and_(*cond)).order_by(Transaction.date, Transaction.id)
) )
txs = list(res.scalars()) txs = list(res.scalars())
# Eagerly load categories for each transaction # Eagerly load categories for each transaction
@@ -73,6 +96,36 @@ async def list_transactions(
return [_to_read_model(tx) for tx in txs] return [_to_read_model(tx) for tx in txs]
@router.get("/balance_series")
async def get_balance_series(
start_date: Optional[date] = None,
end_date: Optional[date] = None,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
cond = [Transaction.user_id == user.id]
if start_date is not None:
cond.append(Transaction.date >= start_date)
if end_date is not None:
cond.append(Transaction.date <= end_date)
res = await session.execute(
select(Transaction).where(and_(*cond)).order_by(Transaction.date, Transaction.id)
)
txs = list(res.scalars())
# Group by date and accumulate
daily = {}
for tx in txs:
key = tx.date.isoformat() if hasattr(tx.date, 'isoformat') else str(tx.date)
daily[key] = daily.get(key, 0.0) + float(tx.amount)
# Build cumulative series sorted by date
series = []
running = 0.0
for d in sorted(daily.keys()):
running += daily[d]
series.append({"date": d, "balance": running})
return series
@router.get("/{transaction_id}", response_model=TransactionRead) @router.get("/{transaction_id}", response_model=TransactionRead)
async def get_transaction( async def get_transaction(
transaction_id: int, transaction_id: int,
@@ -111,6 +164,14 @@ async def update_transaction(
tx.amount = payload.amount tx.amount = payload.amount
if payload.description is not None: if payload.description is not None:
tx.description = payload.description tx.description = payload.description
if payload.date is not None:
new_date = payload.date
if isinstance(new_date, str):
try:
new_date = date.fromisoformat(new_date)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format, expected YYYY-MM-DD")
tx.date = new_date
if payload.category_ids is not None: if payload.category_ids is not None:
# Preload categories to avoid async lazy-load during assignment # Preload categories to avoid async lazy-load during assignment

View File

@@ -6,25 +6,30 @@ from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from starlette.requests import Request from starlette.requests import Request
from app.models.user import User from app.services import bank_scraper
from app.workers.celery_tasks import load_transactions, load_all_transactions
from app.models.user import User, OAuthAccount
from app.services.user_service import current_active_verified_user from app.services.user_service import current_active_verified_user
from app.api.auth import router as auth_router 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.categories import router as categories_router
from app.api.transactions import router as transactions_router from app.api.transactions import router as transactions_router
from app.services.user_service import auth_backend, current_active_verified_user, fastapi_users, get_oauth_provider from app.services.user_service import auth_backend, current_active_verified_user, fastapi_users, get_oauth_provider, UserManager, get_jwt_strategy
from fastapi import FastAPI from fastapi import FastAPI
import sentry_sdk import sentry_sdk
from fastapi_users.db import SQLAlchemyUserDatabase
from app.core.db import async_session_maker
sentry_sdk.init( sentry_sdk.init(
dsn=os.getenv("SENTRY_DSN"), dsn=os.getenv("SENTRY_DSN"),
send_default_pii=True, send_default_pii=True,
) )
app = FastAPI()
fastApi = FastAPI() fastApi = FastAPI()
app = fastApi
# CORS for frontend dev server # CORS for frontend dev server
fastApi.add_middleware( fastApi.add_middleware(
@@ -70,6 +75,7 @@ fastApi.include_router(
auth_backend, auth_backend,
"SECRET", "SECRET",
associate_by_email=True, associate_by_email=True,
redirect_url=os.getenv("FRONTEND_DOMAIN_SCHEME", "http://localhost:3000") + "/auth/mojeid/callback",
), ),
prefix="/auth/mojeid", prefix="/auth/mojeid",
tags=["auth"], tags=["auth"],
@@ -81,11 +87,13 @@ fastApi.include_router(
auth_backend, auth_backend,
"SECRET", "SECRET",
associate_by_email=True, associate_by_email=True,
redirect_url=os.getenv("FRONTEND_DOMAIN_SCHEME", "http://localhost:3000") + "/auth/bankid/callback",
), ),
prefix="/auth/bankid", prefix="/auth/bankid",
tags=["auth"], tags=["auth"],
) )
fastApi.include_router(csas_router)
# Liveness/root endpoint # Liveness/root endpoint
@fastApi.get("/", include_in_schema=False) @fastApi.get("/", include_in_schema=False)
@@ -97,6 +105,16 @@ async def root():
async def authenticated_route(user: User = Depends(current_active_verified_user)): async def authenticated_route(user: User = Depends(current_active_verified_user)):
return {"message": f"Hello {user.email}!"} return {"message": f"Hello {user.email}!"}
@fastApi.get("/sentry-debug")
async def trigger_error(): @fastApi.get("/debug/scrape/csas/all", tags=["debug"])
division_by_zero = 1 / 0 async def debug_scrape_csas_all():
logging.info("[Debug] Queueing CSAS scrape for all users via HTTP endpoint (Celery)")
task = load_all_transactions.delay()
return {"status": "queued", "action": "csas_scrape_all", "task_id": getattr(task, 'id', None)}
@fastApi.post("/debug/scrape/csas/{user_id}", tags=["debug"])
async def debug_scrape_csas_user(user_id: str, user: User = Depends(current_active_verified_user)):
logging.info("[Debug] Queueing CSAS scrape for single user via HTTP endpoint (Celery) | user_id=%s", user_id)
task = load_transactions.delay(user_id)
return {"status": "queued", "action": "csas_scrape_single", "user_id": user_id, "task_id": getattr(task, 'id', None)}

View File

@@ -1,15 +1,22 @@
import os
from fastapi_users_db_sqlalchemy import GUID from fastapi_users_db_sqlalchemy import GUID
from sqlalchemy import Column, Integer, String, Float, ForeignKey from sqlalchemy import Column, Integer, String, Float, ForeignKey, Date, func
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy_utils import EncryptedType
from sqlalchemy_utils.types.encrypted.encrypted_type import FernetEngine
from app.core.base import Base from app.core.base import Base
from app.models.categories import association_table from app.models.categories import association_table
SECRET_KEY = os.environ.get("DB_ENCRYPTION_KEY", "localdev")
class Transaction(Base): class Transaction(Base):
__tablename__ = "transaction" __tablename__ = "transaction"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
amount = Column(Float, nullable=False) amount = Column(EncryptedType(Float, SECRET_KEY, engine=FernetEngine), nullable=False)
description = Column(String(length=255), nullable=True) description = Column(EncryptedType(String(length=255), SECRET_KEY, engine=FernetEngine), nullable=True)
date = Column(Date, nullable=False, server_default=func.current_date())
user_id = Column(GUID, ForeignKey("user.id"), nullable=False) user_id = Column(GUID, ForeignKey("user.id"), nullable=False)
# Relationship # Relationship

View File

@@ -1,6 +1,8 @@
from sqlalchemy import Column, String from sqlalchemy import Column, String
from sqlalchemy.orm import relationship, mapped_column, Mapped from sqlalchemy.orm import relationship, mapped_column, Mapped
from fastapi_users.db import SQLAlchemyBaseUserTableUUID, SQLAlchemyBaseOAuthAccountTableUUID from fastapi_users.db import SQLAlchemyBaseUserTableUUID, SQLAlchemyBaseOAuthAccountTableUUID
from sqlalchemy.sql.sqltypes import JSON
from app.core.base import Base from app.core.base import Base
@@ -13,6 +15,7 @@ class User(SQLAlchemyBaseUserTableUUID, Base):
first_name = Column(String(length=100), nullable=True) first_name = Column(String(length=100), nullable=True)
last_name = Column(String(length=100), nullable=True) last_name = Column(String(length=100), nullable=True)
oauth_accounts = relationship("OAuthAccount", lazy="joined") oauth_accounts = relationship("OAuthAccount", lazy="joined")
config = Column(JSON, default={})
# Relationship # Relationship
transactions = relationship("Transaction", back_populates="user") transactions = relationship("Transaction", back_populates="user")

View File

@@ -0,0 +1,33 @@
import os
from os.path import dirname, join
from typing import Optional, Any
import httpx
from httpx_oauth.exceptions import GetProfileError
from httpx_oauth.oauth2 import BaseOAuth2
import app.services.db
BASE_DIR = dirname(__file__)
certs = (
join(BASE_DIR, "public_key.pem"),
join(BASE_DIR, "private_key.key")
)
class CSASOAuth(BaseOAuth2):
def __init__(self, client_id: str, client_secret: str):
super().__init__(
client_id,
client_secret,
base_scopes=["aisp"],
authorize_endpoint="https://webapi.developers.erstegroup.com/api/csas/sandbox/v1/sandbox-idp/auth",
access_token_endpoint="https://webapi.developers.erstegroup.com/api/csas/sandbox/v1/sandbox-idp/token",
refresh_token_endpoint="https://webapi.developers.erstegroup.com/api/csas/sandbox/v1/sandbox-idp/token"
)

View File

@@ -11,7 +11,7 @@ class MojeIDOAuth(CustomOpenID):
super().__init__( super().__init__(
client_id, client_id,
client_secret, client_secret,
"https://mojeid.regtest.nic.cz/.well-known/openid-configuration/", "https://mojeid.cz/.well-known/openid-configuration/",
"MojeID", "MojeID",
base_scopes=["openid", "email", "profile"], base_scopes=["openid", "email", "profile"],
) )

View File

@@ -0,0 +1,28 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDcr/oxgV074ETd
DkP/0l8LFnRofru+m2wNNG/ttVCioTqwnvR4oYxwq3U9qIBsT0D+Rx/Ef7qcpzqf
/w9xt6Hosdv6I5jMHGaVQqLiPuV26/a7WvcmU+PpYuEBmbBHjGVJRBwgPtlUW1VL
M8Pht9YiaagEKvFa6SUidZLfPv+ECohqgH4mgMrEcG/BTnry0/5xQdadRC9o25cl
NtZIesS5GPeelhggFTkbh/FaxvMXhIAaRXT61cnxgxtfM71h5ObX5Lwle9z5a+Tw
xgQhSQq1jbHALYvTwsc4Q/NQGXpGNWy599sb7dg5AkPFSSF4ceXBo/2jOaZCqWrt
FVONZ+blAgMBAAECggEBAJwQbrRXsaFIRiq1jez5znC+3m+PQCHZM55a+NR3pqB7
uE9y+ZvdUr3S4sRJxxfRLDsl/Rcu5L8nm9PNwhQ/MmamcNQCHGoro3fmed3ZcNia
og94ktMt/DztygUhtIHEjVQ0sFc1WufG9xiJcPrM0MfhRAo+fBQ4UCSAVO8/U98B
a4yukrPNeEA03hyjLB9W41pNQfyOtAHqzwDg9Q5XVaGMCLZT1bjCIquUcht5iMva
tiw3cwdiYIklLTzTCsPPK9A/AlWZyUXL8KxtN0mU0kkwlXqASoXZ2nqdkhjRye/V
3JXOmlDtDaJCqWDpH2gHLxMCl7OjfPvuD66bAT3H63kCgYEA5zxW/l6oI3gwYW7+
j6rEjA2n8LikVnyW2e/PZ7pxBH3iBFe2DHx/imeqd/0IzixcM1zZT/V+PTFPQizG
lOU7stN6Zg/LuRdxneHPyLWCimJP7BBJCWyJkuxKy9psokyBhGSLR/phL3fP7UkB
o2I3vGmTFu5A0FzXcNH/cXPMdy8CgYEA9FJw3kyzXlInhJ6Cd63mckLPLYDArUsm
THBoeH2CVTBS5g0bCbl7N1ZxUoYwZPD4lg5V0nWhZALGf+85ULSjX03PMf1cc6WW
EIbZIo9hX+mGRa/FudDd+TlbtBnn0jucwABuLQi9mIepE55Hu9tw5/FT3cHeZVQc
cC0T6ulVvisCgYBCzFeFG+sOdAXl356B+h7VJozBKVWv9kXNp00O9fj4BzVnc78P
VFezr8a66snEZWQtIkFUq+JP4xK2VyD2mlHoktbk7OM5EOCtbzILFQQk3cmgtAOl
SUlkvAXPZcXEDL3NdQ4XOOkiQUY7kb97Z0AamZT4JtNqXaeO29si9wS12QKBgHYg
Hd3864Qg6GZgVOgUNiTsVErFw2KFwQCYIIqQ9CDH+myrzXTILuC0dJnXszI6p5W1
XJ0irmMyTFKykN2KWKrNbe3Xd4mad5GKARWKiSPcPkUXFNwgNhI3PzU2iTTGCaVz
D9HKNhC3FnIbxsb29AHQViITh7kqD43U3ZpoMkJ9AoGAZ+sg+CPfuo3ZMpbcdb3B
ZX2UhAvNKxgHvNnHOjO+pvaM7HiH+BT0650brfBWQ0nTG1dt18mCevVk1UM/5hO9
AtZw06vCLOJ3p3qpgkSlRZ1H7VokG9M8Od0zXqtJrmeLeBq7dfuDisYOuA+NUEbJ
UM/UHByieS6ywetruz0LpM0=
-----END RSA PRIVATE KEY-----

View File

@@ -0,0 +1,31 @@
-----BEGIN CERTIFICATE-----
MIIFSTCCAzGgAwIBAgIEAQIDBDANBgkqhkiG9w0BAQsFADCBgDELMAkGA1UEBhMC
Q1oxDjAMBgNVBAcTBUN6ZWNoMRMwEQYDVQQKEwpFcnN0ZUdyb3VwMRUwEwYDVQQL
EwxFcnN0ZUh1YlRlYW0xETAPBgNVBAMTCEVyc3RlSHViMSIwIAYJKoZIhvcNAQkB
FhNpbmZvQGVyc3RlZ3JvdXAuY29tMB4XDTIyMTIxNDA4MDc1N1oXDTI2MDMxNDA4
MDc1N1owUjEaMBgGA1UEYRMRUFNEQ1otQ05CLTEyMzQ1NjcxCzAJBgNVBAYTAkNa
MRYwFAYDVQQDEw1UUFAgVGVzdCBRV0FDMQ8wDQYDVQQKEwZNeSBUUFAwggEiMA0G
CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDcr/oxgV074ETdDkP/0l8LFnRofru+
m2wNNG/ttVCioTqwnvR4oYxwq3U9qIBsT0D+Rx/Ef7qcpzqf/w9xt6Hosdv6I5jM
HGaVQqLiPuV26/a7WvcmU+PpYuEBmbBHjGVJRBwgPtlUW1VLM8Pht9YiaagEKvFa
6SUidZLfPv+ECohqgH4mgMrEcG/BTnry0/5xQdadRC9o25clNtZIesS5GPeelhgg
FTkbh/FaxvMXhIAaRXT61cnxgxtfM71h5ObX5Lwle9z5a+TwxgQhSQq1jbHALYvT
wsc4Q/NQGXpGNWy599sb7dg5AkPFSSF4ceXBo/2jOaZCqWrtFVONZ+blAgMBAAGj
gfcwgfQwCwYDVR0PBAQDAgHGMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD
AjCBrwYIKwYBBQUHAQMEgaIwgZ8wCAYGBACORgEBMAsGBgQAjkYBAwIBFDAIBgYE
AI5GAQQwEwYGBACORgEGMAkGBwQAjkYBBgMwZwYGBACBmCcCMF0wTDARBgcEAIGY
JwEBDAZQU1BfQVMwEQYHBACBmCcBAgwGUFNQX1BJMBEGBwQAgZgnAQMMBlBTUF9B
STARBgcEAIGYJwEEDAZQU1BfSUMMBUVyc3RlDAZBVC1FUlMwFAYDVR0RBA0wC4IJ
bXl0cHAuY29tMA0GCSqGSIb3DQEBCwUAA4ICAQBlTMPSwz46GMRBEPcy+25gV7xE
5aFS5N6sf3YQyFelRJgPxxPxTHo55WelcK4XmXRQKeQ4VoKf4FgP0Cj74+p0N0gw
wFJDWPGXH3SdjAXPRtG+FOiHwUSoyrmvbL4kk6Vbrd4cF+qe0BlzHzJ2Q6vFLwsk
NYvWzkY9YjoItB38nAnQhyYgl1yHUK/uDWyrwHVfZn1AeTws/hr/KufORuiQfaTU
kvAH1nzi7WSJ6AIQCd2exUEPx/O14Y+oCoJhTVd+RpA/9lkcqebceBijj47b2bvv
QbjymvyTXqHd3L224Y7zVmh95g+CaJ8PRpApdrImfjfDDRy8PaFWx2pd/v0UQgrQ
lgbO6jE7ah/tS0T5q5JtwnLAiOOqHPaKRvo5WB65jcZ2fvOH/0/oZ89noxp1Ihus
vvsjqc9k2h9Rvt2pEjVU40HtQZ6XCmWqgFwK3n9CHrKNV/GqgANIZRNcvXKMCUoB
VoJORVwi2DF4caKSFmyEWuK+5FyCEILtQ60SY/NHVGsUeOuN7OTjZjECARO6p4hz
Uw+GCIXrzmIjS6ydh/LRef+NK28+xTbjmLHu/wnHg9rrHEnTPd39is+byfS7eeLV
Dld/0Xrv88C0wxz63dcwAceiahjyz2mbQm765tOf9rK7EqsvT5M8EXFJ3dP4zwqS
6mNFoIa0XGbAUT3E1w==
-----END CERTIFICATE-----

View File

@@ -11,6 +11,11 @@ class CategoryCreate(CategoryBase):
pass pass
class CategoryUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
class CategoryRead(CategoryBase): class CategoryRead(CategoryBase):
id: int id: int
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View File

@@ -1,10 +1,13 @@
from typing import List, Optional from typing import List, Optional, Union
from datetime import date
from pydantic import BaseModel, Field, ConfigDict from pydantic import BaseModel, Field, ConfigDict
class TransactionBase(BaseModel): class TransactionBase(BaseModel):
amount: float = Field(..., gt=-1e18, lt=1e18) amount: float = Field(..., gt=-1e18, lt=1e18)
description: Optional[str] = None description: Optional[str] = None
# accept either ISO date string or date object
date: Optional[Union[date, str]] = None
class TransactionCreate(TransactionBase): class TransactionCreate(TransactionBase):
category_ids: Optional[List[int]] = None category_ids: Optional[List[int]] = None
@@ -12,10 +15,12 @@ class TransactionCreate(TransactionBase):
class TransactionUpdate(BaseModel): class TransactionUpdate(BaseModel):
amount: Optional[float] = Field(None, gt=-1e18, lt=1e18) amount: Optional[float] = Field(None, gt=-1e18, lt=1e18)
description: Optional[str] = None description: Optional[str] = None
# accept either ISO date string or date object
date: Optional[Union[date, str]] = None
category_ids: Optional[List[int]] = None category_ids: Optional[List[int]] = None
class TransactionRead(TransactionBase): class TransactionRead(TransactionBase):
id: int id: int
category_ids: List[int] = [] category_ids: List[int] = []
date: Optional[Union[date, str]]
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,121 @@
import json
import logging
from os.path import dirname, join
from time import strptime
from uuid import UUID
import httpx
from sqlalchemy import select
from app.core.db import async_session_maker
from app.models.transaction import Transaction
from app.models.user import User
logger = logging.getLogger(__name__)
OAUTH_DIR = join(dirname(__file__), "..", "oauth")
CERTS = (
join(OAUTH_DIR, "public_key.pem"),
join(OAUTH_DIR, "private_key.key"),
)
async def aload_ceska_sporitelna_transactions(user_id: str) -> None:
try:
uid = UUID(str(user_id))
except Exception:
logger.error("Invalid user_id provided to bank_scraper (async): %r", user_id)
return
await _aload_ceska_sporitelna_transactions(uid)
async def aload_all_ceska_sporitelna_transactions() -> None:
async with async_session_maker() as session:
result = await session.execute(select(User))
users = result.unique().scalars().all()
logger.info("[BankScraper] Starting CSAS scrape for all users | count=%d", len(users))
processed = 0
for user in users:
try:
await _aload_ceska_sporitelna_transactions(user.id)
processed += 1
except Exception:
logger.exception("[BankScraper] Error scraping for user id=%s email=%s", user.id,
getattr(user, 'email', None))
logger.info("[BankScraper] Finished CSAS scrape for all users | processed=%d", processed)
async def _aload_ceska_sporitelna_transactions(user_id: UUID) -> None:
async with (async_session_maker() as session):
result = await session.execute(select(User).where(User.id == user_id))
user: User = result.unique().scalar_one_or_none()
if user is None:
logger.warning("User not found for id=%s", user_id)
return
cfg = user.config or {}
if "csas" not in cfg:
return
cfg = json.loads(cfg["csas"])
if "access_token" not in cfg:
return
accounts = []
try:
async with httpx.AsyncClient(cert=CERTS, timeout=httpx.Timeout(20.0)) as client:
response = await client.get(
"https://webapi.developers.erstegroup.com/api/csas/sandbox/v4/account-information/my/accounts?size=10&page=0&sort=iban&order=desc",
headers={
"Authorization": f"Bearer {cfg['access_token']}",
"WEB-API-key": "09fdc637-3c57-4242-95f2-c2205a2438f3",
"user-involved": "false",
},
)
if response.status_code != httpx.codes.OK:
return
for account in response.json()["accounts"]:
accounts.append(account)
except (httpx.HTTPError,) as e:
logger.exception("[BankScraper] HTTP error during CSAS request | user_id=%s", user_id)
return
for account in accounts:
id = account["id"]
url = f"https://webapi.developers.erstegroup.com/api/csas/sandbox/v4/account-information/my/accounts/{id}/transactions?size=100&page=0&sort=bookingdate&order=desc"
async with httpx.AsyncClient(cert=CERTS) as client:
response = await client.get(
url,
headers={
"Authorization": f"Bearer {cfg['access_token']}",
"WEB-API-key": "09fdc637-3c57-4242-95f2-c2205a2438f3",
"user-involved": "false",
},
)
if response.status_code != httpx.codes.OK:
continue
transactions = response.json()["transactions"]
for transaction in transactions:
description = transaction.get("entryDetails", {}).get("transactionDetails", {}).get(
"additionalRemittanceInformation")
date_str = transaction.get("bookingDate", {}).get("date")
date = strptime(date_str, "%Y-%m-%d") if date_str else None
obj = Transaction(
amount=transaction['amount']['value'],
description=description,
date=date,
user_id=user_id,
)
session.add(obj)
await session.commit()
pass
pass

View File

@@ -14,6 +14,7 @@ from httpx_oauth.oauth2 import BaseOAuth2
from app.models.user import User from app.models.user import User
from app.oauth.bank_id import BankID from app.oauth.bank_id import BankID
from app.oauth.csas import CSASOAuth
from app.oauth.custom_openid import CustomOpenID from app.oauth.custom_openid import CustomOpenID
from app.oauth.moje_id import MojeIDOAuth from app.oauth.moje_id import MojeIDOAuth
from app.services.db import get_user_db from app.services.db import get_user_db
@@ -32,7 +33,7 @@ providers = {
"BankID": BankID( "BankID": BankID(
os.getenv("BANKID_CLIENT_ID", "CHANGE_ME_CLIENT_ID"), os.getenv("BANKID_CLIENT_ID", "CHANGE_ME_CLIENT_ID"),
os.getenv("BANKID_CLIENT_SECRET", "CHANGE_ME_CLIENT_SECRET"), os.getenv("BANKID_CLIENT_SECRET", "CHANGE_ME_CLIENT_SECRET"),
) ),
} }
@@ -101,7 +102,7 @@ bearer_transport = BearerTransport(tokenUrl="auth/jwt/login")
def get_jwt_strategy() -> JWTStrategy: def get_jwt_strategy() -> JWTStrategy:
return JWTStrategy(secret=SECRET, lifetime_seconds=3600) return JWTStrategy(secret=SECRET, lifetime_seconds=604800)
auth_backend = AuthenticationBackend( auth_backend = AuthenticationBackend(

View File

@@ -1,7 +1,10 @@
import logging import logging
import asyncio
from celery import shared_task from celery import shared_task
import app.services.bank_scraper
logger = logging.getLogger("celery_tasks") logger = logging.getLogger("celery_tasks")
if not logger.handlers: if not logger.handlers:
_h = logging.StreamHandler() _h = logging.StreamHandler()
@@ -9,6 +12,72 @@ if not logger.handlers:
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
def run_coro(coro) -> None:
"""Run an async coroutine in a fresh event loop without using run_until_complete.
Primary strategy runs in a new loop in the current thread. If that fails due to
debugger patches (e.g., Bad file descriptor from pydevd_nest_asyncio), fall back
to running in a dedicated thread with its own event loop.
"""
import threading
def _cleanup_loop(loop):
try:
pending = [t for t in asyncio.all_tasks(loop) if not t.done()]
for t in pending:
t.cancel()
if pending:
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
except Exception:
pass
finally:
try:
loop.close()
finally:
asyncio.set_event_loop(None)
# First attempt: Run in current thread with a fresh event loop
try:
loop = asyncio.get_event_loop_policy().new_event_loop()
try:
asyncio.set_event_loop(loop)
task = loop.create_task(coro)
task.add_done_callback(lambda _t: loop.stop())
loop.run_forever()
exc = task.exception()
if exc:
raise exc
return
finally:
_cleanup_loop(loop)
except OSError as e:
logger.warning("run_coro primary strategy failed (%s). Falling back to thread runner.", e)
except Exception:
# For any other unexpected errors, try thread fallback as well
logger.exception("run_coro primary strategy raised; attempting thread fallback")
# Fallback: Run in a dedicated thread with its own event loop
error = {"exc": None}
def _thread_target():
loop = asyncio.new_event_loop()
try:
asyncio.set_event_loop(loop)
task = loop.create_task(coro)
task.add_done_callback(lambda _t: loop.stop())
loop.run_forever()
exc = task.exception()
if exc:
error["exc"] = exc
finally:
_cleanup_loop(loop)
th = threading.Thread(target=_thread_target, name="celery-async-runner", daemon=True)
th.start()
th.join()
if error["exc"] is not None:
raise error["exc"]
@shared_task(name="workers.send_email") @shared_task(name="workers.send_email")
def send_email(to: str, subject: str, body: str) -> None: def send_email(to: str, subject: str, body: str) -> None:
if not (to and subject and body): if not (to and subject and body):
@@ -17,3 +86,22 @@ def send_email(to: str, subject: str, body: str) -> None:
# Placeholder for real email sending logic # Placeholder for real email sending logic
logger.info("[Celery] Email sent | to=%s | subject=%s | body_len=%d", to, subject, len(body)) logger.info("[Celery] Email sent | to=%s | subject=%s | body_len=%d", to, subject, len(body))
@shared_task(name="workers.load_transactions")
def load_transactions(user_id: str) -> None:
if not user_id:
logger.error("Load transactions task missing user_id.")
return
run_coro(app.services.bank_scraper.aload_ceska_sporitelna_transactions(user_id))
# Placeholder for real transaction loading logic
logger.info("[Celery] Transactions loaded for user_id=%s", user_id)
@shared_task(name="workers.load_all_transactions")
def load_all_transactions() -> None:
logger.info("[Celery] Starting load_all_transactions")
run_coro(app.services.bank_scraper.aload_all_ceska_sporitelna_transactions())
logger.info("[Celery] Finished load_all_transactions")

View File

@@ -0,0 +1,2 @@
[tool.pytest.ini_options]
pythonpath = "."

View File

@@ -54,6 +54,7 @@ sentry-sdk==2.42.0
six==1.17.0 six==1.17.0
sniffio==1.3.1 sniffio==1.3.1
SQLAlchemy==2.0.43 SQLAlchemy==2.0.43
SQLAlchemy-Utils==0.42.0
starlette==0.48.0 starlette==0.48.0
tomli==2.2.1 tomli==2.2.1
typing-inspection==0.4.1 typing-inspection==0.4.1

View File

@@ -0,0 +1,22 @@
import sys
import types
import pytest
from fastapi.testclient import TestClient
# Stub sentry_sdk to avoid optional dependency issues during import of app
stub = types.ModuleType("sentry_sdk")
stub.init = lambda *args, **kwargs: None
sys.modules.setdefault("sentry_sdk", stub)
# Import the FastAPI application
from app.app import fastApi as app # noqa: E402
@pytest.fixture(scope="session")
def fastapi_app():
return app
@pytest.fixture(scope="session")
def client(fastapi_app):
return TestClient(fastapi_app, raise_server_exceptions=True)

View File

@@ -0,0 +1,15 @@
from fastapi import status
def test_e2e_minimal_auth_flow(client):
# 1) Service is alive
alive = client.get("/")
assert alive.status_code == status.HTTP_200_OK
# 2) Attempt to login without payload should fail fast (validation error)
login = client.post("/auth/jwt/login")
assert login.status_code in (status.HTTP_400_BAD_REQUEST, status.HTTP_422_UNPROCESSABLE_CONTENT)
# 3) Protected endpoint should not be accessible without token
me = client.get("/users/me")
assert me.status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)

View File

@@ -0,0 +1,18 @@
from fastapi import status
import pytest
def test_root_ok(client):
resp = client.get("/")
assert resp.status_code == status.HTTP_200_OK
assert resp.json() == {"status": "ok"}
def test_authenticated_route_requires_auth(client):
resp = client.get("/authenticated-route")
assert resp.status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)
def test_sentry_debug_raises_exception(client):
with pytest.raises(ZeroDivisionError):
client.get("/sentry-debug")

View File

@@ -0,0 +1,55 @@
import types
import asyncio
import pytest
from app.services import user_service
def test_get_oauth_provider_known_unknown():
# Known providers should return a provider instance
bankid = user_service.get_oauth_provider("BankID")
mojeid = user_service.get_oauth_provider("MojeID")
assert bankid is not None
assert mojeid is not None
# Unknown should return None
assert user_service.get_oauth_provider("DoesNotExist") is None
def test_get_jwt_strategy_lifetime():
strategy = user_service.get_jwt_strategy()
assert strategy is not None
# Basic smoke check: strategy has a lifetime set to 3600
assert getattr(strategy, "lifetime_seconds", None) in (604800,)
@pytest.mark.asyncio
async def test_on_after_request_verify_enqueues_email(monkeypatch):
calls = {}
def fake_enqueue_email(to: str, subject: str, body: str):
calls.setdefault("emails", []).append({
"to": to,
"subject": subject,
"body": body,
})
# Patch the enqueue_email used inside user_service
monkeypatch.setattr(user_service, "enqueue_email", fake_enqueue_email)
class DummyUser:
def __init__(self, email):
self.email = email
mgr = user_service.UserManager(user_db=None) # user_db not needed for this method
user = DummyUser("test@example.com")
# Call the hook
await mgr.on_after_request_verify(user, token="abc123", request=None)
# Verify one email has been enqueued with expected content
assert len(calls.get("emails", [])) == 1
email = calls["emails"][0]
assert email["to"] == "test@example.com"
assert "ověření účtu" in email["subject"].lower()
assert "abc123" in email["body"]

View File

@@ -78,6 +78,16 @@ spec:
secretKeyRef: secretKeyRef:
name: prod name: prod
key: BANKID_CLIENT_SECRET key: BANKID_CLIENT_SECRET
- name: CSAS_CLIENT_ID
valueFrom:
secretKeyRef:
name: prod
key: CSAS_CLIENT_ID
- name: CSAS_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: prod
key: CSAS_CLIENT_SECRET
- name: DOMAIN - name: DOMAIN
value: {{ required "Set .Values.domain" .Values.domain | quote }} value: {{ required "Set .Values.domain" .Values.domain | quote }}
- name: DOMAIN_SCHEME - name: DOMAIN_SCHEME
@@ -91,6 +101,11 @@ spec:
secretKeyRef: secretKeyRef:
name: prod name: prod
key: SENTRY_DSN key: SENTRY_DSN
- name: DB_ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: prod
key: DB_ENCRYPTION_KEY
livenessProbe: livenessProbe:
httpGet: httpGet:
path: / path: /

View File

@@ -8,6 +8,8 @@ stringData:
MOJEID_CLIENT_SECRET: {{ .Values.oauth.mojeid.clientSecret | quote }} MOJEID_CLIENT_SECRET: {{ .Values.oauth.mojeid.clientSecret | quote }}
BANKID_CLIENT_ID: {{ .Values.oauth.bankid.clientId | quote }} BANKID_CLIENT_ID: {{ .Values.oauth.bankid.clientId | quote }}
BANKID_CLIENT_SECRET: {{ .Values.oauth.bankid.clientSecret | quote }} BANKID_CLIENT_SECRET: {{ .Values.oauth.bankid.clientSecret | quote }}
CSAS_CLIENT_ID: {{ .Values.oauth.csas.clientId | quote }}
CSAS_CLIENT_SECRET: {{ .Values.oauth.csas.clientSecret | quote }}
# Database credentials # Database credentials
MARIADB_DB: {{ required "Set .Values.deployment" .Values.deployment | quote }} MARIADB_DB: {{ required "Set .Values.deployment" .Values.deployment | quote }}
MARIADB_USER: {{ required "Set .Values.deployment" .Values.deployment | quote }} MARIADB_USER: {{ required "Set .Values.deployment" .Values.deployment | quote }}
@@ -16,3 +18,4 @@ stringData:
RABBITMQ_PASSWORD: {{ .Values.rabbitmq.password | default "" | quote }} RABBITMQ_PASSWORD: {{ .Values.rabbitmq.password | default "" | quote }}
RABBITMQ_USERNAME: {{ .Values.rabbitmq.username | quote }} RABBITMQ_USERNAME: {{ .Values.rabbitmq.username | quote }}
SENTRY_DSN: {{ .Values.sentry_dsn | quote }} SENTRY_DSN: {{ .Values.sentry_dsn | quote }}
DB_ENCRYPTION_KEY: {{ required "Set .Values.database.encryptionSecret" .Values.database.encryptionSecret | quote }}

View File

@@ -20,7 +20,7 @@ spec:
securityContext: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
capabilities: capabilities:
drop: ["ALL"] drop: [ "ALL" ]
command: command:
- celery - celery
- -A - -A
@@ -70,3 +70,18 @@ spec:
secretKeyRef: secretKeyRef:
name: prod name: prod
key: SENTRY_DSN key: SENTRY_DSN
- name: CSAS_CLIENT_ID
valueFrom:
secretKeyRef:
name: prod
key: CSAS_CLIENT_ID
- name: CSAS_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: prod
key: CSAS_CLIENT_SECRET
- name: DB_ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: prod
key: DB_ENCRYPTION_KEY

View File

@@ -46,6 +46,9 @@ oauth:
mojeid: mojeid:
clientId: "" clientId: ""
clientSecret: "" clientSecret: ""
csas:
clientId: ""
clientSecret: ""
rabbitmq: rabbitmq:
create: true create: true
@@ -72,3 +75,4 @@ database:
userName: app-demo-user userName: app-demo-user
secretName: app-demo-database-secret secretName: app-demo-database-secret
password: "" password: ""
encryptionSecret: ""

View File

@@ -9,7 +9,8 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1" "react-dom": "^19.1.1",
"recharts": "^3.3.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.36.0", "@eslint/js": "^9.36.0",
@@ -1047,6 +1048,32 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/@reduxjs/toolkit": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.1.tgz",
"integrity": "sha512-sETJ3qO72y7L7WiR5K54UFLT3jRzAtqeBPVO15xC3bGA6kDqCH8m/v7BKCPH4czydXzz/1lPEGLvew7GjOO3Qw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^10.0.3",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.38", "version": "1.0.0-beta.38",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz",
@@ -1362,6 +1389,18 @@
"win32" "win32"
] ]
}, },
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1407,6 +1446,69 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1435,7 +1537,7 @@
"version": "19.2.0", "version": "19.2.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.0.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.0.tgz",
"integrity": "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA==", "integrity": "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
@@ -1451,6 +1553,12 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.45.0", "version": "8.45.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.45.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.45.0.tgz",
@@ -1929,6 +2037,15 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1982,9 +2099,130 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2003,6 +2241,12 @@
} }
} }
}, },
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-is": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2017,6 +2261,16 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/es-toolkit": {
"version": "1.40.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.40.0.tgz",
"integrity": "sha512-8o6w0KFmU0CiIl0/Q/BCEOabF2IJaELM1T2PWj6e8KqzHv1gdx+7JtFnDwOx1kJH/isJ5NwlDG1nCr1HrRF94Q==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.25.10", "version": "0.25.10",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz",
@@ -2260,6 +2514,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2463,6 +2723,16 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/immer": {
"version": "10.1.3",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz",
"integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -2490,6 +2760,15 @@
"node": ">=0.8.19" "node": ">=0.8.19"
} }
}, },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-extglob": { "node_modules/is-extglob": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -2925,6 +3204,36 @@
"react": "^19.2.0" "react": "^19.2.0"
} }
}, },
"node_modules/react-is": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz",
"integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==",
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -2935,6 +3244,54 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/recharts": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.3.0.tgz",
"integrity": "sha512-Vi0qmTB0iz1+/Cz9o5B7irVyUjX2ynvEgImbgMt/3sKRREcUM07QiYjS1QpAVrkmVlXqy5gykq4nGWMz9AS4Rg==",
"license": "MIT",
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve-from": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -3097,6 +3454,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -3270,10 +3633,41 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "7.1.9", "version": "7.1.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz",
"integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==", "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@@ -11,7 +11,8 @@
}, },
"dependencies": { "dependencies": {
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1" "react-dom": "^19.1.1",
"recharts": "^3.3.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.36.0", "@eslint/js": "^9.36.0",

View File

@@ -1 +0,0 @@
/* App-level styles moved to ui.css for a cleaner layout. */

View File

@@ -1,23 +1,55 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import './App.css';
import LoginRegisterPage from './pages/LoginRegisterPage'; import LoginRegisterPage from './pages/LoginRegisterPage';
import Dashboard from './pages/Dashboard'; import Dashboard from './pages/Dashboard';
import { logout } from './api'; import { logout } from './api';
import { BACKEND_URL } from './config';
function App() { function App() {
const [hasToken, setHasToken] = useState<boolean>(!!localStorage.getItem('token')); const [hasToken, setHasToken] = useState<boolean>(!!localStorage.getItem('token'));
const [processingCallback, setProcessingCallback] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
// Handle OAuth callback: /oauth-callback?access_token=...&token_type=... const path = window.location.pathname;
if (window.location.pathname === '/oauth-callback') {
const params = new URLSearchParams(window.location.search); // Minimal handling for provider callbacks: /auth|/oauth/:provider/callback?code=...&state=...
const token = params.get('access_token'); const parts = path.split('/').filter(Boolean);
if (token) { const isCallback = parts.length === 3 && (parts[0] === 'auth') && parts[2] === 'callback';
localStorage.setItem('token', token);
setHasToken(true); if (isCallback) {
// Guard against double invocation in React 18 StrictMode/dev
const w = window as any;
if (w.__oauthCallbackHandled) {
return;
} }
// Clean URL and redirect to home w.__oauthCallbackHandled = true;
window.history.replaceState({}, '', '/');
setProcessingCallback(true);
const provider = parts[1];
const qs = window.location.search || '';
const base = BACKEND_URL.replace(/\/$/, '');
const url = `${base}/auth/${encodeURIComponent(provider)}/callback${qs}`;
(async () => {
try {
const token = localStorage.getItem('token');
const res = await fetch(url, {
method: 'GET',
credentials: 'include',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
let data: any = null;
try {
data = await res.json();
} catch {}
if (provider !== 'csas' && res.ok && data?.access_token) {
localStorage.setItem('token', data?.access_token);
setHasToken(true);
}
} catch {}
// Clean URL and go home regardless of result
setProcessingCallback(false);
window.history.replaceState({}, '', '/');
})();
} }
const onStorage = (e: StorageEvent) => { const onStorage = (e: StorageEvent) => {
@@ -27,6 +59,24 @@ function App() {
return () => window.removeEventListener('storage', onStorage); return () => window.removeEventListener('storage', onStorage);
}, []); }, []);
if (processingCallback) {
return (
<div style={{ display: 'grid', placeItems: 'center', height: '100vh' }}>
<div className="card" style={{ width: 360, textAlign: 'center', padding: 24 }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12 }}>
<svg width="48" height="48" viewBox="0 0 50 50" aria-label="Loading">
<circle cx="25" cy="25" r="20" fill="none" stroke="#3b82f6" strokeWidth="5" strokeLinecap="round" strokeDasharray="31.4 31.4">
<animateTransform attributeName="transform" type="rotate" from="0 25 25" to="360 25 25" dur="0.9s" repeatCount="indefinite" />
</circle>
</svg>
<div>Finishing sign-in</div>
<div className="muted">Please wait</div>
</div>
</div>
</div>
);
}
if (!hasToken) { if (!hasToken) {
return <LoginRegisterPage onLoggedIn={() => setHasToken(true)} />; return <LoginRegisterPage onLoggedIn={() => setHasToken(true)} />;
} }

View File

@@ -16,6 +16,7 @@ export type Transaction = {
amount: number; amount: number;
description?: string | null; description?: string | null;
category_ids: number[]; category_ids: number[];
date?: string | null; // ISO date (YYYY-MM-DD)
}; };
function getBaseUrl() { function getBaseUrl() {
@@ -84,6 +85,7 @@ export type CreateTransactionInput = {
amount: number; amount: number;
description?: string; description?: string;
category_ids?: number[]; category_ids?: number[];
date?: string; // YYYY-MM-DD
}; };
export async function createTransaction(input: CreateTransactionInput): Promise<Transaction> { export async function createTransaction(input: CreateTransactionInput): Promise<Transaction> {
@@ -99,8 +101,13 @@ export async function createTransaction(input: CreateTransactionInput): Promise<
return res.json(); return res.json();
} }
export async function getTransactions(): Promise<Transaction[]> { export async function getTransactions(start_date?: string, end_date?: string): Promise<Transaction[]> {
const res = await fetch(`${getBaseUrl()}/transactions/`, { const params = new URLSearchParams();
if (start_date) params.set('start_date', start_date);
if (end_date) params.set('end_date', end_date);
const qs = params.toString();
const url = `${getBaseUrl()}/transactions/${qs ? `?${qs}` : ''}`;
const res = await fetch(url, {
headers: getHeaders(), headers: getHeaders(),
}); });
if (!res.ok) throw new Error('Failed to load transactions'); if (!res.ok) throw new Error('Failed to load transactions');
@@ -153,3 +160,68 @@ export async function deleteMe(): Promise<void> {
export function logout() { export function logout() {
localStorage.removeItem('token'); localStorage.removeItem('token');
} }
// Categories
export type CreateCategoryInput = { name: string; description?: string };
export async function createCategory(input: CreateCategoryInput): Promise<Category> {
const res = await fetch(`${getBaseUrl()}/categories/create`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(input),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Failed to create category');
}
return res.json();
}
export type UpdateCategoryInput = { name?: string; description?: string };
export async function updateCategory(category_id: number, input: UpdateCategoryInput): Promise<Category> {
const res = await fetch(`${getBaseUrl()}/categories/${category_id}`, {
method: 'PATCH',
headers: getHeaders(),
body: JSON.stringify(input),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Failed to update category');
}
return res.json();
}
// Transactions update
export type UpdateTransactionInput = {
amount?: number;
description?: string;
date?: string;
category_ids?: number[];
};
export async function updateTransaction(id: number, input: UpdateTransactionInput): Promise<Transaction> {
const res = await fetch(`${getBaseUrl()}/transactions/${id}/edit`, {
method: 'PATCH',
headers: getHeaders(),
body: JSON.stringify(input),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Failed to update transaction');
}
return res.json();
}
// Balance series
export type BalancePoint = { date: string; balance: number };
export async function getBalanceSeries(start_date?: string, end_date?: string): Promise<BalancePoint[]> {
const params = new URLSearchParams();
if (start_date) params.set('start_date', start_date);
if (end_date) params.set('end_date', end_date);
const qs = params.toString();
const url = `${getBaseUrl()}/transactions/balance_series${qs ? `?${qs}` : ''}`;
const res = await fetch(url, { headers: getHeaders() });
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Failed to load balance series');
}
return res.json();
}

View File

@@ -14,8 +14,8 @@ export function applyFontSize(size: FontSize) {
const root = document.documentElement; const root = document.documentElement;
const map: Record<FontSize, string> = { const map: Record<FontSize, string> = {
small: '14px', small: '14px',
medium: '16px', medium: '18px',
large: '18px', large: '22px',
}; };
root.style.fontSize = map[size]; root.style.fontSize = map[size];
} }

View File

@@ -0,0 +1,46 @@
// src/BalanceChart.tsx
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { type BalancePoint } from '../api';
function formatAmount(n: number) {
return new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n);
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
export default function BalanceChart({ data }: { data: BalancePoint[] }) {
if (data.length === 0) {
return <div>No data to display</div>;
}
return (
<ResponsiveContainer width="100%" height={300}>
<LineChart
data={data}
// Increased 'left' margin to create more space for the Y-axis label and tick values
margin={{ top: 5, right: 30, left: 50, bottom: 5 }} // <-- Change this line
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="date"
tickFormatter={formatDate}
label={{ value: 'Date', position: 'insideBottom', offset: -5 }}
/>
<YAxis
tickFormatter={(value) => formatAmount(value as number)}
// Adjusted 'offset' for the Y-axis label.
// A negative offset moves it further away from the axis.
label={{ value: 'Balance', angle: -90, position: 'insideLeft', offset: -30 }} // <-- Change this line
/>
<Tooltip
labelFormatter={formatDate}
formatter={(value) => [formatAmount(value as number), 'Balance']}
/>
<Legend />
<Line type="monotone" dataKey="balance" stroke="#3b82f6" strokeWidth={2} activeDot={{ r: 8 }} />
</LineChart>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,100 @@
// src/CategoryPieCharts.tsx (renamed from CategoryPieChart.tsx)
import { useMemo } from 'react';
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { type Transaction, type Category } from '../api';
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#AF19FF', '#FF4242', '#8884d8', '#82ca9d'];
// Helper component for a single pie chart
function SinglePieChart({ data, title }: { data: { name: string; value: number }[]; title: string }) {
if (data.length === 0) {
return (
<div style={{ flex: 1, textAlign: 'center' }}>
<h4>{title}</h4>
<div>No data to display.</div>
</div>
);
}
return (
<div style={{ flex: 1 }}>
<h4>{title}</h4>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
labelLine={false}
outerRadius={80}
fill="#8884d8"
dataKey="value"
nameKey="name"
label={(props: any) => `${props.name} ${(props.percent * 100).toFixed(0)}%`}
>
{data.map((_entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(value) => new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' }).format(value as number)} />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
);
}
export default function CategoryPieCharts({ transactions, categories }: { transactions: Transaction[], categories: Category[] }) {
// Calculate expenses data
const expensesData = useMemo(() => {
const spendingMap = new Map<number, number>();
transactions.forEach(tx => {
// Expenses are typically negative amounts in your system
if (tx.amount < 0 && tx.category_ids.length > 0) {
tx.category_ids.forEach(catId => {
// Use absolute value for display on chart
spendingMap.set(catId, (spendingMap.get(catId) || 0) + Math.abs(tx.amount));
});
}
});
return Array.from(spendingMap.entries())
.map(([categoryId, total]) => ({
name: categories.find(c => c.id === categoryId)?.name || `Category #${categoryId}`,
value: total,
}))
.sort((a, b) => b.value - a.value); // Sort descending
}, [transactions, categories]);
// Calculate earnings data
const earningsData = useMemo(() => {
const incomeMap = new Map<number, number>();
transactions.forEach(tx => {
// Earnings are typically positive amounts in your system
if (tx.amount > 0 && tx.category_ids.length > 0) {
tx.category_ids.forEach(catId => {
incomeMap.set(catId, (incomeMap.get(catId) || 0) + tx.amount);
});
}
});
return Array.from(incomeMap.entries())
.map(([categoryId, total]) => ({
name: categories.find(c => c.id === categoryId)?.name || `Category #${categoryId}`,
value: total,
}))
.sort((a, b) => b.value - a.value); // Sort descending
}, [transactions, categories]);
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '20px', justifyContent: 'center' }}>
<SinglePieChart data={expensesData} title="Expenses by Category" />
<SinglePieChart data={earningsData} title="Earnings by Category" />
</div>
);
}

View File

@@ -1,7 +1,11 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { type Category, type Transaction, createTransaction, getCategories, getTransactions } from '../api'; import { type Category, type Transaction, type BalancePoint, createTransaction, getCategories, getTransactions, createCategory, updateTransaction, getBalanceSeries } from '../api';
import AccountPage from './AccountPage'; import AccountPage from './AccountPage';
import AppearancePage from './AppearancePage'; import AppearancePage from './AppearancePage';
import BalanceChart from './BalanceChart';
import CategoryPieChart from './CategoryPieChart';
import MockBankModal, { type MockGenerationOptions } from './MockBankModal';
import { BACKEND_URL } from '../config';
function formatAmount(n: number) { function formatAmount(n: number) {
return new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n); return new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n);
@@ -13,6 +17,29 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isMockModalOpen, setMockModalOpen] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
// Start CSAS (George) OAuth after login
async function startOauthCsas() {
const base = BACKEND_URL.replace(/\/$/, '');
const url = `${base}/auth/csas/authorize`;
try {
const token = localStorage.getItem('token');
const res = await fetch(url, {
credentials: 'include',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
const data = await res.json();
if (data && typeof data.authorization_url === 'string') {
window.location.assign(data.authorization_url);
} else {
alert('Cannot start CSAS OAuth.');
}
} catch (e) {
alert('Cannot start CSAS OAuth.');
}
}
// New transaction form state // New transaction form state
const [amount, setAmount] = useState<string>(''); const [amount, setAmount] = useState<string>('');
@@ -25,13 +52,42 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
const [filterCategoryId, setFilterCategoryId] = useState<number | ''>(''); const [filterCategoryId, setFilterCategoryId] = useState<number | ''>('');
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
// Date-range filter
const [startDate, setStartDate] = useState<string>(''); // YYYY-MM-DD
const [endDate, setEndDate] = useState<string>('');
// Pagination over filtered transactions (20 per page), 0 = latest (most recent)
const pageSize = 20;
const [page, setPage] = useState<number>(0);
// Balance chart series for current date filter
const [balanceSeries, setBalanceSeries] = useState<BalancePoint[]>([]);
// Category creation form
const [newCatName, setNewCatName] = useState('');
const [newCatDesc, setNewCatDesc] = useState('');
// New transaction date
const [txDate, setTxDate] = useState<string>('');
// Inline edit state for transaction categories
const [editingTxId, setEditingTxId] = useState<number | null>(null);
const [editingCategoryIds, setEditingCategoryIds] = useState<number[]>([]);
async function loadAll() { async function loadAll() {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const [txs, cats] = await Promise.all([getTransactions(), getCategories()]); const [txs, cats, series] = await Promise.all([
getTransactions(startDate || undefined, endDate || undefined),
getCategories(),
getBalanceSeries(startDate || undefined, endDate || undefined),
]);
setTransactions(txs); setTransactions(txs);
setCategories(cats); setCategories(cats);
setBalanceSeries(series);
// reset paging to most recent
setPage(0);
} catch (err: any) { } catch (err: any) {
setError(err?.message || 'Failed to load data'); setError(err?.message || 'Failed to load data');
} finally { } finally {
@@ -39,15 +95,54 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
} }
} }
useEffect(() => { loadAll(); }, []); async function handleGenerateMockTransactions(options: MockGenerationOptions) {
setIsGenerating(true);
setMockModalOpen(false);
const last10 = useMemo(() => { const { count, minAmount, maxAmount, startDate, endDate, categoryIds } = options;
const sorted = [...transactions].sort((a, b) => b.id - a.id); const newTransactions: Transaction[] = [];
return sorted.slice(0, 10);
}, [transactions]); const startDateTime = new Date(startDate).getTime();
const endDateTime = new Date(endDate).getTime();
for (let i = 0; i < count; i++) {
// Generate random data based on user input
const amount = parseFloat((Math.random() * (maxAmount - minAmount) + minAmount).toFixed(2));
const randomTime = Math.random() * (endDateTime - startDateTime) + startDateTime;
const date = new Date(randomTime);
const dateString = date.toISOString().split('T')[0];
const randomCategory = categoryIds.length > 0
? [categoryIds[Math.floor(Math.random() * categoryIds.length)]]
: [];
const payload = {
amount,
date: dateString,
category_ids: randomCategory,
};
try {
const created = await createTransaction(payload);
newTransactions.push(created);
} catch (err) {
console.error("Failed to create mock transaction:", err);
alert('An error occurred while generating transactions. Check the console.');
break;
}
}
setIsGenerating(false);
alert(`${newTransactions.length} mock transactions were successfully generated!`);
await loadAll();
}
useEffect(() => { loadAll(); }, [startDate, endDate]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
let arr = last10; let arr = [...transactions];
const min = minAmount !== '' ? Number(minAmount) : undefined; const min = minAmount !== '' ? Number(minAmount) : undefined;
const max = maxAmount !== '' ? Number(maxAmount) : undefined; const max = maxAmount !== '' ? Number(maxAmount) : undefined;
if (min !== undefined) arr = arr.filter(t => t.amount >= min); if (min !== undefined) arr = arr.filter(t => t.amount >= min);
@@ -55,7 +150,20 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
if (filterCategoryId !== '') arr = arr.filter(t => t.category_ids.includes(filterCategoryId as number)); if (filterCategoryId !== '') arr = arr.filter(t => t.category_ids.includes(filterCategoryId as number));
if (searchText.trim()) arr = arr.filter(t => (t.description || '').toLowerCase().includes(searchText.toLowerCase())); if (searchText.trim()) arr = arr.filter(t => (t.description || '').toLowerCase().includes(searchText.toLowerCase()));
return arr; return arr;
}, [last10, minAmount, maxAmount, filterCategoryId, searchText]); }, [transactions, minAmount, maxAmount, filterCategoryId, searchText]);
const sortedDesc = useMemo(() => {
return [...filtered].sort((a, b) => {
const ad = (a.date || '') > (b.date || '') ? 1 : (a.date || '') < (b.date || '') ? -1 : 0;
if (ad !== 0) return -ad; // date desc
return b.id - a.id; // fallback id desc
});
}, [filtered]);
const totalPages = Math.ceil(sortedDesc.length / pageSize);
const pageStart = page * pageSize;
const pageEnd = pageStart + pageSize;
const visible = sortedDesc.slice(pageStart, pageEnd);
function categoryNameById(id: number) { return categories.find(c => c.id === id)?.name || `#${id}`; } function categoryNameById(id: number) { return categories.find(c => c.id === id)?.name || `#${id}`; }
@@ -66,16 +174,36 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
amount: Number(amount), amount: Number(amount),
description: description || undefined, description: description || undefined,
category_ids: selectedCategoryId !== '' ? [Number(selectedCategoryId)] : undefined, category_ids: selectedCategoryId !== '' ? [Number(selectedCategoryId)] : undefined,
date: txDate || undefined,
}; };
try { try {
const created = await createTransaction(payload); const created = await createTransaction(payload);
setTransactions(prev => [created, ...prev]); setTransactions(prev => [created, ...prev]);
setAmount(''); setDescription(''); setSelectedCategoryId(''); setAmount(''); setDescription(''); setSelectedCategoryId(''); setTxDate('');
} catch (err: any) { } catch (err: any) {
alert(err?.message || 'Failed to create transaction'); alert(err?.message || 'Failed to create transaction');
} }
} }
function beginEditCategories(t: Transaction) {
setEditingTxId(t.id);
setEditingCategoryIds([...(t.category_ids || [])]);
}
function cancelEditCategories() {
setEditingTxId(null);
setEditingCategoryIds([]);
}
async function saveEditCategories() {
if (editingTxId == null) return;
try {
const updated = await updateTransaction(editingTxId, { category_ids: editingCategoryIds });
setTransactions(prev => prev.map(p => (p.id === updated.id ? updated : p)));
cancelEditCategories();
} catch (err: any) {
alert(err?.message || 'Failed to update transaction categories');
}
}
return ( return (
<div className="app-layout"> <div className="app-layout">
<aside className="sidebar"> <aside className="sidebar">
@@ -97,10 +225,23 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
<main className="page space-y"> <main className="page space-y">
{current === 'home' && ( {current === 'home' && (
<> <>
<section className="card space-y">
<h3>Bank connections</h3>
<div className="connection-row">
<p className="muted" style={{ margin: 0 }}>Connect your CSAS (George) account.</p>
<button className="btn primary" onClick={startOauthCsas}>Connect CSAS (George)</button>
</div>
<div className="connection-row">
<p className="muted" style={{ margin: 0 }}>Generate data from a mock bank.</p>
<button className="btn primary" onClick={() => setMockModalOpen(true)}>Connect Mock Bank</button>
</div>
</section>
<section className="card"> <section className="card">
<h3>Add Transaction</h3> <h3>Add Transaction</h3>
<form onSubmit={handleCreate} className="form-row"> <form onSubmit={handleCreate} className="form-row">
<input className="input" type="number" step="0.01" placeholder="Amount" value={amount} onChange={(e) => setAmount(e.target.value)} required /> <input className="input" type="number" step="0.01" placeholder="Amount" value={amount} onChange={(e) => setAmount(e.target.value)} required />
<input className="input" type="date" placeholder="Date (optional)" value={txDate} onChange={(e) => setTxDate(e.target.value)} />
<input className="input" type="text" placeholder="Description (optional)" value={description} onChange={(e) => setDescription(e.target.value)} /> <input className="input" type="text" placeholder="Description (optional)" value={description} onChange={(e) => setDescription(e.target.value)} />
<select className="input" value={selectedCategoryId} onChange={(e) => setSelectedCategoryId(e.target.value ? Number(e.target.value) : '')}> <select className="input" value={selectedCategoryId} onChange={(e) => setSelectedCategoryId(e.target.value ? Number(e.target.value) : '')}>
<option value="">No category</option> <option value="">No category</option>
@@ -110,9 +251,20 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
</form> </form>
</section> </section>
<section className="card">
<h3>Categories</h3>
<form className="form-row" onSubmit={async (e) => { e.preventDefault(); if (!newCatName.trim()) return; try { const cat = await createCategory({ name: newCatName.trim(), description: newCatDesc || undefined }); setCategories(prev => [...prev, cat]); setNewCatName(''); setNewCatDesc(''); } catch (err: any) { alert(err?.message || 'Failed to create category'); } }}>
<input className="input" type="text" placeholder="New category name" value={newCatName} onChange={(e) => setNewCatName(e.target.value)} />
<input className="input" type="text" placeholder="Description (optional)" value={newCatDesc} onChange={(e) => setNewCatDesc(e.target.value)} />
<button className="btn primary" type="submit">Create category</button>
</form>
</section>
<section className="card"> <section className="card">
<h3>Filters</h3> <h3>Filters</h3>
<div className="form-row"> <div className="form-row" style={{ gap: 8, flexWrap: 'wrap' }}>
<input className="input" type="date" placeholder="Start date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
<input className="input" type="date" placeholder="End date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
<input className="input" type="number" step="0.01" placeholder="Min amount" value={minAmount} onChange={(e) => setMinAmount(e.target.value)} /> <input className="input" type="number" step="0.01" placeholder="Min amount" value={minAmount} onChange={(e) => setMinAmount(e.target.value)} />
<input className="input" type="number" step="0.01" placeholder="Max amount" value={maxAmount} onChange={(e) => setMaxAmount(e.target.value)} /> <input className="input" type="number" step="0.01" placeholder="Max amount" value={maxAmount} onChange={(e) => setMaxAmount(e.target.value)} />
<select className="input" value={filterCategoryId} onChange={(e) => setFilterCategoryId(e.target.value ? Number(e.target.value) : '')}> <select className="input" value={filterCategoryId} onChange={(e) => setFilterCategoryId(e.target.value ? Number(e.target.value) : '')}>
@@ -124,7 +276,30 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
</section> </section>
<section className="card"> <section className="card">
<h3>Latest Transactions (last 10)</h3> <h3>Balance over time</h3>
{loading ? (
<div>Loading</div>
) : error ? (
<div style={{ color: 'crimson' }}>{error}</div>
) : (
<BalanceChart data={balanceSeries} />
)}
</section>
{/* 3. Add the new section for the Category Pie Chart */}
<section className="card">
{loading ? (
<div>Loading</div>
) : error ? (
<div style={{ color: 'crimson' }}>{error}</div>
) : (
// Pass the filtered transactions to see the breakdown for the current view
<CategoryPieChart transactions={filtered} categories={categories} />
)}
</section>
<section className="card">
<h3>Transactions</h3>
{loading ? ( {loading ? (
<div>Loading</div> <div>Loading</div>
) : error ? ( ) : error ? (
@@ -132,26 +307,57 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<div>No transactions</div> <div>No transactions</div>
) : ( ) : (
<table className="table"> <>
<thead> <div className="table-controls">
<tr> <div className="muted">
<th>ID</th> Showing {visible.length} of {filtered.length} (page {Math.min(page + 1, Math.max(1, totalPages))}/{Math.max(1, totalPages)})
<th style={{ textAlign: 'right' }}>Amount</th> </div>
<th>Description</th> <div className="actions">
<th>Categories</th> <button className="btn primary" disabled={page <= 0} onClick={() => setPage(p => Math.max(0, p - 1))}>Previous</button>
</tr> <button className="btn primary" disabled={page >= totalPages - 1} onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}>Next</button>
</thead> </div>
<tbody> </div>
{filtered.map(t => ( <table className="table">
<tr key={t.id}> <thead>
<td>{t.id}</td> <tr>
<td className="amount">{formatAmount(t.amount)}</td> <th>Date</th>
<td>{t.description || ''}</td> <th style={{ textAlign: 'right' }}>Amount</th>
<td>{t.category_ids.map(id => categoryNameById(id)).join(', ')}</td> <th>Description</th>
<th>Categories</th>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {visible.map(t => (
<tr key={t.id}>
<td>{t.date || ''}</td>
<td className="amount">{formatAmount(t.amount)}</td>
<td>{t.description || ''}</td>
<td>
{editingTxId === t.id ? (
<div className="space-y" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<select multiple className="input" value={editingCategoryIds.map(String)} onChange={(e) => {
const opts = Array.from(e.currentTarget.selectedOptions).map(o => Number(o.value));
setEditingCategoryIds(opts);
}}>
{categories.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
<button className="btn small" onClick={saveEditCategories}>Save</button>
<button className="btn small" onClick={cancelEditCategories}>Cancel</button>
</div>
) : (
<div className="space-x" style={{ display: 'flex', alignItems: 'center', gap: 8, justifyContent: 'space-between' }}>
<span>{t.category_ids.map(id => categoryNameById(id)).join(', ') || '—'}</span>
<button className="btn small" onClick={() => beginEditCategories(t)}>Change</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</>
)} )}
</section> </section>
</> </>
@@ -167,6 +373,13 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
)} )}
</main> </main>
</div> </div>
<MockBankModal
isOpen={isMockModalOpen}
isGenerating={isGenerating}
categories={categories}
onClose={() => setMockModalOpen(false)}
onGenerate={handleGenerateMockTransactions}
/>
</div> </div>
); );
} }

View File

@@ -2,10 +2,21 @@ import { useState, useEffect } from 'react';
import { login, register } from '../api'; import { login, register } from '../api';
import { BACKEND_URL } from '../config'; import { BACKEND_URL } from '../config';
function oauthUrl(provider: 'mojeid' | 'bankid') { // Minimal helper to start OAuth: fetch authorization_url and redirect
async function startOauth(provider: 'mojeid' | 'bankid') {
const base = BACKEND_URL.replace(/\/$/, ''); const base = BACKEND_URL.replace(/\/$/, '');
const redirect = encodeURIComponent(window.location.origin + '/oauth-callback'); const url = `${base}/auth/${provider}/authorize`;
return `${base}/auth/${provider}/authorize?redirect_url=${redirect}`; try {
const res = await fetch(url, { credentials: 'include' });
const data = await res.json();
if (data && typeof data.authorization_url === 'string') {
window.location.assign(data.authorization_url);
} else {
alert('Cannot start OAuth.');
}
} catch (e) {
alert('Cannot start OAuth.');
}
} }
export default function LoginRegisterPage({ onLoggedIn }: { onLoggedIn: () => void }) { export default function LoginRegisterPage({ onLoggedIn }: { onLoggedIn: () => void }) {
@@ -84,8 +95,8 @@ export default function LoginRegisterPage({ onLoggedIn }: { onLoggedIn: () => vo
<div className="actions" style={{ justifyContent: 'space-between' }}> <div className="actions" style={{ justifyContent: 'space-between' }}>
<div className="muted">Or continue with</div> <div className="muted">Or continue with</div>
<div className="actions"> <div className="actions">
<a className="btn" href={oauthUrl('mojeid')}>MojeID</a> <button type="button" className="btn" onClick={() => startOauth('mojeid')}>MojeID</button>
<a className="btn" href={oauthUrl('bankid')}>BankID</a> <button type="button" className="btn" onClick={() => startOauth('bankid')}>BankID</button>
<button className="btn primary" type="submit" disabled={loading}>{loading ? 'Please wait…' : (mode === 'login' ? 'Login' : 'Register')}</button> <button className="btn primary" type="submit" disabled={loading}>{loading ? 'Please wait…' : (mode === 'login' ? 'Login' : 'Register')}</button>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,100 @@
// src/MockBankModal.tsx
import { useState } from 'react';
import { type Category } from '../api';
// Define the shape of the generation options
export interface MockGenerationOptions {
count: number;
minAmount: number;
maxAmount: number;
startDate: string;
endDate: string;
categoryIds: number[];
}
interface MockBankModalProps {
isOpen: boolean;
isGenerating: boolean;
categories: Category[]; // Pass in available categories
onClose: () => void;
onGenerate: (options: MockGenerationOptions) => void;
}
export default function MockBankModal({ isOpen, isGenerating, categories, onClose, onGenerate }: MockBankModalProps) {
// State for all the new form fields
const [count, setCount] = useState('10');
const [minAmount, setMinAmount] = useState('-200');
const [maxAmount, setMaxAmount] = useState('200');
const [startDate, setStartDate] = useState(() => new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]); // Default to one year ago
const [endDate, setEndDate] = useState(() => new Date().toISOString().split('T')[0]); // Default to today
const [selectedCategoryIds, setSelectedCategoryIds] = useState<string[]>([]);
if (!isOpen) return null;
function handleGenerateClick() {
const parsedCount = parseInt(count, 10);
const parsedMinAmount = parseFloat(minAmount);
const parsedMaxAmount = parseFloat(maxAmount);
const parsedStartDate = new Date(startDate);
const parsedEndDate = new Date(endDate);
// Validation
if (
isNaN(parsedCount) || parsedCount <= 0 ||
isNaN(parsedMinAmount) || isNaN(parsedMaxAmount) ||
parsedMaxAmount < parsedMinAmount ||
isNaN(parsedStartDate.getTime()) || isNaN(parsedEndDate.getTime()) ||
parsedEndDate < parsedStartDate
) {
alert(
"Please ensure:\n" +
"- Count is a positive number\n" +
"- Min and Max Amount are valid numbers, and Max >= Min\n" +
"- Start and End Date are valid, and End Date >= Start Date"
);
return;
}
const options: MockGenerationOptions = {
count: parsedCount,
minAmount: parsedMinAmount,
maxAmount: parsedMaxAmount,
startDate,
endDate,
categoryIds: selectedCategoryIds.map(Number),
};
onGenerate(options);
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h3>Generate Mock Transactions</h3>
<p className="muted">
Customize the random transactions you'd like to import.
</p>
<div className="space-y">
<input className="input" type="number" value={count} onChange={(e) => setCount(e.target.value)} placeholder="Number of transactions" />
<div className="form-row" style={{ gridTemplateColumns: '1fr 1fr' }}>
<input className="input" type="number" value={minAmount} onChange={(e) => setMinAmount(e.target.value)} placeholder="Min amount" />
<input className="input" type="number" value={maxAmount} onChange={(e) => setMaxAmount(e.target.value)} placeholder="Max amount" />
</div>
<div className="form-row" style={{ gridTemplateColumns: '1fr 1fr' }}>
<input className="input" type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} placeholder="Earliest date" />
<input className="input" type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} placeholder="Latest date" />
</div>
<select multiple className="input" style={{ height: '120px' }} value={selectedCategoryIds} onChange={(e) => setSelectedCategoryIds(Array.from(e.target.selectedOptions, option => option.value))}>
{categories.map(c => (<option key={c.id} value={c.id}>{c.name}</option>))}
</select>
</div>
<div className="actions" style={{ justifyContent: 'flex-end', marginTop: '16px' }}>
<button className="btn" onClick={onClose} disabled={isGenerating}>Cancel</button>
<button className="btn primary" onClick={handleGenerateClick} disabled={isGenerating}>
{isGenerating ? 'Generating...' : `Generate Transactions`}
</button>
</div>
</div>
</div>
);
}

View File

@@ -31,27 +31,53 @@ body[data-theme="dark"] {
} }
/* Layout */ /* Layout */
.app-layout { display: grid; grid-template-columns: 260px 1fr; height: 100%; } .app-layout { display: grid; grid-template-columns: 260px 1fr; height: 100vh; }
.sidebar { background: #15172a; color: #e5e7eb; display: flex; flex-direction: column; padding: 20px 12px; } .sidebar { background: #15172a; color: #e5e7eb; display: flex; flex-direction: column; padding: 20px 12px; }
.sidebar .logo { color: #fff; font-weight: 700; font-size: 18px; padding: 12px 14px; display: flex; align-items: center; gap: 10px; } .sidebar .logo { color: #fff; font-weight: 700; font-size: 18px; padding: 12px 14px; display: flex; align-items: center; gap: 10px; }
.nav { margin-top: 12px; display: grid; gap: 4px; } .nav { margin-top: 12px; display: grid; gap: 4px; }
.nav a, .nav button { color: #cbd5e1; text-align: left; background: transparent; border: 0; padding: 10px 12px; border-radius: 8px; cursor: pointer; } .nav a, .nav button { color: #cbd5e1; text-align: left; background: transparent; border: 0; padding: 10px 12px; border-radius: 8px; cursor: pointer; }
.nav a.active, .nav a:hover, .nav button:hover { background: rgba(255,255,255,0.08); color: #fff; } .nav a.active, .nav a:hover, .nav button:hover { background: rgba(255,255,255,0.08); color: #fff; }
.content { display: flex; flex-direction: column; height: 100%; } .content { display: flex; flex-direction: column; overflow-y: auto; }
.topbar { height: 64px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; background: var(--panel); border-bottom: 1px solid var(--border); } .topbar { height: 64px; display: flex; flex-shrink: 0; align-items: center; justify-content: space-between; padding: 0 24px; background: var(--panel); border-bottom: 1px solid var(--border); }
.topbar .user { color: var(--muted); } .topbar .user { color: var(--muted); }
.page { padding: 24px; max-width: 1100px; margin: auto; } .page { padding: 24px; }
/* Cards */ /* Cards */
.card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 16px; } .card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 16px; }
.card h3 { margin: 0 0 12px; } .card h3 { margin: 0 0 12px; }
/* Forms */ /* Forms */
.input, select, textarea { width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--border); background: #fff; color: var(--text); } .input, select, textarea {
.input:focus, select:focus, textarea:focus { outline: 2px solid var(--primary); border-color: var(--primary); } width: 100%;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background-color: var(--panel);
color: var(--muted);
/* Add these properties specifically for the select element */
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
padding-right: 32px; /* Add space for the custom arrow */
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-position: right 0.5rem center;
background-repeat: no-repeat;
background-size: 1.5em 1.5em;
cursor: pointer;
}
.input:focus, select:focus, textarea:focus {
outline: 2px solid var(--primary);
outline-offset: 2px;
border-color: var(--primary);
}
.form-row { display: grid; gap: 8px; grid-template-columns: repeat(4, minmax(0,1fr)); } .form-row { display: grid; gap: 8px; grid-template-columns: repeat(4, minmax(0,1fr)); }
.form-row > * { min-width: 140px; } .form-row > * { min-width: 140px; }
.form-row > .btn {
justify-self: start;
}
.actions { display: flex; align-items: center; gap: 8px; } .actions { display: flex; align-items: center; gap: 8px; }
/* Buttons */ /* Buttons */
@@ -59,12 +85,25 @@ body[data-theme="dark"] {
.btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; } .btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; }
.btn.primary:hover { background: var(--primary-600); } .btn.primary:hover { background: var(--primary-600); }
.btn.ghost { background: transparent; color: var(--muted); } .btn.ghost { background: transparent; color: var(--muted); }
.btn, .input, select, textarea, .nav a, .nav button, .segmented button {
transition: all 0.2s ease-in-out;
}
.btn.small {
padding: 4px 10px;
font-size: 0.875rem; /* 14px */
}
/* Tables */ /* Tables */
.table { width: 100%; border-collapse: collapse; } .table { width: 100%; border-collapse: collapse; }
.table th, .table td { padding: 10px; border-bottom: 1px solid var(--border); } .table th, .table td { padding: 10px; border-bottom: 1px solid var(--border); }
.table th { text-align: left; color: var(--muted); font-weight: 600; } .table th { text-align: left; color: var(--muted); font-weight: 600; }
.table td.amount { text-align: right; font-variant-numeric: tabular-nums; } .table td.amount { text-align: right; font-variant-numeric: tabular-nums; }
.table-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px; /* Adds some space above the table */
}
/* Segmented control */ /* Segmented control */
.segmented { display: inline-flex; background: #f1f5f9; border-radius: 10px; padding: 4px; border: 1px solid var(--border); } .segmented { display: inline-flex; background: #f1f5f9; border-radius: 10px; padding: 4px; border: 1px solid var(--border); }
@@ -83,3 +122,32 @@ body.auth-page #root {
/* Utility */ /* Utility */
.muted { color: var(--muted); } .muted { color: var(--muted); }
.space-y > * + * { margin-top: 12px; } .space-y > * + * { margin-top: 12px; }
/* Modal mock bank */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: var(--panel);
padding: 24px;
border-radius: var(--radius);
box-shadow: var(--shadow);
width: 100%;
max-width: 400px;
}
.connection-row {
display: flex;
justify-content: space-between;
align-items: center;
}

View File

@@ -8,7 +8,7 @@ Just copy the template below for each weekly meeting and fill in the details.
## Administrative Info ## Administrative Info
- Date: 2025-10-08 - Date: 2025-10-16
- Attendees: Dejan Ribarovski, Lukas Trkan - Attendees: Dejan Ribarovski, Lukas Trkan
- Notetaker: Dejan Ribarovski - Notetaker: Dejan Ribarovski
@@ -43,8 +43,8 @@ Prepare 3-5 questions and topics you want to discuss with your mentor.
Last 3 minutes of the meeting, summarize action items. Last 3 minutes of the meeting, summarize action items.
- [ ] OAuth - [x] OAuth
- [ ] CI/CD fix - [x] CI/CD fix
- [ ] Database local (multiple bank accounts) - [ ] Database local (multiple bank accounts)
- [ ] Add tests and set up github pipeline - [ ] Add tests and set up github pipeline
- [ ] Frontend imporvment - user experience - [ ] Frontend imporvment - user experience

View File

@@ -0,0 +1,54 @@
# Weekly Meeting Notes
- Group 8 - Personal finance tracker
- Mentor: Jaychander
Keep all meeting notes in the `meetings.md` file in your project folder.
Just copy the template below for each weekly meeting and fill in the details.
## Administrative Info
- Date: 2025-10-23
- Attendees: Dejan
- Notetaker: Dejan
## Progress Update (Before Meeting)
Last 3 minutes of the meeting, summarize action items.
- [x] OAuth (BankID)
- [x] CI/CD fix
- [X] Database local (multiple bank accounts)
- [X] Add tests and set up github pipeline
- [X] Frontend imporvment - user experience
- [ ] make the report more clear - partly
Summary of what has been accomplished since the last meeting in the following categories.
### Coding
Improved Frontend, added Mock Bank, fixed deployment, fixed OAuth(BankID) on production, added basic tests
### Documentation
Not much - just updated the work done
## Questions and Topics for Discussion (Before Meeting)
This was not prepared, I planned to do it right before meeting, but Jaychander needed to go somewhere earlier.
1. Question 1
2. Question 2
3. Question 3
## Discussion Notes (During Meeting)
The tracker should not store the transactions in the database - security vulnerability.
## Action Items for Next Week (During Meeting)
Last 3 minutes of the meeting, summarize action items.
- [ ] Dont store data in database (security) - Load it on login (from CSAS API and local database), load automatically with email
- [ ] Go through the checklist
- [ ] Look for possible APIs (like stocks or financial details whatever)
- [ ] Report
---

View File

@@ -14,7 +14,7 @@
- 289229, Lukáš Trkan, lukastrkan - 289229, Lukáš Trkan, lukastrkan
- 289258, Dejan Ribarovski, derib2613, ribardej - 289258, Dejan Ribarovski, derib2613, ribardej
**Brief Description**: **Brief Description**: (něco spíš jako abstract, introuction, story behind)
Our application is a finance tracker, so a person can easily track his cash flow Our application is a finance tracker, so a person can easily track his cash flow
through multiple bank accounts. Person can label transactions with custom categories through multiple bank accounts. Person can label transactions with custom categories
and later filter by them. and later filter by them.
@@ -338,15 +338,16 @@ curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/authenticated-route
> Link to the specific commit on GitHub for each contribution. > Link to the specific commit on GitHub for each contribution.
### [Team Member 1 Name] ### [Lukáš]
| Date | Activity | Hours | Description | | Date | Activity | Hours | Description |
| --------- | ------------------- | ---------- | ----------------------------------- | |----------------|---------------------|------------|----------------------------------------------------|
| [Date] | Initial Setup | [X.X] | Repository setup, project structure | | 4.10 to 10.10 | Initial Setup | 40 | Repository setup, project structure, cluster setup |
| [Date] | Backend Development | [X.X] | Implemented user authentication | | 14.10 to 16.10 | Backend Development | 12 | Implemented user authentication - oauth |
| [Date] | Testing | [X.X] | Unit tests for API endpoints | | 8.10 to 12.10 | CI/CD | 10 | Created database schema and models |
| [Date] | Documentation | [X.X] | Updated README and design doc | | [Date] | Testing | [X.X] | Unit tests for API endpoints |
| **Total** | | **[XX.X]** | | | [Date] | Documentation | [X.X] | Updated README and design doc |
| **Total** | | **[XX.X]** | |
### Dejan ### Dejan