first ruggedy draft

This commit is contained in:
2026-06-28 13:48:15 +02:00
parent c916f64fcb
commit 1053f6a2a3
78 changed files with 20905 additions and 2 deletions

View File

@@ -0,0 +1 @@
"""FastAPI routers."""

View File

@@ -0,0 +1,66 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import models
from ..db import get_session
from ..schemas import AccountCreate, AccountRead, AccountUpdate
router = APIRouter(tags=["accounts"])
@router.post("/accounts", response_model=AccountRead)
def create_account(
payload: AccountCreate, session: Session = Depends(get_session)
) -> models.Account:
if not session.get(models.Household, payload.household_id):
raise HTTPException(status_code=404, detail="Household not found")
account = models.Account(**payload.model_dump())
session.add(account)
session.commit()
session.refresh(account)
return account
@router.get("/accounts", response_model=list[AccountRead])
def list_accounts(
household_id: str, session: Session = Depends(get_session)
) -> list[models.Account]:
return list(
session.scalars(
select(models.Account)
.where(models.Account.household_id == household_id)
.order_by(models.Account.name)
).all()
)
@router.patch("/accounts/{account_id}", response_model=AccountRead)
def update_account(
account_id: str,
payload: AccountUpdate,
session: Session = Depends(get_session),
) -> models.Account:
account = session.get(models.Account, account_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
for field, value in payload.model_dump(exclude_unset=True).items():
if field == "currency" and value is not None:
value = value.upper()
setattr(account, field, value)
session.add(account)
session.commit()
session.refresh(account)
return account
@router.delete("/accounts/{account_id}")
def delete_account(account_id: str, session: Session = Depends(get_session)) -> dict[str, bool]:
account = session.get(models.Account, account_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
session.delete(account)
session.commit()
return {"deleted": True}

View File

@@ -0,0 +1,73 @@
from __future__ import annotations
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy.orm import Session
from .. import models
from ..auth import (
clear_session_cookie,
create_local_session,
get_optional_local_session,
set_session_cookie,
)
from ..db import get_session
from ..schemas import AuthSessionRead, HouseholdRead, LocalLoginRequest, LocalSessionRead, UserRead
router = APIRouter(tags=["auth"])
def _auth_session_read(
session: Session,
local_session: models.LocalSession,
) -> AuthSessionRead:
user = session.get(models.User, local_session.user_id)
household = session.get(models.Household, local_session.household_id)
if not user or not household:
raise HTTPException(status_code=404, detail="Session user or household not found")
return AuthSessionRead(
session=LocalSessionRead.model_validate(local_session),
user=UserRead.model_validate(user),
household=HouseholdRead.model_validate(household),
)
@router.post("/auth/login", response_model=AuthSessionRead)
def local_login(
payload: LocalLoginRequest,
response: Response,
session: Session = Depends(get_session),
) -> AuthSessionRead:
user = session.get(models.User, payload.user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
local_session, token = create_local_session(session, user)
session.commit()
session.refresh(local_session)
set_session_cookie(response, token)
return _auth_session_read(session, local_session)
@router.get("/auth/me", response_model=AuthSessionRead | None)
def current_local_session(
local_session: models.LocalSession | None = Depends(get_optional_local_session),
session: Session = Depends(get_session),
) -> AuthSessionRead | None:
if not local_session:
return None
return _auth_session_read(session, local_session)
@router.post("/auth/logout")
def logout(
response: Response,
local_session: models.LocalSession | None = Depends(get_optional_local_session),
session: Session = Depends(get_session),
) -> dict[str, bool]:
if local_session:
local_session.revoked_at = datetime.now(UTC)
session.add(local_session)
session.commit()
clear_session_cookie(response)
return {"logged_out": True}

View File

@@ -0,0 +1,221 @@
from __future__ import annotations
from datetime import UTC, datetime
from pathlib import Path
from shutil import copy2
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from .. import models
from ..auth import get_optional_local_session, require_household_access
from ..config import get_settings
from ..db import get_session
from ..schemas import BackupResult, HouseholdExport
router = APIRouter(tags=["backup"])
def _row(model: object) -> dict[str, Any]:
table = model.__table__
return {column.name: getattr(model, column.name) for column in table.columns}
def _rows(items: list[object]) -> list[dict[str, Any]]:
return [_row(item) for item in items]
@router.get("/backup/export", response_model=HouseholdExport)
def export_household(
household_id: str,
session: Session = Depends(get_session),
local_session: models.LocalSession | None = Depends(get_optional_local_session),
) -> HouseholdExport:
require_household_access(household_id, local_session)
household = session.get(models.Household, household_id)
if not household:
raise HTTPException(status_code=404, detail="Household not found")
users = list(session.scalars(select(models.User).where(models.User.household_id == household_id)).all())
accounts = list(
session.scalars(select(models.Account).where(models.Account.household_id == household_id)).all()
)
observations = list(
session.scalars(
select(models.RawObservation).where(models.RawObservation.household_id == household_id)
).all()
)
events = list(
session.scalars(
select(models.FinancialEvent).where(models.FinancialEvent.household_id == household_id)
).all()
)
categories = list(
session.scalars(select(models.Category).where(models.Category.household_id == household_id)).all()
)
tags = list(session.scalars(select(models.Tag).where(models.Tag.household_id == household_id)).all())
projects = list(
session.scalars(select(models.Project).where(models.Project.household_id == household_id)).all()
)
recurring = list(
session.scalars(
select(models.RecurringCommitment).where(models.RecurringCommitment.household_id == household_id)
).all()
)
promised = list(
session.scalars(
select(models.PromisedPayment).where(models.PromisedPayment.household_id == household_id)
).all()
)
connections = list(
session.scalars(
select(models.ImportConnection).where(models.ImportConnection.household_id == household_id)
).all()
)
import_runs = list(
session.scalars(select(models.ImportRun).where(models.ImportRun.household_id == household_id)).all()
)
audit_log = list(
session.scalars(select(models.AuditLog).where(models.AuditLog.household_id == household_id)).all()
)
event_ids = [event.id for event in events]
observation_ids = [observation.id for observation in observations]
project_ids = [project.id for project in projects]
event_links = (
list(
session.scalars(
select(models.EventObservationLink).where(
or_(
models.EventObservationLink.event_id.in_(event_ids),
models.EventObservationLink.observation_id.in_(observation_ids),
)
)
).all()
)
if event_ids or observation_ids
else []
)
event_relations = (
list(
session.scalars(
select(models.EventRelation).where(
or_(
models.EventRelation.from_event_id.in_(event_ids),
models.EventRelation.to_event_id.in_(event_ids),
)
)
).all()
)
if event_ids
else []
)
split_lines = (
list(
session.scalars(select(models.SplitLine).where(models.SplitLine.event_id.in_(event_ids))).all()
)
if event_ids
else []
)
event_tags = (
list(session.scalars(select(models.EventTag).where(models.EventTag.event_id.in_(event_ids))).all())
if event_ids
else []
)
project_participants = (
list(
session.scalars(
select(models.ProjectParticipant).where(models.ProjectParticipant.project_id.in_(project_ids))
).all()
)
if project_ids
else []
)
budget_lines = (
list(
session.scalars(
select(models.ProjectBudgetLine).where(models.ProjectBudgetLine.project_id.in_(project_ids))
).all()
)
if project_ids
else []
)
planned_items = (
list(
session.scalars(
select(models.ProjectPlannedItem).where(models.ProjectPlannedItem.project_id.in_(project_ids))
).all()
)
if project_ids
else []
)
planned_item_ids = [item.id for item in planned_items]
payment_lines = (
list(
session.scalars(
select(models.PaymentLine).where(
or_(
models.PaymentLine.event_id.in_(event_ids),
models.PaymentLine.planned_item_id.in_(planned_item_ids),
)
)
).all()
)
if event_ids or planned_item_ids
else []
)
data = {
"users": _rows(users),
"accounts": _rows(accounts),
"raw_observations": _rows(observations),
"financial_events": _rows(events),
"event_observation_links": _rows(event_links),
"event_relations": _rows(event_relations),
"split_lines": _rows(split_lines),
"categories": _rows(categories),
"tags": _rows(tags),
"event_tags": _rows(event_tags),
"projects": _rows(projects),
"project_participants": _rows(project_participants),
"project_budget_lines": _rows(budget_lines),
"project_planned_items": _rows(planned_items),
"payment_lines": _rows(payment_lines),
"recurring_commitments": _rows(recurring),
"promised_payments": _rows(promised),
"import_connections": _rows(connections),
"import_runs": _rows(import_runs),
"audit_log": _rows(audit_log),
}
return HouseholdExport(
household=_row(household),
exported_at=datetime.now(UTC),
counts={key: len(value) for key, value in data.items()},
data=data,
)
@router.post("/backup/sqlite", response_model=BackupResult)
def backup_sqlite() -> BackupResult:
settings = get_settings()
if not settings.database_url.startswith("sqlite:///"):
raise HTTPException(status_code=400, detail="SQLite backup is only available for sqlite:/// databases")
db_path_text = settings.database_url.replace("sqlite:///", "", 1)
if db_path_text in {"", ":memory:"}:
raise HTTPException(status_code=400, detail="In-memory SQLite databases cannot be backed up to a file")
db_path = Path(db_path_text)
if not db_path.is_absolute():
db_path = Path.cwd() / db_path
if not db_path.exists():
raise HTTPException(status_code=404, detail="SQLite database file not found")
backup_dir = db_path.parent / "backups"
backup_dir.mkdir(parents=True, exist_ok=True)
backup_path = backup_dir / f"{db_path.stem}-{datetime.now(UTC).strftime('%Y%m%d-%H%M%S')}.db"
copy2(db_path, backup_path)
return BackupResult(
file_path=str(backup_path),
size_bytes=backup_path.stat().st_size,
created_at=datetime.now(UTC),
)

View File

@@ -0,0 +1,144 @@
from __future__ import annotations
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException
from mousehold_domain import ImportConnectionStatus, ImportConnectionType
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import models
from ..db import get_session
from ..schemas import (
ImportConnectionCreate,
ImportConnectionRead,
ImportConnectionUpdate,
LocalAgentHeartbeat,
LocalAgentRegister,
)
router = APIRouter(tags=["connections"])
@router.get("/connections/supported")
def supported_connections() -> dict[str, object]:
return {
"connectors": [
{
"type": ImportConnectionType.FINTS,
"runs_locally": True,
"status": "local-agent-ready",
"notes": "PIN/TAN auth runs in the local agent; normalized observations can be synced to the API.",
},
{
"type": ImportConnectionType.PAYPAL_API,
"runs_locally": True,
"status": "experimental-off-ui",
"notes": "PayPal CSV statements and email evidence are the active PoC path.",
},
{
"type": ImportConnectionType.IMAP,
"runs_locally": True,
"status": "scaffold",
"notes": "Local agent can filter Klarna, PayPal, invoices, and order confirmations via IMAP.",
},
{
"type": ImportConnectionType.CSV_FOLDER,
"runs_locally": True,
"status": "ready-shape",
"notes": "CSV rows can be normalized into /observations/sync payloads.",
},
]
}
@router.post("/connections", response_model=ImportConnectionRead)
def create_connection(
payload: ImportConnectionCreate,
session: Session = Depends(get_session),
) -> models.ImportConnection:
connection = models.ImportConnection(**payload.model_dump())
session.add(connection)
session.commit()
session.refresh(connection)
return connection
@router.get("/connections", response_model=list[ImportConnectionRead])
def list_connections(
household_id: str,
session: Session = Depends(get_session),
) -> list[models.ImportConnection]:
return list(
session.scalars(
select(models.ImportConnection)
.where(models.ImportConnection.household_id == household_id)
.order_by(models.ImportConnection.created_at.desc())
).all()
)
@router.patch("/connections/{connection_id}", response_model=ImportConnectionRead)
def update_connection(
connection_id: str,
payload: ImportConnectionUpdate,
session: Session = Depends(get_session),
) -> models.ImportConnection:
connection = session.get(models.ImportConnection, connection_id)
if not connection:
raise HTTPException(status_code=404, detail="Connection not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(connection, field, value)
session.add(connection)
session.commit()
session.refresh(connection)
return connection
@router.delete("/connections/{connection_id}")
def delete_connection(connection_id: str, session: Session = Depends(get_session)) -> dict[str, bool]:
connection = session.get(models.ImportConnection, connection_id)
if not connection:
raise HTTPException(status_code=404, detail="Connection not found")
session.delete(connection)
session.commit()
return {"deleted": True}
@router.post("/local-agent/register", response_model=ImportConnectionRead)
def register_local_agent(
payload: LocalAgentRegister,
session: Session = Depends(get_session),
) -> models.ImportConnection:
connection = models.ImportConnection(
household_id=payload.household_id,
owner_user_id=payload.owner_user_id,
connection_type=ImportConnectionType.LOCAL_AGENT,
display_name=payload.display_name,
status=ImportConnectionStatus.ACTIVE,
runs_locally=True,
connector_config_json={
"capabilities": ["bank_csv", "paypal_csv", "imap", "fints_placeholder"]
},
)
session.add(connection)
session.commit()
session.refresh(connection)
return connection
@router.post("/local-agent/heartbeat", response_model=ImportConnectionRead)
def local_agent_heartbeat(
payload: LocalAgentHeartbeat,
session: Session = Depends(get_session),
) -> models.ImportConnection:
connection = session.get(models.ImportConnection, payload.connection_id)
if not connection:
raise HTTPException(status_code=404, detail="Connection not found")
connection.status = payload.status
connection.last_error = payload.last_error
connection.last_sync_at = datetime.now(UTC)
session.add(connection)
session.commit()
session.refresh(connection)
return connection

View File

@@ -0,0 +1,270 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from mousehold_domain import FinancialEventStatus, RelationType
from mousehold_matching import compute_normalized_transaction_fingerprint
from sqlalchemy import or_, select
from sqlalchemy.orm import Session, selectinload
from .. import models
from ..db import get_session
from ..schemas import (
EventCreate,
EventDetailRead,
EventObservationLinkRead,
EventRead,
EventRelationCreate,
EventRelationRead,
EventTagsUpdate,
EventUpdate,
SplitLineCreate,
SplitLineRead,
TagRead,
)
from ..services.ingestion import confirm_event
router = APIRouter(tags=["events"])
def _get_event(session: Session, event_id: str) -> models.FinancialEvent:
event = session.scalars(
select(models.FinancialEvent)
.where(models.FinancialEvent.id == event_id)
.options(selectinload(models.FinancialEvent.split_lines))
).first()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
_attach_event_tags(session, [event])
return event
def _attach_event_tags(session: Session, events: list[models.FinancialEvent]) -> None:
if not events:
return
event_ids = [event.id for event in events]
rows = session.execute(
select(models.EventTag.event_id, models.EventTag.tag_id).where(
models.EventTag.event_id.in_(event_ids)
)
).all()
tags_by_event: dict[str, list[str]] = {event_id: [] for event_id in event_ids}
for event_id, tag_id in rows:
tags_by_event.setdefault(event_id, []).append(tag_id)
for event in events:
event.tag_ids = tags_by_event.get(event.id, [])
@router.post("/events", response_model=EventRead)
def create_event(
payload: EventCreate, session: Session = Depends(get_session)
) -> models.FinancialEvent:
fingerprint = compute_normalized_transaction_fingerprint(
amount=payload.amount,
currency=payload.currency,
event_date=payload.event_date,
merchant=payload.merchant,
counterparty=payload.counterparty,
)
event = models.FinancialEvent(
**payload.model_dump(exclude={"split_lines"}),
fingerprint=fingerprint,
)
session.add(event)
session.flush()
for split in payload.split_lines:
session.add(models.SplitLine(event_id=event.id, **split.model_dump()))
session.commit()
return _get_event(session, event.id)
@router.get("/events", response_model=list[EventRead])
def list_events(
household_id: str,
status: FinancialEventStatus | None = None,
hide_related_confirmations: bool = False,
session: Session = Depends(get_session),
) -> list[models.FinancialEvent]:
query = (
select(models.FinancialEvent)
.where(models.FinancialEvent.household_id == household_id)
.options(selectinload(models.FinancialEvent.split_lines))
.order_by(models.FinancialEvent.event_date.desc(), models.FinancialEvent.created_at.desc())
)
if status:
query = query.where(models.FinancialEvent.status == status)
if hide_related_confirmations:
household_event_ids = select(models.FinancialEvent.id).where(
models.FinancialEvent.household_id == household_id
)
hidden_ids = select(models.EventRelation.to_event_id).where(
models.EventRelation.from_event_id.in_(household_event_ids),
models.EventRelation.relation_type.in_(
[RelationType.SAME_ECONOMIC_EVENT_AS, RelationType.DUPLICATE_OF]
),
)
query = query.where(models.FinancialEvent.id.not_in(hidden_ids))
events = list(session.scalars(query).all())
_attach_event_tags(session, events)
return events
@router.get("/events/{event_id}", response_model=EventRead)
def get_event(event_id: str, session: Session = Depends(get_session)) -> models.FinancialEvent:
return _get_event(session, event_id)
@router.get("/events/{event_id}/details", response_model=EventDetailRead)
def get_event_details(event_id: str, session: Session = Depends(get_session)) -> EventDetailRead:
event = _get_event(session, event_id)
observation_links = list(
session.scalars(
select(models.EventObservationLink)
.where(models.EventObservationLink.event_id == event_id)
.order_by(models.EventObservationLink.created_at)
).all()
)
observations: list[models.RawObservation] = []
if observation_links:
observations = list(
session.scalars(
select(models.RawObservation).where(
models.RawObservation.id.in_([link.observation_id for link in observation_links])
)
).all()
)
relations = list(
session.scalars(
select(models.EventRelation)
.where(
or_(
models.EventRelation.from_event_id == event_id,
models.EventRelation.to_event_id == event_id,
)
)
.order_by(models.EventRelation.created_at)
).all()
)
related_event_ids = {
relation.to_event_id if relation.from_event_id == event_id else relation.from_event_id
for relation in relations
}
related_events: list[models.FinancialEvent] = []
if related_event_ids:
related_events = list(
session.scalars(
select(models.FinancialEvent)
.where(models.FinancialEvent.id.in_(related_event_ids))
.options(selectinload(models.FinancialEvent.split_lines))
.order_by(models.FinancialEvent.event_date.desc())
).all()
)
_attach_event_tags(session, related_events)
category = session.get(models.Category, event.category_id) if event.category_id else None
tags = (
list(session.scalars(select(models.Tag).where(models.Tag.id.in_(event.tag_ids))).all())
if event.tag_ids
else []
)
return EventDetailRead(
event=event,
category=category,
tags=tags,
observation_links=[EventObservationLinkRead.model_validate(link) for link in observation_links],
observations=observations,
relations=[EventRelationRead.model_validate(relation) for relation in relations],
related_events=related_events,
)
@router.patch("/events/{event_id}", response_model=EventRead)
def update_event(
event_id: str,
payload: EventUpdate,
session: Session = Depends(get_session),
) -> models.FinancialEvent:
event = _get_event(session, event_id)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(event, field, value)
event.fingerprint = compute_normalized_transaction_fingerprint(
amount=event.amount,
currency=event.currency,
event_date=event.event_date,
merchant=event.merchant,
counterparty=event.counterparty,
)
session.add(event)
session.commit()
return _get_event(session, event_id)
@router.patch("/events/{event_id}/tags", response_model=list[TagRead])
def update_event_tags(
event_id: str,
payload: EventTagsUpdate,
session: Session = Depends(get_session),
) -> list[models.Tag]:
event = _get_event(session, event_id)
tags = (
list(
session.scalars(
select(models.Tag).where(
models.Tag.id.in_(payload.tag_ids),
models.Tag.household_id == event.household_id,
)
).all()
)
if payload.tag_ids
else []
)
if len(tags) != len(set(payload.tag_ids)):
raise HTTPException(status_code=400, detail="One or more tags do not belong to this household")
session.query(models.EventTag).filter(models.EventTag.event_id == event_id).delete(
synchronize_session=False
)
session.add_all([models.EventTag(event_id=event_id, tag_id=tag.id) for tag in tags])
session.commit()
return tags
@router.post("/events/{event_id}/confirm", response_model=EventRead)
def confirm_event_endpoint(
event_id: str, session: Session = Depends(get_session)
) -> models.FinancialEvent:
event = confirm_event(session, _get_event(session, event_id))
session.commit()
return _get_event(session, event.id)
@router.post("/events/{event_id}/relations", response_model=EventRelationRead)
def add_event_relation(
event_id: str,
payload: EventRelationCreate,
session: Session = Depends(get_session),
) -> models.EventRelation:
if not session.get(models.FinancialEvent, event_id) or not session.get(
models.FinancialEvent, payload.to_event_id
):
raise HTTPException(status_code=404, detail="Event not found")
relation = models.EventRelation(from_event_id=event_id, **payload.model_dump())
session.add(relation)
session.commit()
session.refresh(relation)
return relation
@router.post("/events/{event_id}/splits", response_model=list[SplitLineRead])
def replace_event_splits(
event_id: str,
payload: list[SplitLineCreate],
session: Session = Depends(get_session),
) -> list[models.SplitLine]:
event = _get_event(session, event_id)
for split in list(event.split_lines):
session.delete(split)
session.flush()
new_splits = [models.SplitLine(event_id=event_id, **split.model_dump()) for split in payload]
session.add_all(new_splits)
session.commit()
return list(
session.scalars(select(models.SplitLine).where(models.SplitLine.event_id == event_id)).all()
)

View File

@@ -0,0 +1,157 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import models
from ..db import get_session
from ..schemas import (
HouseholdCreate,
HouseholdRead,
HouseholdUpdate,
LoginIdentityCreate,
LoginIdentityRead,
UserCreate,
UserRead,
UserUpdate,
)
router = APIRouter(tags=["households"])
@router.post("/households", response_model=HouseholdRead)
def create_household(
payload: HouseholdCreate, session: Session = Depends(get_session)
) -> models.Household:
household = models.Household(
name=payload.name, default_currency=payload.default_currency.upper()
)
session.add(household)
session.commit()
session.refresh(household)
return household
@router.get("/households", response_model=list[HouseholdRead])
def list_households(session: Session = Depends(get_session)) -> list[models.Household]:
return list(
session.scalars(select(models.Household).order_by(models.Household.created_at)).all()
)
@router.get("/households/{household_id}", response_model=HouseholdRead)
def get_household(household_id: str, session: Session = Depends(get_session)) -> models.Household:
household = session.get(models.Household, household_id)
if not household:
raise HTTPException(status_code=404, detail="Household not found")
return household
@router.patch("/households/{household_id}", response_model=HouseholdRead)
def update_household(
household_id: str,
payload: HouseholdUpdate,
session: Session = Depends(get_session),
) -> models.Household:
household = session.get(models.Household, household_id)
if not household:
raise HTTPException(status_code=404, detail="Household not found")
if payload.name is not None:
household.name = payload.name
if payload.default_currency is not None:
household.default_currency = payload.default_currency.upper()
session.add(household)
session.commit()
session.refresh(household)
return household
@router.delete("/households/{household_id}")
def delete_household(household_id: str, session: Session = Depends(get_session)) -> dict[str, bool]:
household = session.get(models.Household, household_id)
if not household:
raise HTTPException(status_code=404, detail="Household not found")
session.delete(household)
session.commit()
return {"deleted": True}
@router.post("/users", response_model=UserRead)
def create_user(payload: UserCreate, session: Session = Depends(get_session)) -> models.User:
if not session.get(models.Household, payload.household_id):
raise HTTPException(status_code=404, detail="Household not found")
user = models.User(
household_id=payload.household_id,
display_name=payload.display_name,
email=payload.email,
)
session.add(user)
session.commit()
session.refresh(user)
return user
@router.get("/users", response_model=list[UserRead])
def list_users(household_id: str, session: Session = Depends(get_session)) -> list[models.User]:
return list(
session.scalars(
select(models.User)
.where(models.User.household_id == household_id)
.order_by(models.User.display_name)
).all()
)
@router.patch("/users/{user_id}", response_model=UserRead)
def update_user(
user_id: str,
payload: UserUpdate,
session: Session = Depends(get_session),
) -> models.User:
user = session.get(models.User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(user, field, value)
session.add(user)
session.commit()
session.refresh(user)
return user
@router.delete("/users/{user_id}")
def delete_user(user_id: str, session: Session = Depends(get_session)) -> dict[str, bool]:
user = session.get(models.User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
session.delete(user)
session.commit()
return {"deleted": True}
@router.post("/login-identities", response_model=LoginIdentityRead)
def create_login_identity(
payload: LoginIdentityCreate,
session: Session = Depends(get_session),
) -> models.LoginIdentity:
if not session.get(models.User, payload.user_id):
raise HTTPException(status_code=404, detail="User not found")
identity = models.LoginIdentity(**payload.model_dump())
session.add(identity)
session.commit()
session.refresh(identity)
return identity
@router.get("/login-identities", response_model=list[LoginIdentityRead])
def list_login_identities(
user_id: str, session: Session = Depends(get_session)
) -> list[models.LoginIdentity]:
return list(
session.scalars(
select(models.LoginIdentity)
.where(models.LoginIdentity.user_id == user_id)
.order_by(models.LoginIdentity.created_at)
).all()
)

View File

@@ -0,0 +1,644 @@
from __future__ import annotations
from datetime import UTC, date, datetime
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException
from mousehold_domain import (
EventObservationRelation,
FinancialEventStatus,
FinancialEventType,
ImportConnectionStatus,
ObservationStatus,
)
from mousehold_matching import compute_normalized_transaction_fingerprint
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from .. import models
from ..auth import get_optional_local_session, require_household_access
from ..db import get_session
from ..schemas import (
EventRead,
ImportRunDetailRead,
ImportRunRead,
ManualObservationCreate,
ObservationAcceptRequest,
ObservationBatchRequest,
ObservationBatchResponse,
ObservationIgnoreRequest,
ObservationImportCreate,
ObservationLinkRequest,
ObservationReviewItem,
ObservationUpdateRequest,
RawObservationRead,
SyncObservationsRequest,
SyncObservationsResponse,
)
from ..services.ingestion import create_observation_with_candidate
from .events import _attach_event_tags
router = APIRouter(tags=["observations"])
def _get_observation(session: Session, observation_id: str) -> models.RawObservation:
observation = session.get(models.RawObservation, observation_id)
if not observation:
raise HTTPException(status_code=404, detail="Observation not found")
return observation
def _events_for_observation(
session: Session, observation_id: str
) -> list[models.FinancialEvent]:
events = list(
session.scalars(
select(models.FinancialEvent)
.join(
models.EventObservationLink,
models.EventObservationLink.event_id == models.FinancialEvent.id,
)
.where(models.EventObservationLink.observation_id == observation_id)
.options(selectinload(models.FinancialEvent.split_lines))
.order_by(models.FinancialEvent.created_at.desc())
).all()
)
_attach_event_tags(session, events)
return events
def _review_item(session: Session, observation: models.RawObservation) -> ObservationReviewItem:
events = _events_for_observation(session, observation.id)
if observation.status == ObservationStatus.DUPLICATE_SOURCE:
suggested_action = "duplicate"
elif observation.status == ObservationStatus.IGNORED:
suggested_action = "ignored"
elif events:
suggested_action = "review_event"
else:
suggested_action = "create_event"
action_required = observation.status not in {
ObservationStatus.IGNORED,
ObservationStatus.DUPLICATE_SOURCE,
}
return ObservationReviewItem(
observation=RawObservationRead.model_validate(observation),
events=[EventRead.model_validate(event) for event in events],
action_required=action_required,
suggested_action=suggested_action,
)
def _date_from_parsed(value: object, field: str) -> date | None:
if not value:
return None
try:
return date.fromisoformat(str(value))
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"Observation has invalid {field}") from exc
def _create_event_from_observation(
session: Session,
observation: models.RawObservation,
event_status: FinancialEventStatus | None,
) -> models.FinancialEvent:
parsed = observation.parsed_json or {}
event_date = _date_from_parsed(parsed.get("event_date"), "event_date")
if not event_date:
raise HTTPException(status_code=400, detail="Observation does not contain an event date")
amount = parsed.get("amount")
if amount is None:
raise HTTPException(status_code=400, detail="Observation does not contain an amount")
event_type = FinancialEventType(str(parsed.get("event_type") or FinancialEventType.EXPENSE))
status = event_status or FinancialEventStatus(str(parsed.get("status") or FinancialEventStatus.CANDIDATE))
fingerprint = compute_normalized_transaction_fingerprint(
amount=Decimal(str(amount)),
currency=str(parsed.get("currency") or "EUR"),
event_date=event_date,
merchant=parsed.get("merchant"),
counterparty=parsed.get("counterparty"),
external_id=observation.provider_transaction_id,
)
event = models.FinancialEvent(
household_id=observation.household_id,
source_account_id=observation.source_account_id,
payment_account_id=observation.source_account_id,
payer_user_id=observation.owner_user_id,
event_type=event_type,
status=status,
event_date=event_date,
booking_date=_date_from_parsed(parsed.get("booking_date"), "booking_date"),
due_date=_date_from_parsed(parsed.get("due_date"), "due_date"),
amount=Decimal(str(amount)),
currency=str(parsed.get("currency") or "EUR"),
merchant=parsed.get("merchant"),
counterparty=parsed.get("counterparty"),
description=parsed.get("description"),
created_by_user_id=observation.owner_user_id,
fingerprint=fingerprint,
)
session.add(event)
session.flush()
session.add(
models.EventObservationLink(
event_id=event.id,
observation_id=observation.id,
relation=EventObservationRelation.IMPORTED_FROM,
confidence=Decimal("1.00"),
)
)
session.flush()
return event
def _require_account(session: Session, household_id: str, account_id: str | None) -> None:
if not account_id:
return
account = session.get(models.Account, account_id)
if not account or account.household_id != household_id:
raise HTTPException(status_code=404, detail="Account not found")
def _require_user(session: Session, household_id: str, user_id: str | None) -> None:
if not user_id:
return
user = session.get(models.User, user_id)
if not user or user.household_id != household_id:
raise HTTPException(status_code=404, detail="User not found")
def _parsed_update_payload(payload: ObservationUpdateRequest) -> dict[str, object]:
parsed_fields = {
"amount",
"currency",
"event_date",
"booking_date",
"due_date",
"merchant",
"counterparty",
"description",
"event_type",
"status",
}
dumped = payload.model_dump(exclude_unset=True, mode="json")
return {key: value for key, value in dumped.items() if key in parsed_fields}
def _sync_event_from_observation(
event: models.FinancialEvent,
observation: models.RawObservation,
parsed_update: dict[str, object],
) -> None:
parsed = observation.parsed_json or {}
if "amount" in parsed_update:
event.amount = Decimal(str(parsed["amount"]))
if "currency" in parsed_update:
event.currency = str(parsed["currency"] or "EUR").upper()
if "event_date" in parsed_update:
parsed_date = _date_from_parsed(parsed.get("event_date"), "event_date")
if parsed_date:
event.event_date = parsed_date
if "booking_date" in parsed_update:
event.booking_date = _date_from_parsed(parsed.get("booking_date"), "booking_date")
if "due_date" in parsed_update:
event.due_date = _date_from_parsed(parsed.get("due_date"), "due_date")
if "merchant" in parsed_update:
event.merchant = parsed.get("merchant")
if "counterparty" in parsed_update:
event.counterparty = parsed.get("counterparty")
if "description" in parsed_update:
event.description = parsed.get("description")
if "event_type" in parsed_update and parsed.get("event_type"):
event.event_type = FinancialEventType(str(parsed["event_type"]))
if "status" in parsed_update and parsed.get("status"):
event.status = FinancialEventStatus(str(parsed["status"]))
event.fingerprint = compute_normalized_transaction_fingerprint(
amount=event.amount,
currency=event.currency,
event_date=event.event_date,
merchant=event.merchant,
counterparty=event.counterparty,
external_id=observation.provider_transaction_id,
)
def _apply_observation_update(
session: Session,
observation: models.RawObservation,
payload: ObservationUpdateRequest,
) -> None:
if "source_account_id" in payload.model_fields_set:
_require_account(session, observation.household_id, payload.source_account_id)
observation.source_account_id = payload.source_account_id
if "owner_user_id" in payload.model_fields_set:
_require_user(session, observation.household_id, payload.owner_user_id)
observation.owner_user_id = payload.owner_user_id
if "raw_text" in payload.model_fields_set:
observation.raw_text = payload.raw_text
parsed_update = _parsed_update_payload(payload)
if parsed_update:
parsed = dict(observation.parsed_json or {})
parsed.update(parsed_update)
if "currency" in parsed and parsed["currency"]:
parsed["currency"] = str(parsed["currency"]).upper()
observation.parsed_json = parsed
session.add(observation)
session.flush()
if payload.sync_linked_events:
for event in _events_for_observation(session, observation.id):
_sync_event_from_observation(event, observation, parsed_update)
if "source_account_id" in payload.model_fields_set:
event.source_account_id = observation.source_account_id
event.payment_account_id = observation.source_account_id
if "owner_user_id" in payload.model_fields_set:
event.payer_user_id = observation.owner_user_id
session.add(event)
session.flush()
def _accept_observation(
session: Session,
observation: models.RawObservation,
event_status: FinancialEventStatus | None,
create_event_if_missing: bool = True,
) -> None:
events = _events_for_observation(session, observation.id)
if not events and create_event_if_missing:
events = [_create_event_from_observation(session, observation, event_status)]
for event in events:
if event_status:
event.status = event_status
session.add(event)
if events and observation.status not in {
ObservationStatus.DUPLICATE_SOURCE,
ObservationStatus.IGNORED,
}:
observation.status = ObservationStatus.CANDIDATE_CREATED
session.add(observation)
session.flush()
def _ignore_observation(
session: Session,
observation: models.RawObservation,
ignore_linked_events: bool,
actor_user_id: str | None,
reason: str | None,
) -> None:
observation.status = ObservationStatus.IGNORED
session.add(observation)
if ignore_linked_events:
for event in _events_for_observation(session, observation.id):
event.status = FinancialEventStatus.IGNORED
session.add(event)
session.add(
models.AuditLog(
household_id=observation.household_id,
actor_user_id=actor_user_id,
action="observation.ignore",
entity_type="raw_observation",
entity_id=observation.id,
details_json={"reason": reason},
)
)
session.flush()
@router.post("/observations/manual", response_model=RawObservationRead)
def create_manual_observation(
payload: ManualObservationCreate,
session: Session = Depends(get_session),
local_session: models.LocalSession | None = Depends(get_optional_local_session),
) -> models.RawObservation:
require_household_access(payload.household_id, local_session)
observation, _event = create_observation_with_candidate(session, payload)
session.commit()
session.refresh(observation)
return observation
@router.post("/observations/import", response_model=RawObservationRead)
def import_observation(
payload: ObservationImportCreate,
session: Session = Depends(get_session),
local_session: models.LocalSession | None = Depends(get_optional_local_session),
) -> models.RawObservation:
require_household_access(payload.household_id, local_session)
observation, _event = create_observation_with_candidate(session, payload)
session.commit()
session.refresh(observation)
return observation
@router.post("/observations/sync", response_model=SyncObservationsResponse)
def sync_observations(
payload: SyncObservationsRequest,
session: Session = Depends(get_session),
local_session: models.LocalSession | None = Depends(get_optional_local_session),
) -> SyncObservationsResponse:
imported_ids: list[str] = []
event_ids: list[str] = []
duplicate_ids: list[str] = []
connection = session.get(models.ImportConnection, payload.connection_id)
household_id = (
payload.observations[0].household_id
if payload.observations
else connection.household_id if connection else None
)
if not household_id:
raise HTTPException(status_code=400, detail="No household available for import")
require_household_access(household_id, local_session)
run = models.ImportRun(
household_id=household_id,
connection_id=connection.id if connection else None,
actor_user_id=local_session.user_id if local_session else None,
source_type=str(payload.observations[0].source_type) if payload.observations else None,
status="running",
summary_json={"requested_count": len(payload.observations)},
)
session.add(run)
session.flush()
for observation_payload in payload.observations:
if observation_payload.household_id != household_id:
raise HTTPException(status_code=400, detail="Import payload mixes households")
observation, event = create_observation_with_candidate(session, observation_payload)
imported_ids.append(observation.id)
if observation.status == ObservationStatus.DUPLICATE_SOURCE:
duplicate_ids.append(observation.id)
if event:
event_ids.append(event.id)
if connection:
connection.status = ImportConnectionStatus.ACTIVE
connection.last_sync_at = datetime.now(UTC)
connection.last_error = None
config = dict(connection.connector_config_json or {})
config["last_imported_count"] = len(imported_ids) - len(duplicate_ids)
config["last_duplicate_count"] = len(duplicate_ids)
config["last_created_event_count"] = len(event_ids)
config["last_import_run_id"] = run.id
connection.connector_config_json = config
session.add(connection)
run.status = "done"
run.imported_count = len(imported_ids) - len(duplicate_ids)
run.duplicate_count = len(duplicate_ids)
run.created_event_count = len(event_ids)
run.finished_at = datetime.now(UTC)
run.summary_json = {
"requested_count": len(payload.observations),
"imported_observation_ids": imported_ids,
"created_event_ids": event_ids,
"duplicate_observation_ids": duplicate_ids,
}
session.add(run)
session.commit()
session.refresh(run)
return SyncObservationsResponse(
imported_observation_ids=imported_ids,
created_event_ids=event_ids,
duplicate_observation_ids=duplicate_ids,
connection_id=connection.id if connection else None,
imported_count=len(imported_ids) - len(duplicate_ids),
duplicate_count=len(duplicate_ids),
created_event_count=len(event_ids),
run=ImportRunRead.model_validate(run),
)
@router.get("/observations/inbox", response_model=list[RawObservationRead])
def get_observation_inbox(
household_id: str,
status: ObservationStatus | None = None,
session: Session = Depends(get_session),
local_session: models.LocalSession | None = Depends(get_optional_local_session),
) -> list[models.RawObservation]:
require_household_access(household_id, local_session)
query = (
select(models.RawObservation)
.where(models.RawObservation.household_id == household_id)
.order_by(models.RawObservation.received_at.desc())
)
if status:
query = query.where(models.RawObservation.status == status)
return list(session.scalars(query).all())
@router.get("/observations/review", response_model=list[ObservationReviewItem])
def get_observation_review_items(
household_id: str,
status: ObservationStatus | None = None,
limit: int = 100,
session: Session = Depends(get_session),
local_session: models.LocalSession | None = Depends(get_optional_local_session),
) -> list[ObservationReviewItem]:
require_household_access(household_id, local_session)
query = (
select(models.RawObservation)
.where(models.RawObservation.household_id == household_id)
.order_by(models.RawObservation.received_at.desc())
.limit(limit)
)
if status:
query = query.where(models.RawObservation.status == status)
return [_review_item(session, observation) for observation in session.scalars(query).all()]
@router.patch("/observations/{observation_id}", response_model=ObservationReviewItem)
def update_observation(
observation_id: str,
payload: ObservationUpdateRequest,
session: Session = Depends(get_session),
local_session: models.LocalSession | None = Depends(get_optional_local_session),
) -> ObservationReviewItem:
observation = _get_observation(session, observation_id)
require_household_access(observation.household_id, local_session)
_apply_observation_update(session, observation, payload)
session.commit()
session.refresh(observation)
return _review_item(session, observation)
@router.post("/observations/{observation_id}/accept", response_model=ObservationReviewItem)
def accept_observation(
observation_id: str,
payload: ObservationAcceptRequest,
session: Session = Depends(get_session),
local_session: models.LocalSession | None = Depends(get_optional_local_session),
) -> ObservationReviewItem:
observation = _get_observation(session, observation_id)
require_household_access(observation.household_id, local_session)
_accept_observation(
session,
observation,
event_status=payload.event_status,
create_event_if_missing=payload.create_event_if_missing,
)
session.commit()
session.refresh(observation)
return _review_item(session, observation)
@router.post("/observations/{observation_id}/ignore", response_model=ObservationReviewItem)
def ignore_observation(
observation_id: str,
payload: ObservationIgnoreRequest,
session: Session = Depends(get_session),
local_session: models.LocalSession | None = Depends(get_optional_local_session),
) -> ObservationReviewItem:
observation = _get_observation(session, observation_id)
require_household_access(observation.household_id, local_session)
_ignore_observation(
session,
observation,
ignore_linked_events=payload.ignore_linked_events,
actor_user_id=local_session.user_id if local_session else None,
reason=payload.reason,
)
session.commit()
session.refresh(observation)
return _review_item(session, observation)
@router.post("/observations/{observation_id}/link", response_model=ObservationReviewItem)
def link_observation(
observation_id: str,
payload: ObservationLinkRequest,
session: Session = Depends(get_session),
local_session: models.LocalSession | None = Depends(get_optional_local_session),
) -> ObservationReviewItem:
observation = _get_observation(session, observation_id)
require_household_access(observation.household_id, local_session)
event = session.get(models.FinancialEvent, payload.event_id)
if not event or event.household_id != observation.household_id:
raise HTTPException(status_code=404, detail="Event not found")
exists = session.scalars(
select(models.EventObservationLink)
.where(models.EventObservationLink.event_id == event.id)
.where(models.EventObservationLink.observation_id == observation.id)
.where(models.EventObservationLink.relation == payload.relation)
.limit(1)
).first()
if not exists:
session.add(
models.EventObservationLink(
event_id=event.id,
observation_id=observation.id,
relation=payload.relation,
confidence=Decimal("1.00"),
)
)
if observation.status == ObservationStatus.NEW:
observation.status = ObservationStatus.PARSED
session.add(observation)
session.commit()
session.refresh(observation)
return _review_item(session, observation)
@router.post("/observations/batch", response_model=ObservationBatchResponse)
def batch_review_observations(
payload: ObservationBatchRequest,
session: Session = Depends(get_session),
local_session: models.LocalSession | None = Depends(get_optional_local_session),
) -> ObservationBatchResponse:
if payload.action not in {"accept", "confirm", "ignore"}:
raise HTTPException(status_code=400, detail="Unsupported batch action")
if not payload.observation_ids:
return ObservationBatchResponse(updated_count=0, items=[])
items: list[ObservationReviewItem] = []
household_id: str | None = None
for observation_id in payload.observation_ids:
observation = _get_observation(session, observation_id)
if household_id is None:
household_id = observation.household_id
require_household_access(household_id, local_session)
elif observation.household_id != household_id:
raise HTTPException(status_code=400, detail="Batch payload mixes households")
if payload.action == "ignore":
_ignore_observation(
session,
observation,
ignore_linked_events=payload.ignore_linked_events,
actor_user_id=local_session.user_id if local_session else None,
reason="Batch review",
)
else:
_accept_observation(
session,
observation,
event_status=FinancialEventStatus.CONFIRMED
if payload.action == "confirm"
else payload.event_status,
create_event_if_missing=True,
)
items.append(_review_item(session, observation))
session.commit()
return ObservationBatchResponse(updated_count=len(items), items=items)
@router.get("/import-runs", response_model=list[ImportRunRead])
def list_import_runs(
household_id: str,
connection_id: str | None = None,
limit: int = 25,
session: Session = Depends(get_session),
local_session: models.LocalSession | None = Depends(get_optional_local_session),
) -> list[models.ImportRun]:
require_household_access(household_id, local_session)
query = (
select(models.ImportRun)
.where(models.ImportRun.household_id == household_id)
.order_by(models.ImportRun.started_at.desc())
.limit(limit)
)
if connection_id:
query = query.where(models.ImportRun.connection_id == connection_id)
return list(session.scalars(query).all())
@router.get("/import-runs/{run_id}", response_model=ImportRunDetailRead)
def get_import_run_detail(
run_id: str,
session: Session = Depends(get_session),
local_session: models.LocalSession | None = Depends(get_optional_local_session),
) -> ImportRunDetailRead:
run = session.get(models.ImportRun, run_id)
if not run:
raise HTTPException(status_code=404, detail="Import run not found")
require_household_access(run.household_id, local_session)
summary = run.summary_json or {}
observation_ids = list(
dict.fromkeys(
[
*summary.get("imported_observation_ids", []),
*summary.get("duplicate_observation_ids", []),
]
)
)
event_ids = summary.get("created_event_ids", [])
observations = (
list(
session.scalars(
select(models.RawObservation).where(models.RawObservation.id.in_(observation_ids))
).all()
)
if observation_ids
else []
)
events = (
list(
session.scalars(
select(models.FinancialEvent)
.where(models.FinancialEvent.id.in_(event_ids))
.options(selectinload(models.FinancialEvent.split_lines))
).all()
)
if event_ids
else []
)
_attach_event_tags(session, events)
return ImportRunDetailRead(
run=ImportRunRead.model_validate(run),
observations=[RawObservationRead.model_validate(observation) for observation in observations],
events=[EventRead.model_validate(event) for event in events],
)

View File

@@ -0,0 +1,150 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import models
from ..db import get_session
from ..schemas import (
PaymentLineCreate,
PaymentLineRead,
ProjectBudgetLineCreate,
ProjectBudgetLineRead,
ProjectCreate,
ProjectParticipantCreate,
ProjectParticipantRead,
ProjectPlannedItemCreate,
ProjectPlannedItemRead,
ProjectRead,
ProjectSummary,
SettlementSummary,
)
from ..services.projects import calculate_project_summary
from ..services.settlement import calculate_settlement
router = APIRouter(tags=["projects"])
def _get_project(session: Session, project_id: str) -> models.Project:
project = session.get(models.Project, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return project
@router.post("/projects", response_model=ProjectRead)
def create_project(
payload: ProjectCreate, session: Session = Depends(get_session)
) -> models.Project:
project = models.Project(**payload.model_dump(exclude={"participant_user_ids"}))
session.add(project)
session.flush()
for user_id in payload.participant_user_ids:
session.add(models.ProjectParticipant(project_id=project.id, user_id=user_id))
session.commit()
session.refresh(project)
return project
@router.get("/projects", response_model=list[ProjectRead])
def list_projects(
household_id: str, session: Session = Depends(get_session)
) -> list[models.Project]:
return list(
session.scalars(
select(models.Project)
.where(models.Project.household_id == household_id)
.order_by(models.Project.created_at.desc())
).all()
)
@router.get("/projects/{project_id}", response_model=ProjectRead)
def get_project(project_id: str, session: Session = Depends(get_session)) -> models.Project:
return _get_project(session, project_id)
@router.post("/projects/{project_id}/participants", response_model=ProjectParticipantRead)
def add_project_participant(
project_id: str,
payload: ProjectParticipantCreate,
session: Session = Depends(get_session),
) -> models.ProjectParticipant:
_get_project(session, project_id)
participant = models.ProjectParticipant(project_id=project_id, **payload.model_dump())
session.add(participant)
session.commit()
session.refresh(participant)
return participant
@router.post("/projects/{project_id}/budget-lines", response_model=ProjectBudgetLineRead)
def add_project_budget_line(
project_id: str,
payload: ProjectBudgetLineCreate,
session: Session = Depends(get_session),
) -> models.ProjectBudgetLine:
_get_project(session, project_id)
budget_line = models.ProjectBudgetLine(project_id=project_id, **payload.model_dump())
session.add(budget_line)
session.commit()
session.refresh(budget_line)
return budget_line
@router.post("/projects/{project_id}/planned-items", response_model=ProjectPlannedItemRead)
def add_project_planned_item(
project_id: str,
payload: ProjectPlannedItemCreate,
session: Session = Depends(get_session),
) -> models.ProjectPlannedItem:
_get_project(session, project_id)
planned_item = models.ProjectPlannedItem(project_id=project_id, **payload.model_dump())
session.add(planned_item)
session.commit()
session.refresh(planned_item)
return planned_item
@router.post("/projects/{project_id}/payment-lines", response_model=PaymentLineRead)
def add_project_payment_line(
project_id: str,
payload: PaymentLineCreate,
session: Session = Depends(get_session),
) -> models.PaymentLine:
_get_project(session, project_id)
if payload.planned_item_id and not session.get(
models.ProjectPlannedItem, payload.planned_item_id
):
raise HTTPException(status_code=404, detail="Planned item not found")
payment_line = models.PaymentLine(**payload.model_dump())
session.add(payment_line)
session.commit()
session.refresh(payment_line)
return payment_line
@router.get("/projects/{project_id}/summary", response_model=ProjectSummary)
def get_project_summary(project_id: str, session: Session = Depends(get_session)) -> ProjectSummary:
try:
return calculate_project_summary(session, project_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.get("/projects/{project_id}/settlement", response_model=SettlementSummary)
def get_project_settlement(
project_id: str,
currency: str = "EUR",
include_announced: bool = False,
session: Session = Depends(get_session),
) -> SettlementSummary:
project = _get_project(session, project_id)
return calculate_settlement(
session,
household_id=project.household_id,
project_id=project_id,
currency=currency,
include_announced=include_announced,
)

View File

@@ -0,0 +1,84 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..db import get_session
from ..schemas import (
ReconciliationAcceptResponse,
ReconciliationDecisionRead,
ReconciliationIgnoreRequest,
ReconciliationIgnoreResponse,
ReconciliationSuggestionRead,
)
from ..services.reconciliation import (
accept_reconciliation_suggestion,
ignore_reconciliation_suggestion,
list_reconciliation_decisions,
list_reconciliation_suggestions,
)
router = APIRouter(tags=["reconciliation"])
@router.get("/reconciliation/suggestions", response_model=list[ReconciliationSuggestionRead])
def get_reconciliation_suggestions(
household_id: str,
limit: int = 50,
session: Session = Depends(get_session),
) -> list[object]:
return list_reconciliation_suggestions(session, household_id=household_id, limit=limit)
@router.post(
"/reconciliation/suggestions/{suggestion_id}/accept",
response_model=ReconciliationAcceptResponse,
)
def accept_suggestion(
suggestion_id: str,
household_id: str,
session: Session = Depends(get_session),
) -> ReconciliationAcceptResponse:
try:
suggestion = accept_reconciliation_suggestion(
session,
household_id=household_id,
suggestion_id=suggestion_id,
)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
session.commit()
return ReconciliationAcceptResponse(accepted=suggestion)
@router.post(
"/reconciliation/suggestions/{suggestion_id}/ignore",
response_model=ReconciliationIgnoreResponse,
)
def ignore_suggestion(
suggestion_id: str,
household_id: str,
payload: ReconciliationIgnoreRequest | None = None,
session: Session = Depends(get_session),
) -> ReconciliationIgnoreResponse:
try:
suggestion, decision = ignore_reconciliation_suggestion(
session,
household_id=household_id,
suggestion_id=suggestion_id,
actor_user_id=payload.actor_user_id if payload else None,
reason=payload.reason if payload else None,
)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
session.commit()
session.refresh(decision)
return ReconciliationIgnoreResponse(ignored=suggestion, decision=decision)
@router.get("/reconciliation/decisions", response_model=list[ReconciliationDecisionRead])
def get_reconciliation_decisions(
household_id: str,
session: Session = Depends(get_session),
) -> list[object]:
return list_reconciliation_decisions(session, household_id=household_id)

View File

@@ -0,0 +1,75 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import models
from ..db import get_session
from ..schemas import (
RecurringCommitmentCreate,
RecurringCommitmentRead,
RecurringCommitmentUpdate,
)
router = APIRouter(tags=["recurring"])
@router.post("/recurring-commitments", response_model=RecurringCommitmentRead)
def create_recurring_commitment(
payload: RecurringCommitmentCreate,
session: Session = Depends(get_session),
) -> models.RecurringCommitment:
if not session.get(models.Household, payload.household_id):
raise HTTPException(status_code=404, detail="Household not found")
commitment = models.RecurringCommitment(**payload.model_dump())
session.add(commitment)
session.commit()
session.refresh(commitment)
return commitment
@router.get("/recurring-commitments", response_model=list[RecurringCommitmentRead])
def list_recurring_commitments(
household_id: str,
session: Session = Depends(get_session),
) -> list[models.RecurringCommitment]:
return list(
session.scalars(
select(models.RecurringCommitment)
.where(models.RecurringCommitment.household_id == household_id)
.order_by(models.RecurringCommitment.next_due_date)
).all()
)
@router.patch("/recurring-commitments/{commitment_id}", response_model=RecurringCommitmentRead)
def update_recurring_commitment(
commitment_id: str,
payload: RecurringCommitmentUpdate,
session: Session = Depends(get_session),
) -> models.RecurringCommitment:
commitment = session.get(models.RecurringCommitment, commitment_id)
if not commitment:
raise HTTPException(status_code=404, detail="Recurring commitment not found")
for field, value in payload.model_dump(exclude_unset=True).items():
if field == "currency" and value is not None:
value = value.upper()
setattr(commitment, field, value)
session.add(commitment)
session.commit()
session.refresh(commitment)
return commitment
@router.delete("/recurring-commitments/{commitment_id}")
def delete_recurring_commitment(
commitment_id: str,
session: Session = Depends(get_session),
) -> dict[str, bool]:
commitment = session.get(models.RecurringCommitment, commitment_id)
if not commitment:
raise HTTPException(status_code=404, detail="Recurring commitment not found")
session.delete(commitment)
session.commit()
return {"deleted": True}

View File

@@ -0,0 +1,27 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from ..db import get_session
from ..schemas import SettlementSummary
from ..services.settlement import calculate_settlement
router = APIRouter(tags=["settlement"])
@router.get("/settlement/summary", response_model=SettlementSummary)
def get_settlement_summary(
household_id: str,
project_id: str | None = None,
currency: str = "EUR",
include_announced: bool = False,
session: Session = Depends(get_session),
) -> SettlementSummary:
return calculate_settlement(
session,
household_id=household_id,
project_id=project_id,
currency=currency,
include_announced=include_announced,
)

View File

@@ -0,0 +1,188 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import models
from ..db import get_session
from ..schemas import (
CategoryCreate,
CategoryRead,
CategoryUpdate,
TagCreate,
TagRead,
TagUpdate,
)
router = APIRouter(tags=["taxonomy"])
def _require_household(session: Session, household_id: str) -> None:
if not session.get(models.Household, household_id):
raise HTTPException(status_code=404, detail="Household not found")
def _category_path(
session: Session,
payload: CategoryCreate | CategoryUpdate,
category: models.Category | None = None,
) -> str:
if payload.path:
return payload.path
name = payload.name if payload.name is not None else category.name if category else ""
parent_id = payload.parent_id if payload.parent_id is not None else category.parent_id if category else None
if parent_id:
parent = session.get(models.Category, parent_id)
if not parent:
raise HTTPException(status_code=404, detail="Parent category not found")
return f"{parent.path}/{name}"
return name
@router.post("/categories", response_model=CategoryRead)
def create_category(
payload: CategoryCreate,
session: Session = Depends(get_session),
) -> models.Category:
_require_household(session, payload.household_id)
if payload.parent_id:
parent = session.get(models.Category, payload.parent_id)
if not parent or parent.household_id != payload.household_id:
raise HTTPException(status_code=404, detail="Parent category not found")
category = models.Category(
household_id=payload.household_id,
parent_id=payload.parent_id,
name=payload.name,
path=_category_path(session, payload),
is_active=payload.is_active,
)
session.add(category)
session.commit()
session.refresh(category)
return category
@router.get("/categories", response_model=list[CategoryRead])
def list_categories(
household_id: str,
include_inactive: bool = False,
session: Session = Depends(get_session),
) -> list[models.Category]:
query = (
select(models.Category)
.where(models.Category.household_id == household_id)
.order_by(models.Category.path)
)
if not include_inactive:
query = query.where(models.Category.is_active.is_(True))
return list(session.scalars(query).all())
@router.patch("/categories/{category_id}", response_model=CategoryRead)
def update_category(
category_id: str,
payload: CategoryUpdate,
session: Session = Depends(get_session),
) -> models.Category:
category = session.get(models.Category, category_id)
if not category:
raise HTTPException(status_code=404, detail="Category not found")
if payload.parent_id:
parent = session.get(models.Category, payload.parent_id)
if not parent or parent.household_id != category.household_id:
raise HTTPException(status_code=404, detail="Parent category not found")
for field, value in payload.model_dump(exclude_unset=True, exclude={"path"}).items():
setattr(category, field, value)
if {"name", "parent_id", "path"} & payload.model_fields_set:
category.path = _category_path(session, payload, category)
session.add(category)
session.commit()
session.refresh(category)
return category
@router.delete("/categories/{category_id}")
def delete_category(
category_id: str,
session: Session = Depends(get_session),
) -> dict[str, bool]:
category = session.get(models.Category, category_id)
if not category:
raise HTTPException(status_code=404, detail="Category not found")
for event in session.scalars(
select(models.FinancialEvent).where(models.FinancialEvent.category_id == category_id)
):
event.category_id = None
for commitment in session.scalars(
select(models.RecurringCommitment).where(models.RecurringCommitment.category_id == category_id)
):
commitment.category_id = None
for budget_line in session.scalars(
select(models.ProjectBudgetLine).where(models.ProjectBudgetLine.category_id == category_id)
):
budget_line.category_id = None
for child in session.scalars(select(models.Category).where(models.Category.parent_id == category_id)):
child.parent_id = None
child.path = child.name
session.delete(category)
session.commit()
return {"deleted": True}
@router.post("/tags", response_model=TagRead)
def create_tag(
payload: TagCreate,
session: Session = Depends(get_session),
) -> models.Tag:
_require_household(session, payload.household_id)
tag = models.Tag(household_id=payload.household_id, name=payload.name)
session.add(tag)
session.commit()
session.refresh(tag)
return tag
@router.get("/tags", response_model=list[TagRead])
def list_tags(
household_id: str,
session: Session = Depends(get_session),
) -> list[models.Tag]:
return list(
session.scalars(
select(models.Tag).where(models.Tag.household_id == household_id).order_by(models.Tag.name)
).all()
)
@router.patch("/tags/{tag_id}", response_model=TagRead)
def update_tag(
tag_id: str,
payload: TagUpdate,
session: Session = Depends(get_session),
) -> models.Tag:
tag = session.get(models.Tag, tag_id)
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(tag, field, value)
session.add(tag)
session.commit()
session.refresh(tag)
return tag
@router.delete("/tags/{tag_id}")
def delete_tag(
tag_id: str,
session: Session = Depends(get_session),
) -> dict[str, bool]:
tag = session.get(models.Tag, tag_id)
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
session.query(models.EventTag).filter(models.EventTag.tag_id == tag_id).delete(
synchronize_session=False
)
session.delete(tag)
session.commit()
return {"deleted": True}