first ruggedy draft
This commit is contained in:
1
apps/api/mousehold_api/__init__.py
Normal file
1
apps/api/mousehold_api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
__version__ = "0.1.0"
|
||||
86
apps/api/mousehold_api/auth.py
Normal file
86
apps/api/mousehold_api/auth.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from hashlib import sha256
|
||||
from secrets import token_urlsafe
|
||||
|
||||
from fastapi import Cookie, Depends, HTTPException, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from . import models
|
||||
from .db import get_session
|
||||
|
||||
SESSION_COOKIE = "mousehold_session"
|
||||
SESSION_DAYS = 30
|
||||
|
||||
|
||||
def hash_session_token(token: str) -> str:
|
||||
return sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def create_local_session(session: Session, user: models.User) -> tuple[models.LocalSession, str]:
|
||||
token = token_urlsafe(32)
|
||||
now = datetime.now(UTC)
|
||||
local_session = models.LocalSession(
|
||||
user_id=user.id,
|
||||
household_id=user.household_id,
|
||||
token_hash=hash_session_token(token),
|
||||
created_at=now,
|
||||
last_seen_at=now,
|
||||
expires_at=now + timedelta(days=SESSION_DAYS),
|
||||
)
|
||||
session.add(local_session)
|
||||
session.flush()
|
||||
return local_session, token
|
||||
|
||||
|
||||
def set_session_cookie(response: Response, token: str) -> None:
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=False,
|
||||
max_age=SESSION_DAYS * 24 * 60 * 60,
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def clear_session_cookie(response: Response) -> None:
|
||||
response.delete_cookie(SESSION_COOKIE, path="/")
|
||||
|
||||
|
||||
def get_optional_local_session(
|
||||
session_token: str | None = Cookie(default=None, alias=SESSION_COOKIE),
|
||||
session: Session = Depends(get_session),
|
||||
) -> models.LocalSession | None:
|
||||
if not session_token:
|
||||
return None
|
||||
now = datetime.now(UTC)
|
||||
local_session = session.scalars(
|
||||
select(models.LocalSession)
|
||||
.where(models.LocalSession.token_hash == hash_session_token(session_token))
|
||||
.where(models.LocalSession.revoked_at.is_(None))
|
||||
.where(models.LocalSession.expires_at > now)
|
||||
.limit(1)
|
||||
).first()
|
||||
if local_session:
|
||||
local_session.last_seen_at = now
|
||||
session.add(local_session)
|
||||
session.commit()
|
||||
session.refresh(local_session)
|
||||
return local_session
|
||||
|
||||
|
||||
def require_local_session(
|
||||
local_session: models.LocalSession | None = Depends(get_optional_local_session),
|
||||
) -> models.LocalSession:
|
||||
if not local_session:
|
||||
raise HTTPException(status_code=401, detail="Local session required")
|
||||
return local_session
|
||||
|
||||
|
||||
def require_household_access(household_id: str, local_session: models.LocalSession | None) -> None:
|
||||
if local_session and local_session.household_id != household_id:
|
||||
raise HTTPException(status_code=403, detail="Session does not belong to this household")
|
||||
18
apps/api/mousehold_api/config.py
Normal file
18
apps/api/mousehold_api/config.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
app_name: str = "Mousehold API"
|
||||
api_prefix: str = ""
|
||||
database_url: str = "sqlite:///./data/mousehold.db"
|
||||
cors_origins: list[str] = ["http://localhost:5173", "http://127.0.0.1:5173"]
|
||||
local_agent_token: str = "dev-local-agent-token"
|
||||
|
||||
model_config = SettingsConfigDict(env_prefix="MOUSEHOLD_", env_file=".env", extra="ignore")
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
58
apps/api/mousehold_api/db.py
Normal file
58
apps/api/mousehold_api/db.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def _connect_args(database_url: str) -> dict[str, object]:
|
||||
if database_url.startswith("sqlite"):
|
||||
return {"check_same_thread": False}
|
||||
return {}
|
||||
|
||||
|
||||
def _engine_kwargs(database_url: str) -> dict[str, object]:
|
||||
if database_url in {"sqlite://", "sqlite:///:memory:"}:
|
||||
return {"poolclass": StaticPool}
|
||||
return {}
|
||||
|
||||
|
||||
def _ensure_sqlite_parent(database_url: str) -> None:
|
||||
if not database_url.startswith("sqlite:///"):
|
||||
return
|
||||
db_path = database_url.replace("sqlite:///", "", 1)
|
||||
if db_path not in (":memory:", ""):
|
||||
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
_ensure_sqlite_parent(settings.database_url)
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
connect_args=_connect_args(settings.database_url),
|
||||
**_engine_kwargs(settings.database_url),
|
||||
)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
from . import models # noqa: F401
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
def get_session() -> Generator[Session, None, None]:
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
65
apps/api/mousehold_api/main.py
Normal file
65
apps/api/mousehold_api/main.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from mousehold_agent.server import create_router as create_agent_router
|
||||
|
||||
from .config import get_settings
|
||||
from .db import init_db
|
||||
from .routers import (
|
||||
accounts,
|
||||
auth,
|
||||
backup,
|
||||
connections,
|
||||
events,
|
||||
households,
|
||||
observations,
|
||||
projects,
|
||||
reconciliation,
|
||||
recurring,
|
||||
settlement,
|
||||
taxonomy,
|
||||
)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
app = FastAPI(title=settings.app_name, version="0.1.0")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup() -> None:
|
||||
init_db()
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
app.include_router(households.router, prefix=settings.api_prefix)
|
||||
app.include_router(auth.router, prefix=settings.api_prefix)
|
||||
app.include_router(backup.router, prefix=settings.api_prefix)
|
||||
app.include_router(accounts.router, prefix=settings.api_prefix)
|
||||
app.include_router(observations.router, prefix=settings.api_prefix)
|
||||
app.include_router(events.router, prefix=settings.api_prefix)
|
||||
app.include_router(settlement.router, prefix=settings.api_prefix)
|
||||
app.include_router(reconciliation.router, prefix=settings.api_prefix)
|
||||
app.include_router(projects.router, prefix=settings.api_prefix)
|
||||
app.include_router(recurring.router, prefix=settings.api_prefix)
|
||||
app.include_router(taxonomy.router, prefix=settings.api_prefix)
|
||||
app.include_router(connections.router, prefix=settings.api_prefix)
|
||||
app.include_router(create_agent_router(), prefix=f"{settings.api_prefix.rstrip('/')}/agent")
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
def run() -> None:
|
||||
uvicorn.run("mousehold_api.main:app", host="127.0.0.1", port=8000, reload=True)
|
||||
643
apps/api/mousehold_api/models.py
Normal file
643
apps/api/mousehold_api/models.py
Normal file
@@ -0,0 +1,643 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
from uuid import uuid4
|
||||
|
||||
from mousehold_domain import (
|
||||
AccountType,
|
||||
EventObservationRelation,
|
||||
FinancialEventStatus,
|
||||
FinancialEventType,
|
||||
ImportConnectionStatus,
|
||||
ImportConnectionType,
|
||||
ObservationStatus,
|
||||
PaymentLineStatus,
|
||||
PaymentStatus,
|
||||
PlanningStatus,
|
||||
ProjectStatus,
|
||||
ReconciliationStatus,
|
||||
RecurringCommitmentStatus,
|
||||
RecurringInterval,
|
||||
RelationType,
|
||||
SettlementStatus,
|
||||
SourceType,
|
||||
VisibilityMode,
|
||||
)
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Date,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy import Enum as SqlEnum
|
||||
from sqlalchemy.dialects.sqlite import JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from .db import Base
|
||||
|
||||
|
||||
def new_id() -> str:
|
||||
return str(uuid4())
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
Money = Numeric(14, 2)
|
||||
|
||||
|
||||
class Household(Base):
|
||||
__tablename__ = "households"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
default_currency: Mapped[str] = mapped_column(String(3), default="EUR", nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
|
||||
users: Mapped[list[User]] = relationship(
|
||||
back_populates="household", cascade="all, delete-orphan"
|
||||
)
|
||||
accounts: Mapped[list[Account]] = relationship(
|
||||
back_populates="household", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
household_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("households.id"), nullable=False, index=True
|
||||
)
|
||||
display_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
email: Mapped[str | None] = mapped_column(String(320), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
|
||||
household: Mapped[Household] = relationship(back_populates="users")
|
||||
login_identities: Mapped[list[LoginIdentity]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class LoginIdentity(Base):
|
||||
__tablename__ = "login_identities"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("provider", "provider_subject", name="uq_login_provider_subject"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
|
||||
provider: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
provider_subject: Mapped[str] = mapped_column(String(320), nullable=False)
|
||||
email: Mapped[str | None] = mapped_column(String(320), nullable=True)
|
||||
display_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
|
||||
user: Mapped[User] = relationship(back_populates="login_identities")
|
||||
|
||||
|
||||
class LocalSession(Base):
|
||||
__tablename__ = "local_sessions"
|
||||
__table_args__ = (
|
||||
Index("ix_local_session_token", "token_hash"),
|
||||
Index("ix_local_session_user", "user_id", "expires_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
|
||||
household_id: Mapped[str] = mapped_column(ForeignKey("households.id"), nullable=False, index=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class Account(Base):
|
||||
__tablename__ = "accounts"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
household_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("households.id"), nullable=False, index=True
|
||||
)
|
||||
owner_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=True, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
account_type: Mapped[AccountType] = mapped_column(SqlEnum(AccountType), nullable=False)
|
||||
institution_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
currency: Mapped[str] = mapped_column(String(3), default="EUR", nullable=False)
|
||||
visibility_mode: Mapped[VisibilityMode] = mapped_column(SqlEnum(VisibilityMode), nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
|
||||
household: Mapped[Household] = relationship(back_populates="accounts")
|
||||
|
||||
|
||||
class RawObservation(Base):
|
||||
__tablename__ = "raw_observations"
|
||||
__table_args__ = (
|
||||
Index("ix_observation_dedupe", "household_id", "source_type", "raw_hash"),
|
||||
Index("ix_observation_external_refs", "provider_transaction_id", "message_id"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
household_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("households.id"), nullable=False, index=True
|
||||
)
|
||||
source_type: Mapped[SourceType] = mapped_column(SqlEnum(SourceType), nullable=False, index=True)
|
||||
source_account_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("accounts.id"), nullable=True, index=True
|
||||
)
|
||||
owner_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=True, index=True
|
||||
)
|
||||
observed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
received_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
raw_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
source_reference: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
provider_transaction_id: Mapped[str | None] = mapped_column(
|
||||
String(200), nullable=True, index=True
|
||||
)
|
||||
message_id: Mapped[str | None] = mapped_column(String(500), nullable=True, index=True)
|
||||
raw_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
raw_file_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
raw_payload_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
parsed_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
parser_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
parser_version: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
confidence: Mapped[Decimal] = mapped_column(Money, default=Decimal("1.00"), nullable=False)
|
||||
status: Mapped[ObservationStatus] = mapped_column(
|
||||
SqlEnum(ObservationStatus), default=ObservationStatus.NEW, nullable=False
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
|
||||
event_links: Mapped[list[EventObservationLink]] = relationship(
|
||||
back_populates="observation", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class FinancialEvent(Base):
|
||||
__tablename__ = "financial_events"
|
||||
__table_args__ = (
|
||||
Index("ix_events_household_status_date", "household_id", "status", "event_date"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
household_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("households.id"), nullable=False, index=True
|
||||
)
|
||||
source_account_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("accounts.id"), nullable=True, index=True
|
||||
)
|
||||
payment_account_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("accounts.id"), nullable=True, index=True
|
||||
)
|
||||
payer_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=True, index=True
|
||||
)
|
||||
event_type: Mapped[FinancialEventType] = mapped_column(
|
||||
SqlEnum(FinancialEventType), nullable=False
|
||||
)
|
||||
status: Mapped[FinancialEventStatus] = mapped_column(
|
||||
SqlEnum(FinancialEventStatus), default=FinancialEventStatus.CANDIDATE, nullable=False
|
||||
)
|
||||
event_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
booking_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
|
||||
currency: Mapped[str] = mapped_column(String(3), default="EUR", nullable=False)
|
||||
merchant: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
||||
counterparty: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
category_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("categories.id"), nullable=True, index=True
|
||||
)
|
||||
project_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("projects.id"), nullable=True, index=True
|
||||
)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=True, index=True
|
||||
)
|
||||
fingerprint: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
|
||||
)
|
||||
|
||||
observation_links: Mapped[list[EventObservationLink]] = relationship(
|
||||
back_populates="event", cascade="all, delete-orphan"
|
||||
)
|
||||
split_lines: Mapped[list[SplitLine]] = relationship(
|
||||
back_populates="event", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class EventObservationLink(Base):
|
||||
__tablename__ = "event_observation_links"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"event_id", "observation_id", "relation", name="uq_event_observation_relation"
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
event_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("financial_events.id"), nullable=False, index=True
|
||||
)
|
||||
observation_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("raw_observations.id"), nullable=False, index=True
|
||||
)
|
||||
relation: Mapped[EventObservationRelation] = mapped_column(
|
||||
SqlEnum(EventObservationRelation), nullable=False
|
||||
)
|
||||
confidence: Mapped[Decimal] = mapped_column(Money, default=Decimal("1.00"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
|
||||
event: Mapped[FinancialEvent] = relationship(back_populates="observation_links")
|
||||
observation: Mapped[RawObservation] = relationship(back_populates="event_links")
|
||||
|
||||
|
||||
class EventRelation(Base):
|
||||
__tablename__ = "event_relations"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
from_event_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("financial_events.id"), nullable=False, index=True
|
||||
)
|
||||
to_event_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("financial_events.id"), nullable=False, index=True
|
||||
)
|
||||
relation_type: Mapped[RelationType] = mapped_column(SqlEnum(RelationType), nullable=False)
|
||||
confidence: Mapped[Decimal] = mapped_column(Money, default=Decimal("1.00"), nullable=False)
|
||||
created_by: Mapped[str] = mapped_column(String(40), default="system", nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class ReconciliationDecision(Base):
|
||||
__tablename__ = "reconciliation_decisions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("household_id", "suggestion_id", name="uq_reconciliation_decision"),
|
||||
Index("ix_reconciliation_decision_household", "household_id", "decision"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
household_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("households.id"), nullable=False, index=True
|
||||
)
|
||||
suggestion_id: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
decision: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
relation_type: Mapped[RelationType] = mapped_column(SqlEnum(RelationType), nullable=False)
|
||||
primary_event_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("financial_events.id"), nullable=False, index=True
|
||||
)
|
||||
confirming_event_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("financial_events.id"), nullable=False, index=True
|
||||
)
|
||||
confidence: Mapped[Decimal] = mapped_column(Money, default=Decimal("1.00"), nullable=False)
|
||||
actor_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class SplitLine(Base):
|
||||
__tablename__ = "split_lines"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
event_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("financial_events.id"), nullable=False, index=True
|
||||
)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
|
||||
share_amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
|
||||
share_percent: Mapped[Decimal | None] = mapped_column(Money, nullable=True)
|
||||
reason: Mapped[str] = mapped_column(String(80), default="manual", nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
|
||||
event: Mapped[FinancialEvent] = relationship(back_populates="split_lines")
|
||||
|
||||
|
||||
class Category(Base):
|
||||
__tablename__ = "categories"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
household_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("households.id"), nullable=False, index=True
|
||||
)
|
||||
parent_id: Mapped[str | None] = mapped_column(ForeignKey("categories.id"), nullable=True)
|
||||
name: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
path: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
|
||||
class Tag(Base):
|
||||
__tablename__ = "tags"
|
||||
__table_args__ = (UniqueConstraint("household_id", "name", name="uq_tag_household_name"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
household_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("households.id"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
|
||||
|
||||
class EventTag(Base):
|
||||
__tablename__ = "event_tags"
|
||||
__table_args__ = (UniqueConstraint("event_id", "tag_id", name="uq_event_tag"),)
|
||||
|
||||
event_id: Mapped[str] = mapped_column(ForeignKey("financial_events.id"), primary_key=True)
|
||||
tag_id: Mapped[str] = mapped_column(ForeignKey("tags.id"), primary_key=True)
|
||||
|
||||
|
||||
class Project(Base):
|
||||
__tablename__ = "projects"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
household_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("households.id"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(220), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
default_split_rule_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
status: Mapped[ProjectStatus] = mapped_column(
|
||||
SqlEnum(ProjectStatus), default=ProjectStatus.PLANNING, nullable=False
|
||||
)
|
||||
currency: Mapped[str] = mapped_column(String(3), default="EUR", nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class ProjectParticipant(Base):
|
||||
__tablename__ = "project_participants"
|
||||
__table_args__ = (UniqueConstraint("project_id", "user_id", name="uq_project_participant"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
project_id: Mapped[str] = mapped_column(ForeignKey("projects.id"), nullable=False, index=True)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
|
||||
role: Mapped[str] = mapped_column(String(80), default="participant", nullable=False)
|
||||
default_share_percent: Mapped[Decimal | None] = mapped_column(Money, nullable=True)
|
||||
|
||||
|
||||
class ProjectBudgetLine(Base):
|
||||
__tablename__ = "project_budget_lines"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
project_id: Mapped[str] = mapped_column(ForeignKey("projects.id"), nullable=False, index=True)
|
||||
category_id: Mapped[str | None] = mapped_column(ForeignKey("categories.id"), nullable=True)
|
||||
name: Mapped[str] = mapped_column(String(180), nullable=False)
|
||||
budgeted_amount: Mapped[Decimal] = mapped_column(Money, default=Decimal("0.00"), nullable=False)
|
||||
committed_amount_cached: Mapped[Decimal | None] = mapped_column(Money, nullable=True)
|
||||
paid_amount_cached: Mapped[Decimal | None] = mapped_column(Money, nullable=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class ProjectPlannedItem(Base):
|
||||
__tablename__ = "project_planned_items"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
project_id: Mapped[str] = mapped_column(ForeignKey("projects.id"), nullable=False, index=True)
|
||||
budget_line_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("project_budget_lines.id"), nullable=True, index=True
|
||||
)
|
||||
description: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||
estimated_amount: Mapped[Decimal] = mapped_column(
|
||||
Money, default=Decimal("0.00"), nullable=False
|
||||
)
|
||||
committed_amount: Mapped[Decimal | None] = mapped_column(Money, nullable=True)
|
||||
expected_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
expected_payer_account_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("accounts.id"), nullable=True
|
||||
)
|
||||
split_rule_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
planning_status: Mapped[PlanningStatus] = mapped_column(
|
||||
SqlEnum(PlanningStatus), default=PlanningStatus.IDEA, nullable=False
|
||||
)
|
||||
payment_status: Mapped[PaymentStatus] = mapped_column(
|
||||
SqlEnum(PaymentStatus), default=PaymentStatus.UNPAID, nullable=False
|
||||
)
|
||||
reconciliation_status: Mapped[ReconciliationStatus] = mapped_column(
|
||||
SqlEnum(ReconciliationStatus), default=ReconciliationStatus.NO_EVIDENCE, nullable=False
|
||||
)
|
||||
settlement_status: Mapped[SettlementStatus] = mapped_column(
|
||||
SqlEnum(SettlementStatus), default=SettlementStatus.UNSETTLED, nullable=False
|
||||
)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class PaymentLine(Base):
|
||||
__tablename__ = "payment_lines"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
planned_item_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("project_planned_items.id"), nullable=True, index=True
|
||||
)
|
||||
event_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("financial_events.id"), nullable=True, index=True
|
||||
)
|
||||
amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
|
||||
currency: Mapped[str] = mapped_column(String(3), default="EUR", nullable=False)
|
||||
date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
paid_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=True, index=True
|
||||
)
|
||||
payment_account_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("accounts.id"), nullable=True, index=True
|
||||
)
|
||||
source_observation_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("raw_observations.id"), nullable=True
|
||||
)
|
||||
status: Mapped[PaymentLineStatus] = mapped_column(
|
||||
SqlEnum(PaymentLineStatus), default=PaymentLineStatus.EXPECTED, nullable=False
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class RecurringCommitment(Base):
|
||||
__tablename__ = "recurring_commitments"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
household_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("households.id"), nullable=False, index=True
|
||||
)
|
||||
provider: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
amount_expected: Mapped[Decimal] = mapped_column(Money, nullable=False)
|
||||
amount_min: Mapped[Decimal | None] = mapped_column(Money, nullable=True)
|
||||
amount_max: Mapped[Decimal | None] = mapped_column(Money, nullable=True)
|
||||
currency: Mapped[str] = mapped_column(String(3), default="EUR", nullable=False)
|
||||
interval: Mapped[RecurringInterval] = mapped_column(SqlEnum(RecurringInterval), nullable=False)
|
||||
next_due_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
payer_account_id: Mapped[str | None] = mapped_column(ForeignKey("accounts.id"), nullable=True)
|
||||
split_rule_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
category_id: Mapped[str | None] = mapped_column(ForeignKey("categories.id"), nullable=True)
|
||||
project_id: Mapped[str | None] = mapped_column(ForeignKey("projects.id"), nullable=True)
|
||||
notice_period: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
renewal_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
status: Mapped[RecurringCommitmentStatus] = mapped_column(
|
||||
SqlEnum(RecurringCommitmentStatus), default=RecurringCommitmentStatus.ACTIVE, nullable=False
|
||||
)
|
||||
matching_rule_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class PromisedPayment(Base):
|
||||
__tablename__ = "promised_payments"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
household_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("households.id"), nullable=False, index=True
|
||||
)
|
||||
related_event_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("financial_events.id"), nullable=True, index=True
|
||||
)
|
||||
provider: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
|
||||
currency: Mapped[str] = mapped_column(String(3), default="EUR", nullable=False)
|
||||
created_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
due_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
||||
expected_funding_account_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("accounts.id"), nullable=True
|
||||
)
|
||||
responsible_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(40), default="expected", nullable=False)
|
||||
source_observation_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("raw_observations.id"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class ImportConnection(Base):
|
||||
__tablename__ = "import_connections"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
household_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("households.id"), nullable=False, index=True
|
||||
)
|
||||
owner_user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
|
||||
connection_type: Mapped[ImportConnectionType] = mapped_column(
|
||||
SqlEnum(ImportConnectionType), nullable=False
|
||||
)
|
||||
display_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
status: Mapped[ImportConnectionStatus] = mapped_column(
|
||||
SqlEnum(ImportConnectionStatus), default=ImportConnectionStatus.NEEDS_AUTH, nullable=False
|
||||
)
|
||||
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
secret_storage_ref: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
runs_locally: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
connector_config_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class ImportRun(Base):
|
||||
__tablename__ = "import_runs"
|
||||
__table_args__ = (
|
||||
Index("ix_import_runs_household_started", "household_id", "started_at"),
|
||||
Index("ix_import_runs_connection_started", "connection_id", "started_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
household_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("households.id"), nullable=False, index=True
|
||||
)
|
||||
connection_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("import_connections.id"), nullable=True, index=True
|
||||
)
|
||||
actor_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
source_type: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(40), default="running", nullable=False)
|
||||
imported_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
duplicate_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
created_event_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
skipped_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
summary_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
started_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_log"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
household_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("households.id"), nullable=False, index=True
|
||||
)
|
||||
actor_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
action: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
entity_type: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
entity_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
details_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
1
apps/api/mousehold_api/routers/__init__.py
Normal file
1
apps/api/mousehold_api/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""FastAPI routers."""
|
||||
66
apps/api/mousehold_api/routers/accounts.py
Normal file
66
apps/api/mousehold_api/routers/accounts.py
Normal 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}
|
||||
73
apps/api/mousehold_api/routers/auth.py
Normal file
73
apps/api/mousehold_api/routers/auth.py
Normal 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}
|
||||
221
apps/api/mousehold_api/routers/backup.py
Normal file
221
apps/api/mousehold_api/routers/backup.py
Normal 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),
|
||||
)
|
||||
144
apps/api/mousehold_api/routers/connections.py
Normal file
144
apps/api/mousehold_api/routers/connections.py
Normal 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
|
||||
270
apps/api/mousehold_api/routers/events.py
Normal file
270
apps/api/mousehold_api/routers/events.py
Normal 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()
|
||||
)
|
||||
157
apps/api/mousehold_api/routers/households.py
Normal file
157
apps/api/mousehold_api/routers/households.py
Normal 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()
|
||||
)
|
||||
644
apps/api/mousehold_api/routers/observations.py
Normal file
644
apps/api/mousehold_api/routers/observations.py
Normal 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],
|
||||
)
|
||||
150
apps/api/mousehold_api/routers/projects.py
Normal file
150
apps/api/mousehold_api/routers/projects.py
Normal 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,
|
||||
)
|
||||
84
apps/api/mousehold_api/routers/reconciliation.py
Normal file
84
apps/api/mousehold_api/routers/reconciliation.py
Normal 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)
|
||||
75
apps/api/mousehold_api/routers/recurring.py
Normal file
75
apps/api/mousehold_api/routers/recurring.py
Normal 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}
|
||||
27
apps/api/mousehold_api/routers/settlement.py
Normal file
27
apps/api/mousehold_api/routers/settlement.py
Normal 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,
|
||||
)
|
||||
188
apps/api/mousehold_api/routers/taxonomy.py
Normal file
188
apps/api/mousehold_api/routers/taxonomy.py
Normal 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}
|
||||
782
apps/api/mousehold_api/schemas.py
Normal file
782
apps/api/mousehold_api/schemas.py
Normal file
@@ -0,0 +1,782 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from mousehold_domain import (
|
||||
AccountType,
|
||||
EventObservationRelation,
|
||||
FinancialEventStatus,
|
||||
FinancialEventType,
|
||||
ImportConnectionStatus,
|
||||
ImportConnectionType,
|
||||
ObservationStatus,
|
||||
PaymentLineStatus,
|
||||
PaymentStatus,
|
||||
PlanningStatus,
|
||||
ProjectStatus,
|
||||
ReconciliationStatus,
|
||||
RecurringCommitmentStatus,
|
||||
RecurringInterval,
|
||||
RelationType,
|
||||
SettlementStatus,
|
||||
SourceType,
|
||||
VisibilityMode,
|
||||
)
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ApiModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class HouseholdCreate(ApiModel):
|
||||
name: str
|
||||
default_currency: str = "EUR"
|
||||
|
||||
|
||||
class HouseholdUpdate(ApiModel):
|
||||
name: str | None = None
|
||||
default_currency: str | None = None
|
||||
|
||||
|
||||
class HouseholdRead(ApiModel):
|
||||
id: str
|
||||
name: str
|
||||
default_currency: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class UserCreate(ApiModel):
|
||||
household_id: str
|
||||
display_name: str
|
||||
email: str | None = None
|
||||
|
||||
|
||||
class UserUpdate(ApiModel):
|
||||
display_name: str | None = None
|
||||
email: str | None = None
|
||||
|
||||
|
||||
class UserRead(ApiModel):
|
||||
id: str
|
||||
household_id: str
|
||||
display_name: str
|
||||
email: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class LoginIdentityCreate(ApiModel):
|
||||
user_id: str
|
||||
provider: str
|
||||
provider_subject: str
|
||||
email: str | None = None
|
||||
display_name: str | None = None
|
||||
|
||||
|
||||
class LoginIdentityRead(ApiModel):
|
||||
id: str
|
||||
user_id: str
|
||||
provider: str
|
||||
provider_subject: str
|
||||
email: str | None
|
||||
display_name: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class LocalLoginRequest(ApiModel):
|
||||
user_id: str
|
||||
|
||||
|
||||
class LocalSessionRead(ApiModel):
|
||||
id: str
|
||||
user_id: str
|
||||
household_id: str
|
||||
created_at: datetime
|
||||
last_seen_at: datetime
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class AuthSessionRead(ApiModel):
|
||||
session: LocalSessionRead
|
||||
user: UserRead
|
||||
household: HouseholdRead
|
||||
|
||||
|
||||
class AccountCreate(ApiModel):
|
||||
household_id: str
|
||||
owner_user_id: str | None = None
|
||||
name: str
|
||||
account_type: AccountType
|
||||
institution_name: str | None = None
|
||||
currency: str = "EUR"
|
||||
visibility_mode: VisibilityMode = VisibilityMode.PRIVATE
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class AccountUpdate(ApiModel):
|
||||
owner_user_id: str | None = None
|
||||
name: str | None = None
|
||||
account_type: AccountType | None = None
|
||||
institution_name: str | None = None
|
||||
currency: str | None = None
|
||||
visibility_mode: VisibilityMode | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class AccountRead(ApiModel):
|
||||
id: str
|
||||
household_id: str
|
||||
owner_user_id: str | None
|
||||
name: str
|
||||
account_type: AccountType
|
||||
institution_name: str | None
|
||||
currency: str
|
||||
visibility_mode: VisibilityMode
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class SplitLineCreate(ApiModel):
|
||||
user_id: str
|
||||
share_amount: Decimal
|
||||
share_percent: Decimal | None = None
|
||||
reason: str = "manual"
|
||||
|
||||
|
||||
class SplitLineRead(ApiModel):
|
||||
id: str
|
||||
event_id: str
|
||||
user_id: str
|
||||
share_amount: Decimal
|
||||
share_percent: Decimal | None
|
||||
reason: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ManualObservationCreate(ApiModel):
|
||||
household_id: str
|
||||
source_type: SourceType = SourceType.MANUAL
|
||||
source_account_id: str | None = None
|
||||
owner_user_id: str | None = None
|
||||
observed_at: datetime | None = None
|
||||
source_reference: str | None = None
|
||||
provider_transaction_id: str | None = None
|
||||
message_id: str | None = None
|
||||
raw_text: str | None = None
|
||||
raw_payload_json: dict[str, Any] | None = None
|
||||
amount: Decimal
|
||||
currency: str = "EUR"
|
||||
event_date: date
|
||||
booking_date: date | None = None
|
||||
due_date: date | None = None
|
||||
merchant: str | None = None
|
||||
counterparty: str | None = None
|
||||
description: str | None = None
|
||||
event_type: FinancialEventType = FinancialEventType.EXPENSE
|
||||
status: FinancialEventStatus | None = None
|
||||
payer_user_id: str | None = None
|
||||
payment_account_id: str | None = None
|
||||
category_id: str | None = None
|
||||
project_id: str | None = None
|
||||
split_lines: list[SplitLineCreate] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ObservationImportCreate(ManualObservationCreate):
|
||||
source_type: SourceType
|
||||
|
||||
|
||||
class RawObservationRead(ApiModel):
|
||||
id: str
|
||||
household_id: str
|
||||
source_type: SourceType
|
||||
source_account_id: str | None
|
||||
owner_user_id: str | None
|
||||
observed_at: datetime | None
|
||||
received_at: datetime
|
||||
raw_hash: str
|
||||
source_reference: str | None
|
||||
provider_transaction_id: str | None
|
||||
message_id: str | None
|
||||
raw_text: str | None
|
||||
raw_file_path: str | None
|
||||
raw_payload_json: dict[str, Any] | None
|
||||
parsed_json: dict[str, Any] | None
|
||||
parser_name: str | None
|
||||
parser_version: str | None
|
||||
confidence: Decimal
|
||||
status: ObservationStatus
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class EventObservationLinkRead(ApiModel):
|
||||
id: str
|
||||
event_id: str
|
||||
observation_id: str
|
||||
relation: EventObservationRelation
|
||||
confidence: Decimal
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class EventCreate(ApiModel):
|
||||
household_id: str
|
||||
source_account_id: str | None = None
|
||||
payment_account_id: str | None = None
|
||||
payer_user_id: str | None = None
|
||||
event_type: FinancialEventType = FinancialEventType.EXPENSE
|
||||
status: FinancialEventStatus = FinancialEventStatus.CANDIDATE
|
||||
event_date: date
|
||||
booking_date: date | None = None
|
||||
due_date: date | None = None
|
||||
amount: Decimal
|
||||
currency: str = "EUR"
|
||||
merchant: str | None = None
|
||||
counterparty: str | None = None
|
||||
description: str | None = None
|
||||
category_id: str | None = None
|
||||
project_id: str | None = None
|
||||
created_by_user_id: str | None = None
|
||||
split_lines: list[SplitLineCreate] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EventUpdate(ApiModel):
|
||||
source_account_id: str | None = None
|
||||
payment_account_id: str | None = None
|
||||
payer_user_id: str | None = None
|
||||
event_type: FinancialEventType | None = None
|
||||
status: FinancialEventStatus | None = None
|
||||
event_date: date | None = None
|
||||
booking_date: date | None = None
|
||||
due_date: date | None = None
|
||||
amount: Decimal | None = None
|
||||
currency: str | None = None
|
||||
merchant: str | None = None
|
||||
counterparty: str | None = None
|
||||
description: str | None = None
|
||||
category_id: str | None = None
|
||||
project_id: str | None = None
|
||||
|
||||
|
||||
class EventRead(ApiModel):
|
||||
id: str
|
||||
household_id: str
|
||||
source_account_id: str | None
|
||||
payment_account_id: str | None
|
||||
payer_user_id: str | None
|
||||
event_type: FinancialEventType
|
||||
status: FinancialEventStatus
|
||||
event_date: date
|
||||
booking_date: date | None
|
||||
due_date: date | None
|
||||
amount: Decimal
|
||||
currency: str
|
||||
merchant: str | None
|
||||
counterparty: str | None
|
||||
description: str | None
|
||||
category_id: str | None
|
||||
project_id: str | None
|
||||
created_by_user_id: str | None
|
||||
fingerprint: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
split_lines: list[SplitLineRead] = Field(default_factory=list)
|
||||
tag_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EventRelationCreate(ApiModel):
|
||||
to_event_id: str
|
||||
relation_type: RelationType
|
||||
confidence: Decimal = Decimal("1.00")
|
||||
created_by: str = "user"
|
||||
|
||||
|
||||
class EventRelationRead(ApiModel):
|
||||
id: str
|
||||
from_event_id: str
|
||||
to_event_id: str
|
||||
relation_type: RelationType
|
||||
confidence: Decimal
|
||||
created_by: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CategoryCreate(ApiModel):
|
||||
household_id: str
|
||||
name: str
|
||||
parent_id: str | None = None
|
||||
path: str | None = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class CategoryUpdate(ApiModel):
|
||||
name: str | None = None
|
||||
parent_id: str | None = None
|
||||
path: str | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class CategoryRead(ApiModel):
|
||||
id: str
|
||||
household_id: str
|
||||
parent_id: str | None
|
||||
name: str
|
||||
path: str
|
||||
is_active: bool
|
||||
|
||||
|
||||
class TagCreate(ApiModel):
|
||||
household_id: str
|
||||
name: str
|
||||
|
||||
|
||||
class TagUpdate(ApiModel):
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class TagRead(ApiModel):
|
||||
id: str
|
||||
household_id: str
|
||||
name: str
|
||||
|
||||
|
||||
class EventTagsUpdate(ApiModel):
|
||||
tag_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EventDetailRead(ApiModel):
|
||||
event: EventRead
|
||||
category: CategoryRead | None = None
|
||||
tags: list[TagRead] = Field(default_factory=list)
|
||||
observation_links: list[EventObservationLinkRead] = Field(default_factory=list)
|
||||
observations: list[RawObservationRead] = Field(default_factory=list)
|
||||
relations: list[EventRelationRead] = Field(default_factory=list)
|
||||
related_events: list[EventRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ObservationReviewItem(ApiModel):
|
||||
observation: RawObservationRead
|
||||
events: list[EventRead] = Field(default_factory=list)
|
||||
action_required: bool = True
|
||||
suggested_action: str = "review"
|
||||
|
||||
|
||||
class ObservationAcceptRequest(ApiModel):
|
||||
event_status: FinancialEventStatus | None = None
|
||||
create_event_if_missing: bool = True
|
||||
|
||||
|
||||
class ObservationUpdateRequest(ApiModel):
|
||||
source_account_id: str | None = None
|
||||
owner_user_id: str | None = None
|
||||
raw_text: str | None = None
|
||||
amount: Decimal | None = None
|
||||
currency: str | None = None
|
||||
event_date: date | None = None
|
||||
booking_date: date | None = None
|
||||
due_date: date | None = None
|
||||
merchant: str | None = None
|
||||
counterparty: str | None = None
|
||||
description: str | None = None
|
||||
event_type: FinancialEventType | None = None
|
||||
status: FinancialEventStatus | None = None
|
||||
sync_linked_events: bool = True
|
||||
|
||||
|
||||
class ObservationIgnoreRequest(ApiModel):
|
||||
ignore_linked_events: bool = False
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class ObservationLinkRequest(ApiModel):
|
||||
event_id: str
|
||||
relation: EventObservationRelation = EventObservationRelation.EVIDENCE_FOR
|
||||
|
||||
|
||||
class ObservationBatchRequest(ApiModel):
|
||||
observation_ids: list[str]
|
||||
action: str
|
||||
event_status: FinancialEventStatus | None = None
|
||||
ignore_linked_events: bool = False
|
||||
|
||||
|
||||
class ObservationBatchResponse(ApiModel):
|
||||
updated_count: int
|
||||
items: list[ObservationReviewItem]
|
||||
|
||||
|
||||
class SettlementTransfer(ApiModel):
|
||||
from_user_id: str
|
||||
to_user_id: str
|
||||
amount: Decimal
|
||||
currency: str
|
||||
|
||||
|
||||
class SettlementUserBalance(ApiModel):
|
||||
user_id: str
|
||||
paid: Decimal
|
||||
share: Decimal
|
||||
net: Decimal
|
||||
|
||||
|
||||
class SettlementSummary(ApiModel):
|
||||
household_id: str
|
||||
project_id: str | None
|
||||
currency: str
|
||||
included_event_ids: list[str]
|
||||
excluded_event_ids: list[str]
|
||||
balances: list[SettlementUserBalance]
|
||||
suggested_transfers: list[SettlementTransfer]
|
||||
explanation: list[str]
|
||||
|
||||
|
||||
class ProjectCreate(ApiModel):
|
||||
household_id: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
start_date: date | None = None
|
||||
end_date: date | None = None
|
||||
default_split_rule_id: str | None = None
|
||||
status: ProjectStatus = ProjectStatus.PLANNING
|
||||
currency: str = "EUR"
|
||||
participant_user_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ProjectRead(ApiModel):
|
||||
id: str
|
||||
household_id: str
|
||||
name: str
|
||||
description: str | None
|
||||
start_date: date | None
|
||||
end_date: date | None
|
||||
default_split_rule_id: str | None
|
||||
status: ProjectStatus
|
||||
currency: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ProjectParticipantCreate(ApiModel):
|
||||
user_id: str
|
||||
role: str = "participant"
|
||||
default_share_percent: Decimal | None = None
|
||||
|
||||
|
||||
class ProjectParticipantRead(ApiModel):
|
||||
id: str
|
||||
project_id: str
|
||||
user_id: str
|
||||
role: str
|
||||
default_share_percent: Decimal | None
|
||||
|
||||
|
||||
class ProjectBudgetLineCreate(ApiModel):
|
||||
category_id: str | None = None
|
||||
name: str
|
||||
budgeted_amount: Decimal = Decimal("0.00")
|
||||
sort_order: int = 0
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ProjectBudgetLineRead(ApiModel):
|
||||
id: str
|
||||
project_id: str
|
||||
category_id: str | None
|
||||
name: str
|
||||
budgeted_amount: Decimal
|
||||
committed_amount_cached: Decimal | None
|
||||
paid_amount_cached: Decimal | None
|
||||
sort_order: int
|
||||
notes: str | None
|
||||
|
||||
|
||||
class ProjectPlannedItemCreate(ApiModel):
|
||||
budget_line_id: str | None = None
|
||||
description: str
|
||||
estimated_amount: Decimal = Decimal("0.00")
|
||||
committed_amount: Decimal | None = None
|
||||
expected_date: date | None = None
|
||||
due_date: date | None = None
|
||||
expected_payer_account_id: str | None = None
|
||||
split_rule_id: str | None = None
|
||||
planning_status: PlanningStatus = PlanningStatus.IDEA
|
||||
payment_status: PaymentStatus = PaymentStatus.UNPAID
|
||||
reconciliation_status: ReconciliationStatus = ReconciliationStatus.NO_EVIDENCE
|
||||
settlement_status: SettlementStatus = SettlementStatus.UNSETTLED
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ProjectPlannedItemRead(ApiModel):
|
||||
id: str
|
||||
project_id: str
|
||||
budget_line_id: str | None
|
||||
description: str
|
||||
estimated_amount: Decimal
|
||||
committed_amount: Decimal | None
|
||||
expected_date: date | None
|
||||
due_date: date | None
|
||||
expected_payer_account_id: str | None
|
||||
split_rule_id: str | None
|
||||
planning_status: PlanningStatus
|
||||
payment_status: PaymentStatus
|
||||
reconciliation_status: ReconciliationStatus
|
||||
settlement_status: SettlementStatus
|
||||
notes: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class PaymentLineCreate(ApiModel):
|
||||
planned_item_id: str | None = None
|
||||
event_id: str | None = None
|
||||
amount: Decimal
|
||||
currency: str = "EUR"
|
||||
date: date
|
||||
paid_by_user_id: str | None = None
|
||||
payment_account_id: str | None = None
|
||||
source_observation_id: str | None = None
|
||||
status: PaymentLineStatus = PaymentLineStatus.EXPECTED
|
||||
|
||||
|
||||
class PaymentLineRead(ApiModel):
|
||||
id: str
|
||||
planned_item_id: str | None
|
||||
event_id: str | None
|
||||
amount: Decimal
|
||||
currency: str
|
||||
date: date
|
||||
paid_by_user_id: str | None
|
||||
payment_account_id: str | None
|
||||
source_observation_id: str | None
|
||||
status: PaymentLineStatus
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ProjectSummary(ApiModel):
|
||||
project_id: str
|
||||
currency: str
|
||||
budgeted_total: Decimal
|
||||
committed_total: Decimal
|
||||
paid_total: Decimal
|
||||
reconciled_total: Decimal
|
||||
settled_total: Decimal
|
||||
due_later_total: Decimal
|
||||
remaining_uncommitted_budget: Decimal
|
||||
open_actions: list[str]
|
||||
|
||||
|
||||
class ImportConnectionCreate(ApiModel):
|
||||
household_id: str
|
||||
owner_user_id: str
|
||||
connection_type: ImportConnectionType
|
||||
display_name: str
|
||||
status: ImportConnectionStatus = ImportConnectionStatus.NEEDS_AUTH
|
||||
secret_storage_ref: str | None = None
|
||||
runs_locally: bool = True
|
||||
connector_config_json: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ImportConnectionUpdate(ApiModel):
|
||||
owner_user_id: str | None = None
|
||||
connection_type: ImportConnectionType | None = None
|
||||
display_name: str | None = None
|
||||
status: ImportConnectionStatus | None = None
|
||||
secret_storage_ref: str | None = None
|
||||
runs_locally: bool | None = None
|
||||
connector_config_json: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ImportConnectionRead(ApiModel):
|
||||
id: str
|
||||
household_id: str
|
||||
owner_user_id: str
|
||||
connection_type: ImportConnectionType
|
||||
display_name: str
|
||||
status: ImportConnectionStatus
|
||||
last_sync_at: datetime | None
|
||||
last_error: str | None
|
||||
secret_storage_ref: str | None
|
||||
runs_locally: bool
|
||||
connector_config_json: dict[str, Any] | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ImportRunRead(ApiModel):
|
||||
id: str
|
||||
household_id: str
|
||||
connection_id: str | None
|
||||
actor_user_id: str | None
|
||||
source_type: str | None
|
||||
status: str
|
||||
imported_count: int
|
||||
duplicate_count: int
|
||||
created_event_count: int
|
||||
skipped_count: int
|
||||
error: str | None
|
||||
summary_json: dict[str, Any] | None
|
||||
started_at: datetime
|
||||
finished_at: datetime | None
|
||||
|
||||
|
||||
class ImportRunDetailRead(ApiModel):
|
||||
run: ImportRunRead
|
||||
observations: list[RawObservationRead] = Field(default_factory=list)
|
||||
events: list[EventRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class LocalAgentRegister(ApiModel):
|
||||
household_id: str
|
||||
owner_user_id: str
|
||||
display_name: str = "Local connector agent"
|
||||
|
||||
|
||||
class LocalAgentHeartbeat(ApiModel):
|
||||
connection_id: str
|
||||
status: ImportConnectionStatus = ImportConnectionStatus.ACTIVE
|
||||
last_error: str | None = None
|
||||
|
||||
|
||||
class SyncObservationsRequest(ApiModel):
|
||||
connection_id: str
|
||||
observations: list[ObservationImportCreate]
|
||||
|
||||
|
||||
class SyncObservationsResponse(ApiModel):
|
||||
imported_observation_ids: list[str]
|
||||
created_event_ids: list[str]
|
||||
duplicate_observation_ids: list[str]
|
||||
connection_id: str | None = None
|
||||
imported_count: int = 0
|
||||
duplicate_count: int = 0
|
||||
created_event_count: int = 0
|
||||
run: ImportRunRead | None = None
|
||||
|
||||
|
||||
class BackupResult(ApiModel):
|
||||
file_path: str
|
||||
size_bytes: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class HouseholdExport(ApiModel):
|
||||
household: dict[str, Any]
|
||||
exported_at: datetime
|
||||
counts: dict[str, int]
|
||||
data: dict[str, list[dict[str, Any]]]
|
||||
|
||||
|
||||
class RecurringCommitmentCreate(ApiModel):
|
||||
household_id: str
|
||||
provider: str
|
||||
description: str | None = None
|
||||
amount_expected: Decimal
|
||||
amount_min: Decimal | None = None
|
||||
amount_max: Decimal | None = None
|
||||
currency: str = "EUR"
|
||||
interval: RecurringInterval
|
||||
next_due_date: date
|
||||
payer_account_id: str | None = None
|
||||
category_id: str | None = None
|
||||
project_id: str | None = None
|
||||
notice_period: str | None = None
|
||||
renewal_date: date | None = None
|
||||
status: RecurringCommitmentStatus = RecurringCommitmentStatus.ACTIVE
|
||||
matching_rule_json: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class RecurringCommitmentUpdate(ApiModel):
|
||||
provider: str | None = None
|
||||
description: str | None = None
|
||||
amount_expected: Decimal | None = None
|
||||
amount_min: Decimal | None = None
|
||||
amount_max: Decimal | None = None
|
||||
currency: str | None = None
|
||||
interval: RecurringInterval | None = None
|
||||
next_due_date: date | None = None
|
||||
payer_account_id: str | None = None
|
||||
category_id: str | None = None
|
||||
project_id: str | None = None
|
||||
notice_period: str | None = None
|
||||
renewal_date: date | None = None
|
||||
status: RecurringCommitmentStatus | None = None
|
||||
matching_rule_json: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class RecurringCommitmentRead(ApiModel):
|
||||
id: str
|
||||
household_id: str
|
||||
provider: str
|
||||
description: str | None
|
||||
amount_expected: Decimal
|
||||
amount_min: Decimal | None
|
||||
amount_max: Decimal | None
|
||||
currency: str
|
||||
interval: RecurringInterval
|
||||
next_due_date: date
|
||||
payer_account_id: str | None
|
||||
category_id: str | None
|
||||
project_id: str | None
|
||||
notice_period: str | None
|
||||
renewal_date: date | None
|
||||
status: RecurringCommitmentStatus
|
||||
matching_rule_json: dict[str, Any] | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ReconciliationEventSummary(ApiModel):
|
||||
id: str
|
||||
source_type: str | None
|
||||
event_type: str
|
||||
status: str
|
||||
event_date: date
|
||||
amount: Decimal
|
||||
currency: str
|
||||
merchant: str | None
|
||||
counterparty: str | None
|
||||
description: str | None
|
||||
source_reference: str | None
|
||||
provider_transaction_id: str | None
|
||||
observation_id: str | None
|
||||
|
||||
|
||||
class ReconciliationSuggestionRead(ApiModel):
|
||||
id: str
|
||||
kind: str
|
||||
relation_type: RelationType
|
||||
primary_event: ReconciliationEventSummary
|
||||
confirming_event: ReconciliationEventSummary
|
||||
confidence: Decimal
|
||||
reasons: list[str]
|
||||
action: str
|
||||
|
||||
|
||||
class ReconciliationAcceptResponse(ApiModel):
|
||||
accepted: ReconciliationSuggestionRead
|
||||
|
||||
|
||||
class ReconciliationIgnoreRequest(ApiModel):
|
||||
actor_user_id: str | None = None
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class ReconciliationDecisionRead(ApiModel):
|
||||
id: str
|
||||
household_id: str
|
||||
suggestion_id: str
|
||||
decision: str
|
||||
kind: str
|
||||
relation_type: RelationType
|
||||
primary_event_id: str
|
||||
confirming_event_id: str
|
||||
confidence: Decimal
|
||||
actor_user_id: str | None
|
||||
reason: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ReconciliationIgnoreResponse(ApiModel):
|
||||
ignored: ReconciliationSuggestionRead
|
||||
decision: ReconciliationDecisionRead
|
||||
1
apps/api/mousehold_api/services/__init__.py
Normal file
1
apps/api/mousehold_api/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Application services for ledger ingestion, reconciliation, and summaries."""
|
||||
192
apps/api/mousehold_api/services/ingestion.py
Normal file
192
apps/api/mousehold_api/services/ingestion.py
Normal file
@@ -0,0 +1,192 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from mousehold_domain import (
|
||||
EventObservationRelation,
|
||||
FinancialEventStatus,
|
||||
FinancialEventType,
|
||||
ObservationStatus,
|
||||
SourceType,
|
||||
)
|
||||
from mousehold_matching import (
|
||||
compute_normalized_transaction_fingerprint,
|
||||
compute_raw_observation_hash,
|
||||
)
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import models
|
||||
from ..schemas import ManualObservationCreate, ObservationImportCreate
|
||||
|
||||
|
||||
def infer_event_status(payload: ManualObservationCreate) -> FinancialEventStatus:
|
||||
if payload.status:
|
||||
return payload.status
|
||||
if payload.source_type in {
|
||||
SourceType.BANK_CSV,
|
||||
SourceType.FINTS,
|
||||
SourceType.PSD2_AGGREGATOR,
|
||||
SourceType.PAYPAL_API,
|
||||
SourceType.PAYPAL_CSV,
|
||||
SourceType.KLARNA_CSV,
|
||||
}:
|
||||
return FinancialEventStatus.CONFIRMED
|
||||
if (
|
||||
payload.source_type in {SourceType.KLARNA_EMAIL, SourceType.PAYPAL_EMAIL}
|
||||
and payload.due_date
|
||||
):
|
||||
return FinancialEventStatus.OUTSTANDING
|
||||
if payload.source_type in {
|
||||
SourceType.ORDER_CONFIRMATION_EMAIL,
|
||||
SourceType.IMAP_EMAIL,
|
||||
SourceType.PDF_INVOICE,
|
||||
}:
|
||||
return FinancialEventStatus.ANNOUNCED
|
||||
return FinancialEventStatus.CANDIDATE
|
||||
|
||||
|
||||
def infer_event_type(payload: ManualObservationCreate) -> FinancialEventType:
|
||||
if payload.event_type != FinancialEventType.EXPENSE:
|
||||
return payload.event_type
|
||||
if payload.source_type not in {SourceType.BANK_CSV, SourceType.FINTS, SourceType.PSD2_AGGREGATOR}:
|
||||
return payload.event_type
|
||||
text = f"{payload.merchant or ''} {payload.counterparty or ''} {payload.description or ''}".lower()
|
||||
liability_markers = (
|
||||
"paypal",
|
||||
"klarna",
|
||||
"visa",
|
||||
"mastercard",
|
||||
"amex",
|
||||
"american express",
|
||||
"credit card",
|
||||
"kreditkarte",
|
||||
"kartenabrechnung",
|
||||
"card repayment",
|
||||
)
|
||||
if any(marker in text for marker in liability_markers):
|
||||
return FinancialEventType.LIABILITY_PAYMENT
|
||||
return payload.event_type
|
||||
|
||||
|
||||
def create_observation_with_candidate(
|
||||
session: Session,
|
||||
payload: ManualObservationCreate | ObservationImportCreate,
|
||||
) -> tuple[models.RawObservation, models.FinancialEvent | None]:
|
||||
raw_payload = payload.raw_payload_json or payload.model_dump(
|
||||
mode="json", exclude={"split_lines"}
|
||||
)
|
||||
event_type = infer_event_type(payload)
|
||||
event_status = infer_event_status(payload)
|
||||
raw_hash = compute_raw_observation_hash(
|
||||
source_type=str(payload.source_type),
|
||||
source_reference=payload.source_reference
|
||||
or payload.provider_transaction_id
|
||||
or payload.message_id,
|
||||
raw_text=payload.raw_text,
|
||||
raw_payload=raw_payload,
|
||||
)
|
||||
|
||||
duplicate = session.scalars(
|
||||
select(models.RawObservation)
|
||||
.where(models.RawObservation.household_id == payload.household_id)
|
||||
.where(models.RawObservation.source_type == payload.source_type)
|
||||
.where(models.RawObservation.raw_hash == raw_hash)
|
||||
.limit(1)
|
||||
).first()
|
||||
|
||||
observation = models.RawObservation(
|
||||
household_id=payload.household_id,
|
||||
source_type=payload.source_type,
|
||||
source_account_id=payload.source_account_id,
|
||||
owner_user_id=payload.owner_user_id,
|
||||
observed_at=payload.observed_at,
|
||||
raw_hash=raw_hash,
|
||||
source_reference=payload.source_reference,
|
||||
provider_transaction_id=payload.provider_transaction_id,
|
||||
message_id=payload.message_id,
|
||||
raw_text=payload.raw_text,
|
||||
raw_payload_json=raw_payload,
|
||||
parsed_json={
|
||||
"amount": str(payload.amount),
|
||||
"currency": payload.currency,
|
||||
"event_date": payload.event_date.isoformat(),
|
||||
"booking_date": payload.booking_date.isoformat() if payload.booking_date else None,
|
||||
"due_date": payload.due_date.isoformat() if payload.due_date else None,
|
||||
"merchant": payload.merchant,
|
||||
"counterparty": payload.counterparty,
|
||||
"description": payload.description,
|
||||
"event_type": str(event_type),
|
||||
"status": str(event_status),
|
||||
},
|
||||
parser_name="manual-or-normalized-import",
|
||||
parser_version="0.1.0",
|
||||
confidence=Decimal("1.00"),
|
||||
status=ObservationStatus.DUPLICATE_SOURCE
|
||||
if duplicate
|
||||
else ObservationStatus.CANDIDATE_CREATED,
|
||||
)
|
||||
session.add(observation)
|
||||
session.flush()
|
||||
|
||||
if duplicate:
|
||||
return observation, None
|
||||
|
||||
fingerprint = compute_normalized_transaction_fingerprint(
|
||||
amount=payload.amount,
|
||||
currency=payload.currency,
|
||||
event_date=payload.event_date,
|
||||
merchant=payload.merchant,
|
||||
counterparty=payload.counterparty,
|
||||
external_id=payload.provider_transaction_id,
|
||||
)
|
||||
event = models.FinancialEvent(
|
||||
household_id=payload.household_id,
|
||||
source_account_id=payload.source_account_id,
|
||||
payment_account_id=payload.payment_account_id or payload.source_account_id,
|
||||
payer_user_id=payload.payer_user_id or payload.owner_user_id,
|
||||
event_type=event_type,
|
||||
status=event_status,
|
||||
event_date=payload.event_date,
|
||||
booking_date=payload.booking_date,
|
||||
due_date=payload.due_date,
|
||||
amount=payload.amount,
|
||||
currency=payload.currency,
|
||||
merchant=payload.merchant,
|
||||
counterparty=payload.counterparty,
|
||||
description=payload.description,
|
||||
category_id=payload.category_id,
|
||||
project_id=payload.project_id,
|
||||
created_by_user_id=payload.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"),
|
||||
)
|
||||
)
|
||||
for split in payload.split_lines:
|
||||
session.add(
|
||||
models.SplitLine(
|
||||
event_id=event.id,
|
||||
user_id=split.user_id,
|
||||
share_amount=split.share_amount,
|
||||
share_percent=split.share_percent,
|
||||
reason=split.reason,
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
return observation, event
|
||||
|
||||
|
||||
def confirm_event(session: Session, event: models.FinancialEvent) -> models.FinancialEvent:
|
||||
event.status = FinancialEventStatus.CONFIRMED
|
||||
session.add(event)
|
||||
session.flush()
|
||||
return event
|
||||
136
apps/api/mousehold_api/services/projects.py
Normal file
136
apps/api/mousehold_api/services/projects.py
Normal file
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from mousehold_domain import (
|
||||
FinancialEventStatus,
|
||||
PaymentLineStatus,
|
||||
PaymentStatus,
|
||||
PlanningStatus,
|
||||
ReconciliationStatus,
|
||||
SettlementStatus,
|
||||
)
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import models
|
||||
from ..schemas import ProjectSummary
|
||||
|
||||
|
||||
def _money(value: Decimal | int | str = "0.00") -> Decimal:
|
||||
return Decimal(value).quantize(Decimal("0.01"))
|
||||
|
||||
|
||||
def calculate_project_summary(session: Session, project_id: str) -> ProjectSummary:
|
||||
project = session.get(models.Project, project_id)
|
||||
if not project:
|
||||
raise ValueError("Project not found")
|
||||
|
||||
budget_lines = list(
|
||||
session.scalars(
|
||||
select(models.ProjectBudgetLine).where(
|
||||
models.ProjectBudgetLine.project_id == project_id
|
||||
)
|
||||
).all()
|
||||
)
|
||||
planned_items = list(
|
||||
session.scalars(
|
||||
select(models.ProjectPlannedItem).where(
|
||||
models.ProjectPlannedItem.project_id == project_id
|
||||
)
|
||||
).all()
|
||||
)
|
||||
payment_lines = list(
|
||||
session.scalars(
|
||||
select(models.PaymentLine)
|
||||
.join(
|
||||
models.ProjectPlannedItem,
|
||||
models.PaymentLine.planned_item_id == models.ProjectPlannedItem.id,
|
||||
)
|
||||
.where(models.ProjectPlannedItem.project_id == project_id)
|
||||
).all()
|
||||
)
|
||||
linked_events = list(
|
||||
session.scalars(
|
||||
select(models.FinancialEvent).where(models.FinancialEvent.project_id == project_id)
|
||||
).all()
|
||||
)
|
||||
|
||||
budgeted_total = _money(sum((line.budgeted_amount for line in budget_lines), Decimal("0.00")))
|
||||
committed_total = _money(
|
||||
sum(
|
||||
(
|
||||
item.committed_amount
|
||||
if item.committed_amount is not None
|
||||
else item.estimated_amount
|
||||
if item.planning_status == PlanningStatus.COMMITTED
|
||||
else Decimal("0.00")
|
||||
)
|
||||
for item in planned_items
|
||||
)
|
||||
)
|
||||
paid_total = _money(
|
||||
sum(
|
||||
(
|
||||
line.amount
|
||||
for line in payment_lines
|
||||
if line.status in {PaymentLineStatus.PAID, PaymentLineStatus.RECONCILED}
|
||||
),
|
||||
Decimal("0.00"),
|
||||
)
|
||||
+ sum(
|
||||
(
|
||||
event.amount
|
||||
for event in linked_events
|
||||
if event.status
|
||||
in {
|
||||
FinancialEventStatus.CONFIRMED,
|
||||
FinancialEventStatus.BOOKED,
|
||||
FinancialEventStatus.SETTLED,
|
||||
}
|
||||
),
|
||||
Decimal("0.00"),
|
||||
)
|
||||
)
|
||||
reconciled_total = _money(
|
||||
sum(
|
||||
(line.amount for line in payment_lines if line.status == PaymentLineStatus.RECONCILED),
|
||||
Decimal("0.00"),
|
||||
)
|
||||
)
|
||||
settled_total = _money(
|
||||
sum(
|
||||
(
|
||||
item.committed_amount or item.estimated_amount
|
||||
for item in planned_items
|
||||
if item.settlement_status == SettlementStatus.SETTLED
|
||||
),
|
||||
Decimal("0.00"),
|
||||
)
|
||||
)
|
||||
due_later_total = _money(max(Decimal("0.00"), committed_total - paid_total))
|
||||
remaining_uncommitted_budget = _money(budgeted_total - committed_total)
|
||||
|
||||
open_actions: list[str] = []
|
||||
for item in planned_items:
|
||||
expected = item.committed_amount or item.estimated_amount
|
||||
if item.planning_status == PlanningStatus.COMMITTED and item.payment_status in {
|
||||
PaymentStatus.UNPAID,
|
||||
PaymentStatus.PARTIALLY_PAID,
|
||||
}:
|
||||
open_actions.append(f"{item.description} still has payment due.")
|
||||
if item.reconciliation_status == ReconciliationStatus.NO_EVIDENCE and expected > 0:
|
||||
open_actions.append(f"{item.description} needs evidence.")
|
||||
|
||||
return ProjectSummary(
|
||||
project_id=project.id,
|
||||
currency=project.currency,
|
||||
budgeted_total=budgeted_total,
|
||||
committed_total=committed_total,
|
||||
paid_total=paid_total,
|
||||
reconciled_total=reconciled_total,
|
||||
settled_total=settled_total,
|
||||
due_later_total=due_later_total,
|
||||
remaining_uncommitted_budget=remaining_uncommitted_budget,
|
||||
open_actions=open_actions,
|
||||
)
|
||||
532
apps/api/mousehold_api/services/reconciliation.py
Normal file
532
apps/api/mousehold_api/services/reconciliation.py
Normal file
@@ -0,0 +1,532 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from mousehold_domain import (
|
||||
EventObservationRelation,
|
||||
FinancialEventStatus,
|
||||
RelationType,
|
||||
SourceType,
|
||||
)
|
||||
from mousehold_matching import score_event_match
|
||||
from mousehold_matching.fingerprints import normalize_counterparty
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from .. import models
|
||||
|
||||
ANNOUNCEMENT_SOURCES = {
|
||||
SourceType.PAYPAL_EMAIL,
|
||||
SourceType.KLARNA_EMAIL,
|
||||
SourceType.ORDER_CONFIRMATION_EMAIL,
|
||||
SourceType.IMAP_EMAIL,
|
||||
}
|
||||
CONFIRMATION_SOURCES = {
|
||||
SourceType.PAYPAL_API,
|
||||
SourceType.PAYPAL_CSV,
|
||||
SourceType.KLARNA_CSV,
|
||||
SourceType.BANK_CSV,
|
||||
SourceType.FINTS,
|
||||
SourceType.PSD2_AGGREGATOR,
|
||||
}
|
||||
BANK_CONFIRMATION_SOURCES = {SourceType.BANK_CSV, SourceType.FINTS, SourceType.PSD2_AGGREGATOR}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EventSummary:
|
||||
id: str
|
||||
source_type: str | None
|
||||
event_type: str
|
||||
status: str
|
||||
event_date: date
|
||||
amount: Decimal
|
||||
currency: str
|
||||
merchant: str | None
|
||||
counterparty: str | None
|
||||
description: str | None
|
||||
source_reference: str | None
|
||||
provider_transaction_id: str | None
|
||||
observation_id: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReconciliationSuggestion:
|
||||
id: str
|
||||
kind: str
|
||||
relation_type: RelationType
|
||||
primary_event: EventSummary
|
||||
confirming_event: EventSummary
|
||||
confidence: Decimal
|
||||
reasons: list[str]
|
||||
action: str
|
||||
|
||||
|
||||
def list_reconciliation_suggestions(
|
||||
session: Session,
|
||||
*,
|
||||
household_id: str,
|
||||
limit: int = 50,
|
||||
include_ignored: bool = False,
|
||||
) -> list[ReconciliationSuggestion]:
|
||||
events = _events_with_observations(session, household_id=household_id)
|
||||
suggestions: list[ReconciliationSuggestion] = []
|
||||
existing_pairs = _existing_relation_pairs(session, household_id=household_id)
|
||||
ignored_suggestion_ids = (
|
||||
set() if include_ignored else _decision_suggestion_ids(session, household_id=household_id, decision="ignored")
|
||||
)
|
||||
|
||||
for primary in events:
|
||||
primary_source = _first_source_type(primary)
|
||||
if primary_source not in ANNOUNCEMENT_SOURCES:
|
||||
continue
|
||||
if primary.status in {
|
||||
FinancialEventStatus.CONFIRMED,
|
||||
FinancialEventStatus.CANCELLED,
|
||||
FinancialEventStatus.IGNORED,
|
||||
}:
|
||||
continue
|
||||
for confirming in events:
|
||||
if primary.id == confirming.id or (primary.id, confirming.id) in existing_pairs:
|
||||
continue
|
||||
confirming_source = _first_source_type(confirming)
|
||||
if confirming_source not in CONFIRMATION_SOURCES:
|
||||
continue
|
||||
suggestion = _suggest_event_pair(primary, confirming)
|
||||
if suggestion:
|
||||
suggestions.append(suggestion)
|
||||
|
||||
for source in events:
|
||||
source_type = _first_source_type(source)
|
||||
if source_type not in {SourceType.PAYPAL_CSV, SourceType.PAYPAL_API, SourceType.KLARNA_CSV}:
|
||||
continue
|
||||
for bank_event in events:
|
||||
if source.id == bank_event.id or (source.id, bank_event.id) in existing_pairs:
|
||||
continue
|
||||
if _first_source_type(bank_event) not in BANK_CONFIRMATION_SOURCES:
|
||||
continue
|
||||
suggestion = _suggest_event_pair(source, bank_event, kind="statement_bank")
|
||||
if suggestion:
|
||||
suggestions.append(suggestion)
|
||||
|
||||
unique: dict[str, ReconciliationSuggestion] = {}
|
||||
for suggestion in sorted(suggestions, key=lambda item: item.confidence, reverse=True):
|
||||
if suggestion.id not in ignored_suggestion_ids:
|
||||
unique.setdefault(suggestion.id, suggestion)
|
||||
return list(unique.values())[:limit]
|
||||
|
||||
|
||||
def accept_reconciliation_suggestion(
|
||||
session: Session,
|
||||
*,
|
||||
household_id: str,
|
||||
suggestion_id: str,
|
||||
) -> ReconciliationSuggestion:
|
||||
suggestions = list_reconciliation_suggestions(session, household_id=household_id, limit=500)
|
||||
suggestion = next((item for item in suggestions if item.id == suggestion_id), None)
|
||||
if suggestion is None:
|
||||
raise LookupError("Reconciliation suggestion not found")
|
||||
|
||||
primary = session.get(models.FinancialEvent, suggestion.primary_event.id)
|
||||
confirming = session.get(models.FinancialEvent, suggestion.confirming_event.id)
|
||||
if not primary or not confirming:
|
||||
raise LookupError("Reconciliation event not found")
|
||||
if primary.household_id != household_id or confirming.household_id != household_id:
|
||||
raise LookupError("Reconciliation event not found")
|
||||
|
||||
_ensure_event_relation(
|
||||
session,
|
||||
from_event_id=primary.id,
|
||||
to_event_id=confirming.id,
|
||||
relation_type=suggestion.relation_type,
|
||||
confidence=suggestion.confidence,
|
||||
)
|
||||
if suggestion.confirming_event.observation_id:
|
||||
_ensure_observation_link(
|
||||
session,
|
||||
event_id=primary.id,
|
||||
observation_id=suggestion.confirming_event.observation_id,
|
||||
relation=EventObservationRelation.EVIDENCE_FOR,
|
||||
confidence=suggestion.confidence,
|
||||
)
|
||||
if suggestion.primary_event.observation_id:
|
||||
_ensure_observation_link(
|
||||
session,
|
||||
event_id=confirming.id,
|
||||
observation_id=suggestion.primary_event.observation_id,
|
||||
relation=EventObservationRelation.EVIDENCE_FOR,
|
||||
confidence=suggestion.confidence,
|
||||
)
|
||||
|
||||
if confirming.status == FinancialEventStatus.CONFIRMED:
|
||||
primary.status = FinancialEventStatus.CONFIRMED
|
||||
if not primary.payment_account_id and confirming.payment_account_id:
|
||||
primary.payment_account_id = confirming.payment_account_id
|
||||
if not primary.source_account_id and confirming.source_account_id:
|
||||
primary.source_account_id = confirming.source_account_id
|
||||
if not primary.payer_user_id and confirming.payer_user_id:
|
||||
primary.payer_user_id = confirming.payer_user_id
|
||||
session.add(primary)
|
||||
_record_reconciliation_decision(
|
||||
session,
|
||||
household_id=household_id,
|
||||
suggestion=suggestion,
|
||||
decision="accepted",
|
||||
)
|
||||
return suggestion
|
||||
|
||||
|
||||
def ignore_reconciliation_suggestion(
|
||||
session: Session,
|
||||
*,
|
||||
household_id: str,
|
||||
suggestion_id: str,
|
||||
actor_user_id: str | None = None,
|
||||
reason: str | None = None,
|
||||
) -> tuple[ReconciliationSuggestion, models.ReconciliationDecision]:
|
||||
suggestions = list_reconciliation_suggestions(
|
||||
session,
|
||||
household_id=household_id,
|
||||
limit=500,
|
||||
include_ignored=True,
|
||||
)
|
||||
suggestion = next((item for item in suggestions if item.id == suggestion_id), None)
|
||||
if suggestion is None:
|
||||
raise LookupError("Reconciliation suggestion not found")
|
||||
decision = _record_reconciliation_decision(
|
||||
session,
|
||||
household_id=household_id,
|
||||
suggestion=suggestion,
|
||||
decision="ignored",
|
||||
actor_user_id=actor_user_id,
|
||||
reason=reason,
|
||||
)
|
||||
return suggestion, decision
|
||||
|
||||
|
||||
def list_reconciliation_decisions(
|
||||
session: Session,
|
||||
*,
|
||||
household_id: str,
|
||||
) -> list[models.ReconciliationDecision]:
|
||||
return list(
|
||||
session.scalars(
|
||||
select(models.ReconciliationDecision)
|
||||
.where(models.ReconciliationDecision.household_id == household_id)
|
||||
.order_by(models.ReconciliationDecision.updated_at.desc())
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def _suggest_event_pair(
|
||||
primary: models.FinancialEvent,
|
||||
confirming: models.FinancialEvent,
|
||||
*,
|
||||
kind: str = "evidence_confirmation",
|
||||
) -> ReconciliationSuggestion | None:
|
||||
if primary.currency.upper() != confirming.currency.upper():
|
||||
return None
|
||||
if abs(primary.amount - confirming.amount) > Decimal("0.01"):
|
||||
return None
|
||||
days = abs((primary.event_date - confirming.event_date).days)
|
||||
if days > _max_date_window(primary, confirming, kind):
|
||||
return None
|
||||
|
||||
reasons: list[str] = ["amount_exact"]
|
||||
if days == 0:
|
||||
reasons.append("same_date")
|
||||
else:
|
||||
reasons.append(f"date_within_{days}_days")
|
||||
|
||||
reference_score = _reference_score(primary, confirming)
|
||||
if reference_score:
|
||||
reasons.append("shared_reference")
|
||||
|
||||
party_a = _party_text(primary)
|
||||
party_b = _party_text(confirming)
|
||||
score = Decimal(
|
||||
str(
|
||||
score_event_match(
|
||||
amount_a=primary.amount,
|
||||
amount_b=confirming.amount,
|
||||
currency_a=primary.currency,
|
||||
currency_b=confirming.currency,
|
||||
date_a=primary.event_date,
|
||||
date_b=confirming.event_date,
|
||||
party_a=party_a,
|
||||
party_b=party_b,
|
||||
)
|
||||
)
|
||||
)
|
||||
party_overlap = _party_overlap_score(party_a, party_b)
|
||||
if party_overlap >= Decimal("0.60"):
|
||||
reasons.append("party_overlap")
|
||||
if _provider_bridge(primary, confirming):
|
||||
reasons.append("provider_bridge")
|
||||
score += Decimal("0.12")
|
||||
if kind == "statement_bank" and str(confirming.event_type) == "liability_payment":
|
||||
reasons.append("liability_payment")
|
||||
score += Decimal("0.10")
|
||||
score += reference_score
|
||||
if kind == "statement_bank":
|
||||
score += Decimal("0.05")
|
||||
|
||||
confidence = min(score, Decimal("0.99")).quantize(Decimal("0.01"))
|
||||
if confidence < Decimal("0.72"):
|
||||
return None
|
||||
|
||||
relation_type = (
|
||||
RelationType.SAME_ECONOMIC_EVENT_AS
|
||||
if kind == "evidence_confirmation"
|
||||
else RelationType.SETTLES
|
||||
)
|
||||
primary_summary = _event_summary(primary)
|
||||
confirming_summary = _event_summary(confirming)
|
||||
return ReconciliationSuggestion(
|
||||
id=_suggestion_id(kind, primary.id, confirming.id),
|
||||
kind=kind,
|
||||
relation_type=relation_type,
|
||||
primary_event=primary_summary,
|
||||
confirming_event=confirming_summary,
|
||||
confidence=confidence,
|
||||
reasons=reasons,
|
||||
action=(
|
||||
"Attach confirming evidence and mark primary event confirmed"
|
||||
if kind == "evidence_confirmation"
|
||||
else "Link statement event to cash movement"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _events_with_observations(session: Session, *, household_id: str) -> list[models.FinancialEvent]:
|
||||
return list(
|
||||
session.scalars(
|
||||
select(models.FinancialEvent)
|
||||
.where(models.FinancialEvent.household_id == household_id)
|
||||
.options(
|
||||
selectinload(models.FinancialEvent.observation_links).selectinload(
|
||||
models.EventObservationLink.observation
|
||||
)
|
||||
)
|
||||
.order_by(models.FinancialEvent.event_date.desc(), models.FinancialEvent.created_at.desc())
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def _existing_relation_pairs(session: Session, *, household_id: str) -> set[tuple[str, str]]:
|
||||
event_ids = select(models.FinancialEvent.id).where(models.FinancialEvent.household_id == household_id)
|
||||
rows = session.execute(
|
||||
select(models.EventRelation.from_event_id, models.EventRelation.to_event_id)
|
||||
.where(models.EventRelation.from_event_id.in_(event_ids))
|
||||
).all()
|
||||
return {(row[0], row[1]) for row in rows} | {(row[1], row[0]) for row in rows}
|
||||
|
||||
|
||||
def _decision_suggestion_ids(session: Session, *, household_id: str, decision: str) -> set[str]:
|
||||
return set(
|
||||
session.scalars(
|
||||
select(models.ReconciliationDecision.suggestion_id)
|
||||
.where(models.ReconciliationDecision.household_id == household_id)
|
||||
.where(models.ReconciliationDecision.decision == decision)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def _record_reconciliation_decision(
|
||||
session: Session,
|
||||
*,
|
||||
household_id: str,
|
||||
suggestion: ReconciliationSuggestion,
|
||||
decision: str,
|
||||
actor_user_id: str | None = None,
|
||||
reason: str | None = None,
|
||||
) -> models.ReconciliationDecision:
|
||||
existing = session.scalars(
|
||||
select(models.ReconciliationDecision)
|
||||
.where(models.ReconciliationDecision.household_id == household_id)
|
||||
.where(models.ReconciliationDecision.suggestion_id == suggestion.id)
|
||||
.limit(1)
|
||||
).first()
|
||||
if existing:
|
||||
existing.decision = decision
|
||||
existing.kind = suggestion.kind
|
||||
existing.relation_type = suggestion.relation_type
|
||||
existing.primary_event_id = suggestion.primary_event.id
|
||||
existing.confirming_event_id = suggestion.confirming_event.id
|
||||
existing.confidence = suggestion.confidence
|
||||
existing.actor_user_id = actor_user_id
|
||||
existing.reason = reason
|
||||
session.add(existing)
|
||||
return existing
|
||||
row = models.ReconciliationDecision(
|
||||
household_id=household_id,
|
||||
suggestion_id=suggestion.id,
|
||||
decision=decision,
|
||||
kind=suggestion.kind,
|
||||
relation_type=suggestion.relation_type,
|
||||
primary_event_id=suggestion.primary_event.id,
|
||||
confirming_event_id=suggestion.confirming_event.id,
|
||||
confidence=suggestion.confidence,
|
||||
actor_user_id=actor_user_id,
|
||||
reason=reason,
|
||||
)
|
||||
session.add(row)
|
||||
session.flush()
|
||||
return row
|
||||
|
||||
|
||||
def _first_observation(event: models.FinancialEvent) -> models.RawObservation | None:
|
||||
for link in event.observation_links:
|
||||
if link.observation:
|
||||
return link.observation
|
||||
return None
|
||||
|
||||
|
||||
def _first_source_type(event: models.FinancialEvent) -> SourceType | None:
|
||||
observation = _first_observation(event)
|
||||
return observation.source_type if observation else None
|
||||
|
||||
|
||||
def _event_summary(event: models.FinancialEvent) -> EventSummary:
|
||||
observation = _first_observation(event)
|
||||
return EventSummary(
|
||||
id=event.id,
|
||||
source_type=str(observation.source_type) if observation else None,
|
||||
event_type=str(event.event_type),
|
||||
status=str(event.status),
|
||||
event_date=event.event_date,
|
||||
amount=event.amount,
|
||||
currency=event.currency,
|
||||
merchant=event.merchant,
|
||||
counterparty=event.counterparty,
|
||||
description=event.description,
|
||||
source_reference=observation.source_reference if observation else None,
|
||||
provider_transaction_id=observation.provider_transaction_id if observation else None,
|
||||
observation_id=observation.id if observation else None,
|
||||
)
|
||||
|
||||
|
||||
def _suggestion_id(kind: str, primary_id: str, confirming_id: str) -> str:
|
||||
return f"{kind}:{primary_id}:{confirming_id}"
|
||||
|
||||
|
||||
def _party_text(event: models.FinancialEvent) -> str:
|
||||
parts = [event.merchant, event.counterparty, event.description]
|
||||
observation = _first_observation(event)
|
||||
if observation and observation.raw_payload_json:
|
||||
refs = observation.raw_payload_json.get("references")
|
||||
if isinstance(refs, dict):
|
||||
parts.extend(str(value) for value in refs.values() if value)
|
||||
return " ".join(part for part in parts if part)
|
||||
|
||||
|
||||
def _party_overlap_score(a: str, b: str) -> Decimal:
|
||||
left = set(normalize_counterparty(a).split())
|
||||
right = set(normalize_counterparty(b).split())
|
||||
if not left or not right:
|
||||
return Decimal("0")
|
||||
return Decimal(len(left & right)) / Decimal(max(len(left), len(right)))
|
||||
|
||||
|
||||
def _reference_score(a: models.FinancialEvent, b: models.FinancialEvent) -> Decimal:
|
||||
left = _references(a)
|
||||
right = _references(b)
|
||||
if not left or not right:
|
||||
return Decimal("0")
|
||||
return Decimal("0.22") if left & right else Decimal("0")
|
||||
|
||||
|
||||
def _references(event: models.FinancialEvent) -> set[str]:
|
||||
refs: set[str] = set()
|
||||
observation = _first_observation(event)
|
||||
if not observation:
|
||||
return refs
|
||||
for value in (observation.source_reference, observation.provider_transaction_id, observation.message_id):
|
||||
if value:
|
||||
refs.add(normalize_counterparty(value))
|
||||
payload = observation.raw_payload_json or {}
|
||||
raw_refs = payload.get("references")
|
||||
if isinstance(raw_refs, dict):
|
||||
refs.update(normalize_counterparty(str(value)) for value in raw_refs.values() if value)
|
||||
return {value for value in refs if value}
|
||||
|
||||
|
||||
def _provider_bridge(primary: models.FinancialEvent, confirming: models.FinancialEvent) -> bool:
|
||||
party = normalize_counterparty(_party_text(confirming))
|
||||
primary_source = _first_source_type(primary)
|
||||
confirming_source = _first_source_type(confirming)
|
||||
if primary_source in {SourceType.PAYPAL_EMAIL, SourceType.PAYPAL_CSV, SourceType.PAYPAL_API}:
|
||||
return "paypal" in party or confirming_source in {SourceType.PAYPAL_CSV, SourceType.PAYPAL_API}
|
||||
if primary_source in {SourceType.KLARNA_EMAIL, SourceType.KLARNA_CSV}:
|
||||
return "klarna" in party or confirming_source == SourceType.KLARNA_CSV
|
||||
return False
|
||||
|
||||
|
||||
def _max_date_window(primary: models.FinancialEvent, confirming: models.FinancialEvent, kind: str) -> int:
|
||||
primary_source = _first_source_type(primary)
|
||||
confirming_source = _first_source_type(confirming)
|
||||
if kind == "statement_bank":
|
||||
return 45
|
||||
if primary_source in {SourceType.KLARNA_EMAIL, SourceType.PAYPAL_EMAIL} and confirming_source in {
|
||||
SourceType.KLARNA_CSV,
|
||||
SourceType.PAYPAL_CSV,
|
||||
SourceType.PAYPAL_API,
|
||||
}:
|
||||
return 45
|
||||
return 21
|
||||
|
||||
|
||||
def _ensure_event_relation(
|
||||
session: Session,
|
||||
*,
|
||||
from_event_id: str,
|
||||
to_event_id: str,
|
||||
relation_type: RelationType,
|
||||
confidence: Decimal,
|
||||
) -> None:
|
||||
existing = session.scalars(
|
||||
select(models.EventRelation)
|
||||
.where(models.EventRelation.from_event_id == from_event_id)
|
||||
.where(models.EventRelation.to_event_id == to_event_id)
|
||||
.where(models.EventRelation.relation_type == relation_type)
|
||||
.limit(1)
|
||||
).first()
|
||||
if existing:
|
||||
return
|
||||
session.add(
|
||||
models.EventRelation(
|
||||
from_event_id=from_event_id,
|
||||
to_event_id=to_event_id,
|
||||
relation_type=relation_type,
|
||||
confidence=confidence,
|
||||
created_by="reconciliation",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _ensure_observation_link(
|
||||
session: Session,
|
||||
*,
|
||||
event_id: str,
|
||||
observation_id: str,
|
||||
relation: EventObservationRelation,
|
||||
confidence: Decimal,
|
||||
) -> None:
|
||||
existing = session.scalars(
|
||||
select(models.EventObservationLink)
|
||||
.where(models.EventObservationLink.event_id == event_id)
|
||||
.where(models.EventObservationLink.observation_id == observation_id)
|
||||
.where(models.EventObservationLink.relation == relation)
|
||||
.limit(1)
|
||||
).first()
|
||||
if existing:
|
||||
return
|
||||
session.add(
|
||||
models.EventObservationLink(
|
||||
event_id=event_id,
|
||||
observation_id=observation_id,
|
||||
relation=relation,
|
||||
confidence=confidence,
|
||||
)
|
||||
)
|
||||
166
apps/api/mousehold_api/services/settlement.py
Normal file
166
apps/api/mousehold_api/services/settlement.py
Normal file
@@ -0,0 +1,166 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from mousehold_domain import AccountType, FinancialEventStatus, FinancialEventType
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from .. import models
|
||||
from ..schemas import SettlementSummary, SettlementTransfer, SettlementUserBalance
|
||||
|
||||
SETTLEMENT_STATUSES = {
|
||||
FinancialEventStatus.CONFIRMED,
|
||||
FinancialEventStatus.BOOKED,
|
||||
FinancialEventStatus.SETTLED,
|
||||
}
|
||||
SETTLEMENT_EVENT_TYPES = {
|
||||
FinancialEventType.EXPENSE,
|
||||
FinancialEventType.FEE,
|
||||
FinancialEventType.INTEREST,
|
||||
FinancialEventType.REFUND,
|
||||
FinancialEventType.REIMBURSEMENT,
|
||||
}
|
||||
|
||||
|
||||
def _money(value: Decimal | int | str = "0.00") -> Decimal:
|
||||
return Decimal(value).quantize(Decimal("0.01"))
|
||||
|
||||
|
||||
def calculate_settlement(
|
||||
session: Session,
|
||||
*,
|
||||
household_id: str,
|
||||
project_id: str | None = None,
|
||||
currency: str = "EUR",
|
||||
include_announced: bool = False,
|
||||
) -> SettlementSummary:
|
||||
accepted_statuses = set(SETTLEMENT_STATUSES)
|
||||
if include_announced:
|
||||
accepted_statuses.update({FinancialEventStatus.ANNOUNCED, FinancialEventStatus.OUTSTANDING})
|
||||
|
||||
query = (
|
||||
select(models.FinancialEvent)
|
||||
.where(models.FinancialEvent.household_id == household_id)
|
||||
.options(selectinload(models.FinancialEvent.split_lines))
|
||||
.order_by(models.FinancialEvent.event_date, models.FinancialEvent.created_at)
|
||||
)
|
||||
if project_id:
|
||||
query = query.where(models.FinancialEvent.project_id == project_id)
|
||||
events = list(session.scalars(query).all())
|
||||
|
||||
accounts_by_id = {
|
||||
account.id: account
|
||||
for account in session.scalars(
|
||||
select(models.Account).where(models.Account.household_id == household_id)
|
||||
).all()
|
||||
}
|
||||
paid: dict[str, Decimal] = {}
|
||||
share: dict[str, Decimal] = {}
|
||||
included: list[str] = []
|
||||
excluded: list[str] = []
|
||||
explanation: list[str] = []
|
||||
|
||||
for event in events:
|
||||
if (
|
||||
event.currency != currency
|
||||
or event.status not in accepted_statuses
|
||||
or event.event_type not in SETTLEMENT_EVENT_TYPES
|
||||
):
|
||||
excluded.append(event.id)
|
||||
continue
|
||||
payment_account = accounts_by_id.get(
|
||||
event.payment_account_id or event.source_account_id or ""
|
||||
)
|
||||
if (
|
||||
payment_account
|
||||
and payment_account.account_type == AccountType.SHARED_CHECKING
|
||||
and not payment_account.owner_user_id
|
||||
):
|
||||
excluded.append(event.id)
|
||||
explanation.append(f"{event.id} excluded because a neutral shared account paid it.")
|
||||
continue
|
||||
if not event.payer_user_id or not event.split_lines:
|
||||
excluded.append(event.id)
|
||||
explanation.append(f"{event.id} excluded because payer or split lines are missing.")
|
||||
continue
|
||||
|
||||
included.append(event.id)
|
||||
amount = _money(event.amount)
|
||||
multiplier = (
|
||||
Decimal("-1.00") if event.event_type == FinancialEventType.REFUND else Decimal("1.00")
|
||||
)
|
||||
paid[event.payer_user_id] = _money(
|
||||
paid.get(event.payer_user_id, Decimal("0.00")) + (amount * multiplier)
|
||||
)
|
||||
for split in event.split_lines:
|
||||
share[split.user_id] = _money(
|
||||
share.get(split.user_id, Decimal("0.00")) + (split.share_amount * multiplier)
|
||||
)
|
||||
|
||||
user_ids = sorted(set(paid) | set(share))
|
||||
balances = [
|
||||
SettlementUserBalance(
|
||||
user_id=user_id,
|
||||
paid=_money(paid.get(user_id, Decimal("0.00"))),
|
||||
share=_money(share.get(user_id, Decimal("0.00"))),
|
||||
net=_money(paid.get(user_id, Decimal("0.00")) - share.get(user_id, Decimal("0.00"))),
|
||||
)
|
||||
for user_id in user_ids
|
||||
]
|
||||
transfers = _minimize_transfers(balances, currency)
|
||||
explanation.append(
|
||||
f"Included {len(included)} confirmed/booked/settled events"
|
||||
+ (" plus announced/outstanding events." if include_announced else ".")
|
||||
)
|
||||
if not include_announced:
|
||||
explanation.append(
|
||||
"Announced and outstanding events are excluded from settlement by default."
|
||||
)
|
||||
|
||||
return SettlementSummary(
|
||||
household_id=household_id,
|
||||
project_id=project_id,
|
||||
currency=currency,
|
||||
included_event_ids=included,
|
||||
excluded_event_ids=excluded,
|
||||
balances=balances,
|
||||
suggested_transfers=transfers,
|
||||
explanation=explanation,
|
||||
)
|
||||
|
||||
|
||||
def _minimize_transfers(
|
||||
balances: list[SettlementUserBalance], currency: str
|
||||
) -> list[SettlementTransfer]:
|
||||
debtors = [
|
||||
{"user_id": balance.user_id, "amount": _money(-balance.net)}
|
||||
for balance in balances
|
||||
if balance.net < 0
|
||||
]
|
||||
creditors = [
|
||||
{"user_id": balance.user_id, "amount": _money(balance.net)}
|
||||
for balance in balances
|
||||
if balance.net > 0
|
||||
]
|
||||
transfers: list[SettlementTransfer] = []
|
||||
debtor_index = 0
|
||||
creditor_index = 0
|
||||
while debtor_index < len(debtors) and creditor_index < len(creditors):
|
||||
amount = min(debtors[debtor_index]["amount"], creditors[creditor_index]["amount"])
|
||||
if amount > 0:
|
||||
transfers.append(
|
||||
SettlementTransfer(
|
||||
from_user_id=debtors[debtor_index]["user_id"],
|
||||
to_user_id=creditors[creditor_index]["user_id"],
|
||||
amount=_money(amount),
|
||||
currency=currency,
|
||||
)
|
||||
)
|
||||
debtors[debtor_index]["amount"] = _money(debtors[debtor_index]["amount"] - amount)
|
||||
creditors[creditor_index]["amount"] = _money(creditors[creditor_index]["amount"] - amount)
|
||||
if debtors[debtor_index]["amount"] == 0:
|
||||
debtor_index += 1
|
||||
if creditors[creditor_index]["amount"] == 0:
|
||||
creditor_index += 1
|
||||
return transfers
|
||||
Reference in New Issue
Block a user