first ruggedy draft

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

View File

@@ -0,0 +1 @@
__version__ = "0.1.0"

View 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")

View 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()

View 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()

View 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)

View 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
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

@@ -0,0 +1 @@
"""Application services for ledger ingestion, reconciliation, and summaries."""

View 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

View 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,
)

View 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,
)
)

View 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