Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions budgetter_server/api/v1/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from fastapi import APIRouter

from budgetter_server.api.v1.endpoints import accounts, transactions, import_ofx

api_router = APIRouter()
api_router.include_router(accounts.router, prefix="/accounts", tags=["accounts"])
api_router.include_router(transactions.router, prefix="/transactions", tags=["transactions"])
api_router.include_router(import_ofx.router, prefix="/import", tags=["import"])
140 changes: 140 additions & 0 deletions budgetter_server/api/v1/endpoints/accounts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, select

from budgetter_server.db.session import get_session
from budgetter_server.models import Account, AccountBase

router = APIRouter()


@router.post("/", response_model=Account)
def create_account(
*,
session: Session = Depends(get_session),
account: AccountBase
) -> Account:
"""
Create a new account.

Args:
session: Database session dependency.
account: Account creation data (AccountBase).

Returns:
Account: The created account object.
"""
db_account = Account.model_validate(account)
session.add(db_account)
session.commit()
session.refresh(db_account)
return db_account


@router.get("/", response_model=list[Account], response_model_exclude={"transactions", "bank"})
def read_accounts(
*,
session: Session = Depends(get_session),
offset: int = 0,
limit: int = 100
) -> list[Account]:
"""
Retrieve a list of accounts.

Args:
session: Database session dependency.
offset: Number of records to skip (pagination).
limit: Maximum number of records to return (pagination).

Returns:
list[Account]: List of accounts.
"""
accounts = session.exec(select(Account).offset(offset).limit(limit)).all()
return accounts


@router.get("/{account_id}", response_model=Account, response_model_exclude={"transactions", "bank"})
def read_account(
*,
session: Session = Depends(get_session),
account_id: int
) -> Account:
"""
Retrieve a specific account by ID.

Args:
session: Database session dependency.
account_id: ID of the account to retrieve.

Returns:
Account: The requested account.

Raises:
HTTPException: If the account is not found.
"""
account = session.get(Account, account_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")

Check failure on line 76 in budgetter_server/api/v1/endpoints/accounts.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "Account not found" 3 times.

See more on https://sonarcloud.io/project/issues?id=opierre_Budgetter-server&issues=AZsTz9QRxv-F_viQNp0e&open=AZsTz9QRxv-F_viQNp0e&pullRequest=1
return account


@router.put("/{account_id}", response_model=Account, response_model_exclude={"transactions", "bank"})
def update_account(
*,
session: Session = Depends(get_session),
account_id: int,
account_update: AccountBase
) -> Account:
"""
Update an account.

Args:
session: Database session dependency.
account_id: ID of the account to update.
account_update: Account update data (AccountBase).

Returns:
Account: The updated account object.

Raises:
HTTPException: If the account is not found.
"""
db_account = session.get(Account, account_id)
if not db_account:
raise HTTPException(status_code=404, detail="Account not found")

account_data = account_update.model_dump(exclude_unset=True)
for key, value in account_data.items():
setattr(db_account, key, value)

session.add(db_account)
session.commit()
session.refresh(db_account)
return db_account


@router.delete("/{account_id}")
def delete_account(
*,
session: Session = Depends(get_session),
account_id: int
):
"""
Delete an account.

Args:
session: Database session dependency.
account_id: ID of the account to delete.

Returns:
dict: Confirmation message.

Raises:
HTTPException: If the account is not found.
"""
account = session.get(Account, account_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")

session.delete(account)
session.commit()
return {"ok": True}
165 changes: 165 additions & 0 deletions budgetter_server/api/v1/endpoints/import_ofx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
from typing import Annotated

from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
from sqlmodel import Session, select
from ofxtools.Parser import OFXTree
from ofxtools.models.bank.stmt import STMTRS
import io

from budgetter_server.db.session import get_session
from budgetter_server.models import Bank, Account, AccountType, Transaction, Mean, TransactionType

router = APIRouter()


@router.post("/ofx", response_model=dict[str, int])
async def import_ofx(

Check failure on line 16 in budgetter_server/api/v1/endpoints/import_ofx.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 79 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=opierre_Budgetter-server&issues=AZsTz9QFxv-F_viQNp0d&open=AZsTz9QFxv-F_viQNp0d&pullRequest=1
file: Annotated[UploadFile, File()],
session: Session = Depends(get_session)
) -> dict[str, int]:
"""
Import transactions from an OFX file.

Args:
file: The OFX file to upload.
session: Database session dependency.

Returns:
dict: Summary including the count of imported transactions.
"""
if not file.filename.endswith(".ofx"):
raise HTTPException(status_code=400, detail="Invalid file type. Only .ofx files are supported.")

content = await file.read()

# Parse OFX
try:
parser = OFXTree()
parser.parse(io.BytesIO(content))
ofx = parser.convert()
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Failed to parse OFX file: {exc}")

imported_count = 0

imported_count = 0

# Use the statements shortcut provided by ofxtools (handles BANKMSGSRSV1 and CREDITCARDMSGSRSV1)
if not hasattr(ofx, "statements"):
# Fallback or empty
return {"imported_count": 0}

for stmt in ofx.statements:
# Determine account info
bank_id_str = "UNKNOWN"
acct_id_str = "UNKNOWN"
acct_type_enum = AccountType.CHECKING

if hasattr(stmt, "bankacctfrom"):
acct_info = stmt.bankacctfrom
bank_id_str = str(acct_info.bankid) if hasattr(acct_info, "bankid") else "UNKNOWN"
acct_id_str = str(acct_info.acctid)
acct_type_node = acct_info.accttype if hasattr(acct_info, "accttype") else "CHECKING"
try:
acct_type_enum = AccountType(str(acct_type_node).upper())
except ValueError:
acct_type_enum = AccountType.CHECKING
elif hasattr(stmt, "ccacctfrom"):
acct_info = stmt.ccacctfrom
bank_id_str = "UNKNOWN_CC"
acct_id_str = str(acct_info.acctid)
acct_type_enum = AccountType.CREDIT_CARD
else:
# Skip if no account info
continue

# Find or Create Bank
bank = session.exec(select(Bank).where(Bank.swift == bank_id_str)).first()
if not bank:
bank = Bank(name=f"Bank {bank_id_str}", swift=bank_id_str)
session.add(bank)
session.commit()
session.refresh(bank)

# Find or Create Account
account = session.exec(select(Account).where(Account.account_id == acct_id_str)).first()
if not account:
# Get balance
balance = 0.0
if hasattr(stmt, "ledgerbal") and hasattr(stmt.ledgerbal, "balamt"):
balance = float(stmt.ledgerbal.balamt)

account = Account(
name=f"Imported {acct_id_str}",
account_id=acct_id_str,
account_type=acct_type_enum,
bank_id=bank.id,
amount=balance
)
session.add(account)
session.commit()
session.refresh(account)

# Process Transactions
transactions = []
if hasattr(stmt, "banktranlist"):
btl = stmt.banktranlist
try:
# Check for stmttrn (list or single)
txns = btl.stmttrn
if not isinstance(txns, list):
txns = [txns]
transactions = txns
except (AttributeError, KeyError):
# Fallback
if hasattr(btl, "__iter__"):
# Try iterating directly
transactions = list(btl)

for txn in transactions:
fitid = str(txn.fitid) if hasattr(txn, "fitid") else None
if not fitid and hasattr(txn, "FITID"): fitid = str(txn.FITID)

if not fitid: continue

existing = session.exec(select(Transaction).where(Transaction.reference == fitid)).first()
if existing:
continue

# Extract fields
t_type = txn.trntype if hasattr(txn, "trntype") else "OTHER"

Check warning on line 130 in budgetter_server/api/v1/endpoints/import_ofx.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the unused local variable "t_type".

See more on https://sonarcloud.io/project/issues?id=opierre_Budgetter-server&issues=AZsTz9QFxv-F_viQNp0c&open=AZsTz9QFxv-F_viQNp0c&pullRequest=1
amount = txn.trnamt if hasattr(txn, "trnamt") else 0

# Date handling
dt = None
if hasattr(txn, "dtposted"):
dt = txn.dtposted
elif hasattr(txn, "dtuser"):
dt = txn.dtuser

if dt and hasattr(dt, "date"):
date_val = dt.date()
else:
import datetime
date_val = datetime.date.today()

name = str(txn.name) if hasattr(txn, "name") else ""
memo = str(txn.memo) if hasattr(txn, "memo") else ""
final_name = name if name else memo

new_txn = Transaction(
name=final_name,
amount=amount,
date=date_val,
mean=Mean.CARD,
transaction_type=TransactionType.EXPENSES if amount < 0 else TransactionType.INCOME,
reference=fitid,
account_id=account.id,
comment=memo
)
session.add(new_txn)
imported_count += 1

session.commit()

return {"imported_count": imported_count}
Loading