first ruggedy draft
This commit is contained in:
22
.env.example
Normal file
22
.env.example
Normal file
@@ -0,0 +1,22 @@
|
||||
MOUSEHOLD_DATABASE_URL=sqlite:///./data/mousehold.db
|
||||
MOUSEHOLD_CORS_ORIGINS=["http://localhost:5173","http://127.0.0.1:5173"]
|
||||
MOUSEHOLD_LOCAL_AGENT_TOKEN=dev-local-agent-token
|
||||
MOUSEHOLD_LOCAL_AGENT_CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
|
||||
|
||||
# Local agent only. Do not put production secrets in shared env files.
|
||||
MOUSEHOLD_IMAP_HOST=imap.example.com
|
||||
MOUSEHOLD_IMAP_USERNAME=you@example.com
|
||||
MOUSEHOLD_IMAP_PASSWORD=app-password
|
||||
MOUSEHOLD_IMAP_FOLDER=INBOX
|
||||
MOUSEHOLD_IMAP_SENDER_FILTERS=klarna,paypal,bestellung,order,invoice
|
||||
|
||||
# Local agent FinTS connector. Prefer prompt over storing PIN in .env.
|
||||
MOUSEHOLD_FINTS_BLZ=43060967
|
||||
MOUSEHOLD_FINTS_SERVER=https://fints.example-bank.de/fints
|
||||
MOUSEHOLD_FINTS_USER_ID=your-bank-login-id
|
||||
MOUSEHOLD_FINTS_CUSTOMER_ID=
|
||||
MOUSEHOLD_FINTS_PIN=
|
||||
MOUSEHOLD_FINTS_TAN_MECHANISM=
|
||||
MOUSEHOLD_FINTS_TAN_MEDIUM=
|
||||
MOUSEHOLD_FINTS_PRODUCT_ID=your-registered-fints-product-id
|
||||
MOUSEHOLD_FINTS_PRODUCT_VERSION=0.1.0
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -130,6 +130,10 @@ celerybeat.pid
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!apps/web/.env.development
|
||||
!apps/web/.env.example
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
@@ -234,6 +238,9 @@ web_modules/
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Local app state
|
||||
data/
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
@@ -325,4 +332,3 @@ dist
|
||||
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
|
||||
210
README.md
210
README.md
@@ -1,2 +1,210 @@
|
||||
# mousehold
|
||||
# Mousehold
|
||||
|
||||
Household finance ledger scaffold for an evidence-driven household budget and settlement system.
|
||||
|
||||
The first milestone is a local proof of concept:
|
||||
|
||||
- Python/FastAPI backend with SQLite by default.
|
||||
- React/Vite web app.
|
||||
- Local-agent scaffold for bank, CSV, statement, and IMAP ingestion.
|
||||
- Raw observations become candidate, announced, outstanding, or confirmed financial events.
|
||||
- Settlement and project summaries are calculated from confirmed ledger state.
|
||||
|
||||
## Run
|
||||
|
||||
```sh
|
||||
python -m venv .venv
|
||||
. .venv/bin/activate
|
||||
pip install -r requirements-dev.txt
|
||||
pip install -e .
|
||||
mousehold-api
|
||||
```
|
||||
|
||||
In another shell:
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run dev:web
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:5173`.
|
||||
|
||||
The API listens on `http://127.0.0.1:8000` and exposes OpenAPI at `/docs`.
|
||||
If `8000` is already occupied:
|
||||
|
||||
```sh
|
||||
uvicorn mousehold_api.main:app --host 127.0.0.1 --port 8001
|
||||
cd apps/web
|
||||
npm run dev -- --host 127.0.0.1 --port 5173
|
||||
```
|
||||
|
||||
## MVP workflow
|
||||
|
||||
Use `Setup` to create a household, users, accounts, and connector profiles. The
|
||||
top bar has a `Signed in` selector; choosing a user creates a local session
|
||||
cookie for that browser. This is passwordless local-session support for the
|
||||
self-hosted MVP, not a hosted multi-tenant auth system.
|
||||
|
||||
Create FinTS, IMAP, or CSV/statement connector profiles in `Setup` before using
|
||||
the `Bank`, `Mail`, or `Statements` pages. Those pages use the saved profile for
|
||||
defaults and import attribution, while PIN/TAN, IMAP password, and selected CSV
|
||||
files stay ephemeral.
|
||||
|
||||
Use `Inbox` after manual entry or imports to review source observations. You can
|
||||
keep an observation, confirm its linked event, ignore just the observation,
|
||||
ignore the linked event too, or link the observation to an existing ledger event.
|
||||
Use `Import runs` on the dashboard or connector pages to see imported,
|
||||
duplicate, and event counts from each sync.
|
||||
|
||||
Use `Setup -> Backup` to export the household as JSON or create a local SQLite
|
||||
backup file.
|
||||
|
||||
Browser-driven FinTS access is embedded in the API under `/agent/fints/...`.
|
||||
Use the `FinTS` panel in the web app. PIN/TAN inputs go to the API process you
|
||||
are running locally or self-hosting; normalized observations are imported into
|
||||
the ledger only when you click import.
|
||||
|
||||
For comdirect, choose the `comdirect` preset in the FinTS panel. Enter your
|
||||
comdirect Zugangsnummer as the bank user ID, leave customer ID empty, and enter
|
||||
your registered FinTS product ID.
|
||||
|
||||
IMAP access is embedded under `/agent/imap/...` and available from the `Mail`
|
||||
panel. Enter the IMAP host, login, app password, folder, and filters, then list
|
||||
folders or scan. The scan uses a read-only folder selection and stages parsed
|
||||
messages in the browser. Ledger observations are stored only when you click
|
||||
import.
|
||||
|
||||
Use the `Statements` panel for PayPal Activity Download CSV files and Klarna CSV
|
||||
statements. It previews rows in the browser and imports them as `paypal_csv` or
|
||||
`klarna_csv` confirmation evidence.
|
||||
|
||||
Use the `Reconcile` panel after imports to find likely links between early mail
|
||||
evidence, PayPal/Klarna statement rows, and bank/FinTS cash movement. Accepting a
|
||||
suggestion links the evidence and can upgrade the original announced/outstanding
|
||||
event to confirmed.
|
||||
|
||||
## Local Agent
|
||||
|
||||
Register a local agent after creating a household and user:
|
||||
|
||||
```sh
|
||||
mousehold-agent register \
|
||||
--household-id <household-id> \
|
||||
--owner-user-id <user-id>
|
||||
```
|
||||
|
||||
Import demo observations:
|
||||
|
||||
```sh
|
||||
mousehold-agent sync-demo \
|
||||
--connection-id <connection-id> \
|
||||
--household-id <household-id> \
|
||||
--owner-user-id <user-id> \
|
||||
--source-account-id <account-id>
|
||||
```
|
||||
|
||||
Import bank or PayPal CSV:
|
||||
|
||||
```sh
|
||||
mousehold-agent sync-csv \
|
||||
--connection-id <connection-id> \
|
||||
--household-id <household-id> \
|
||||
--owner-user-id <user-id> \
|
||||
--source-account-id <account-id> \
|
||||
--source-type bank_csv \
|
||||
--path ./transactions.csv
|
||||
```
|
||||
|
||||
Discover FinTS accounts locally:
|
||||
|
||||
```sh
|
||||
mousehold-agent fints-accounts \
|
||||
--profile my-bank \
|
||||
--blz <bank-blz> \
|
||||
--server <fints-endpoint-url> \
|
||||
--bank-user-id <bank-login-id> \
|
||||
--with-balances
|
||||
```
|
||||
|
||||
Display recent FinTS transactions locally:
|
||||
|
||||
```sh
|
||||
mousehold-agent fints-transactions \
|
||||
--profile my-bank \
|
||||
--blz <bank-blz> \
|
||||
--server <fints-endpoint-url> \
|
||||
--bank-user-id <bank-login-id> \
|
||||
--iban <iban> \
|
||||
--days 30
|
||||
```
|
||||
|
||||
Import FinTS transactions into the ledger:
|
||||
|
||||
```sh
|
||||
mousehold-agent sync-fints \
|
||||
--api-url http://127.0.0.1:8001 \
|
||||
--connection-id <connection-id> \
|
||||
--household-id <household-id> \
|
||||
--owner-user-id <user-id> \
|
||||
--source-account-id <account-id> \
|
||||
--profile my-bank \
|
||||
--blz <bank-blz> \
|
||||
--server <fints-endpoint-url> \
|
||||
--bank-user-id <bank-login-id> \
|
||||
--iban <iban> \
|
||||
--days 30 \
|
||||
--display
|
||||
```
|
||||
|
||||
The FinTS connector prompts for the PIN if `MOUSEHOLD_FINTS_PIN` or `--pin` is
|
||||
not supplied. TAN mechanism and TAN medium selection are handled interactively
|
||||
and cached under `~/.config/mousehold/fints/<profile>.json`; the cache does not
|
||||
store the PIN.
|
||||
|
||||
You can also run the FinTS browser bridge as a separate local-only process:
|
||||
|
||||
```sh
|
||||
mousehold-agent serve --host 127.0.0.1 --port 8765
|
||||
```
|
||||
|
||||
Set `VITE_LOCAL_AGENT_URL=http://127.0.0.1:8765` in `apps/web/.env.development`
|
||||
if you choose this mode.
|
||||
|
||||
CLI IMAP scanning can still use local environment variables:
|
||||
|
||||
```sh
|
||||
MOUSEHOLD_IMAP_HOST=imap.example.com
|
||||
MOUSEHOLD_IMAP_USERNAME=you@example.com
|
||||
MOUSEHOLD_IMAP_PASSWORD=app-password
|
||||
MOUSEHOLD_IMAP_SENDER_FILTERS=klarna,paypal,bestellung,order,invoice
|
||||
```
|
||||
|
||||
In embedded connector mode, the API process receives FinTS PIN/TAN and IMAP
|
||||
password inputs while the request is running. In separate local-agent mode,
|
||||
those inputs stay out of the API process. In both modes, connector metadata and
|
||||
normalized observations are what get stored; credentials are not written to the
|
||||
database.
|
||||
|
||||
## Scope
|
||||
|
||||
FinTS account and transaction retrieval is implemented and can run embedded in
|
||||
the API or in the separate local agent. It still depends on your bank supporting
|
||||
FinTS PIN/TAN access and on you providing the bank-specific endpoint and login
|
||||
data. IMAP folder listing, scanning, email parsing, preview, and import are
|
||||
implemented for the PoC. PayPal CSV and email evidence are the supported PayPal
|
||||
PoC path. PSD2 aggregator connectors are still scaffolded.
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
pytest
|
||||
npm run lint:web
|
||||
npm run build:web
|
||||
```
|
||||
|
||||
The Playwright MVP smoke test expects the API and web app to be running:
|
||||
|
||||
```sh
|
||||
npx playwright install
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
1
apps/api/mousehold_api/__init__.py
Normal file
1
apps/api/mousehold_api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
__version__ = "0.1.0"
|
||||
86
apps/api/mousehold_api/auth.py
Normal file
86
apps/api/mousehold_api/auth.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from hashlib import sha256
|
||||
from secrets import token_urlsafe
|
||||
|
||||
from fastapi import Cookie, Depends, HTTPException, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from . import models
|
||||
from .db import get_session
|
||||
|
||||
SESSION_COOKIE = "mousehold_session"
|
||||
SESSION_DAYS = 30
|
||||
|
||||
|
||||
def hash_session_token(token: str) -> str:
|
||||
return sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def create_local_session(session: Session, user: models.User) -> tuple[models.LocalSession, str]:
|
||||
token = token_urlsafe(32)
|
||||
now = datetime.now(UTC)
|
||||
local_session = models.LocalSession(
|
||||
user_id=user.id,
|
||||
household_id=user.household_id,
|
||||
token_hash=hash_session_token(token),
|
||||
created_at=now,
|
||||
last_seen_at=now,
|
||||
expires_at=now + timedelta(days=SESSION_DAYS),
|
||||
)
|
||||
session.add(local_session)
|
||||
session.flush()
|
||||
return local_session, token
|
||||
|
||||
|
||||
def set_session_cookie(response: Response, token: str) -> None:
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=False,
|
||||
max_age=SESSION_DAYS * 24 * 60 * 60,
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def clear_session_cookie(response: Response) -> None:
|
||||
response.delete_cookie(SESSION_COOKIE, path="/")
|
||||
|
||||
|
||||
def get_optional_local_session(
|
||||
session_token: str | None = Cookie(default=None, alias=SESSION_COOKIE),
|
||||
session: Session = Depends(get_session),
|
||||
) -> models.LocalSession | None:
|
||||
if not session_token:
|
||||
return None
|
||||
now = datetime.now(UTC)
|
||||
local_session = session.scalars(
|
||||
select(models.LocalSession)
|
||||
.where(models.LocalSession.token_hash == hash_session_token(session_token))
|
||||
.where(models.LocalSession.revoked_at.is_(None))
|
||||
.where(models.LocalSession.expires_at > now)
|
||||
.limit(1)
|
||||
).first()
|
||||
if local_session:
|
||||
local_session.last_seen_at = now
|
||||
session.add(local_session)
|
||||
session.commit()
|
||||
session.refresh(local_session)
|
||||
return local_session
|
||||
|
||||
|
||||
def require_local_session(
|
||||
local_session: models.LocalSession | None = Depends(get_optional_local_session),
|
||||
) -> models.LocalSession:
|
||||
if not local_session:
|
||||
raise HTTPException(status_code=401, detail="Local session required")
|
||||
return local_session
|
||||
|
||||
|
||||
def require_household_access(household_id: str, local_session: models.LocalSession | None) -> None:
|
||||
if local_session and local_session.household_id != household_id:
|
||||
raise HTTPException(status_code=403, detail="Session does not belong to this household")
|
||||
18
apps/api/mousehold_api/config.py
Normal file
18
apps/api/mousehold_api/config.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
app_name: str = "Mousehold API"
|
||||
api_prefix: str = ""
|
||||
database_url: str = "sqlite:///./data/mousehold.db"
|
||||
cors_origins: list[str] = ["http://localhost:5173", "http://127.0.0.1:5173"]
|
||||
local_agent_token: str = "dev-local-agent-token"
|
||||
|
||||
model_config = SettingsConfigDict(env_prefix="MOUSEHOLD_", env_file=".env", extra="ignore")
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
58
apps/api/mousehold_api/db.py
Normal file
58
apps/api/mousehold_api/db.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def _connect_args(database_url: str) -> dict[str, object]:
|
||||
if database_url.startswith("sqlite"):
|
||||
return {"check_same_thread": False}
|
||||
return {}
|
||||
|
||||
|
||||
def _engine_kwargs(database_url: str) -> dict[str, object]:
|
||||
if database_url in {"sqlite://", "sqlite:///:memory:"}:
|
||||
return {"poolclass": StaticPool}
|
||||
return {}
|
||||
|
||||
|
||||
def _ensure_sqlite_parent(database_url: str) -> None:
|
||||
if not database_url.startswith("sqlite:///"):
|
||||
return
|
||||
db_path = database_url.replace("sqlite:///", "", 1)
|
||||
if db_path not in (":memory:", ""):
|
||||
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
_ensure_sqlite_parent(settings.database_url)
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
connect_args=_connect_args(settings.database_url),
|
||||
**_engine_kwargs(settings.database_url),
|
||||
)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
from . import models # noqa: F401
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
def get_session() -> Generator[Session, None, None]:
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
65
apps/api/mousehold_api/main.py
Normal file
65
apps/api/mousehold_api/main.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from mousehold_agent.server import create_router as create_agent_router
|
||||
|
||||
from .config import get_settings
|
||||
from .db import init_db
|
||||
from .routers import (
|
||||
accounts,
|
||||
auth,
|
||||
backup,
|
||||
connections,
|
||||
events,
|
||||
households,
|
||||
observations,
|
||||
projects,
|
||||
reconciliation,
|
||||
recurring,
|
||||
settlement,
|
||||
taxonomy,
|
||||
)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
app = FastAPI(title=settings.app_name, version="0.1.0")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup() -> None:
|
||||
init_db()
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
app.include_router(households.router, prefix=settings.api_prefix)
|
||||
app.include_router(auth.router, prefix=settings.api_prefix)
|
||||
app.include_router(backup.router, prefix=settings.api_prefix)
|
||||
app.include_router(accounts.router, prefix=settings.api_prefix)
|
||||
app.include_router(observations.router, prefix=settings.api_prefix)
|
||||
app.include_router(events.router, prefix=settings.api_prefix)
|
||||
app.include_router(settlement.router, prefix=settings.api_prefix)
|
||||
app.include_router(reconciliation.router, prefix=settings.api_prefix)
|
||||
app.include_router(projects.router, prefix=settings.api_prefix)
|
||||
app.include_router(recurring.router, prefix=settings.api_prefix)
|
||||
app.include_router(taxonomy.router, prefix=settings.api_prefix)
|
||||
app.include_router(connections.router, prefix=settings.api_prefix)
|
||||
app.include_router(create_agent_router(), prefix=f"{settings.api_prefix.rstrip('/')}/agent")
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
def run() -> None:
|
||||
uvicorn.run("mousehold_api.main:app", host="127.0.0.1", port=8000, reload=True)
|
||||
643
apps/api/mousehold_api/models.py
Normal file
643
apps/api/mousehold_api/models.py
Normal file
@@ -0,0 +1,643 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
from uuid import uuid4
|
||||
|
||||
from mousehold_domain import (
|
||||
AccountType,
|
||||
EventObservationRelation,
|
||||
FinancialEventStatus,
|
||||
FinancialEventType,
|
||||
ImportConnectionStatus,
|
||||
ImportConnectionType,
|
||||
ObservationStatus,
|
||||
PaymentLineStatus,
|
||||
PaymentStatus,
|
||||
PlanningStatus,
|
||||
ProjectStatus,
|
||||
ReconciliationStatus,
|
||||
RecurringCommitmentStatus,
|
||||
RecurringInterval,
|
||||
RelationType,
|
||||
SettlementStatus,
|
||||
SourceType,
|
||||
VisibilityMode,
|
||||
)
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Date,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy import Enum as SqlEnum
|
||||
from sqlalchemy.dialects.sqlite import JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from .db import Base
|
||||
|
||||
|
||||
def new_id() -> str:
|
||||
return str(uuid4())
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
Money = Numeric(14, 2)
|
||||
|
||||
|
||||
class Household(Base):
|
||||
__tablename__ = "households"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
default_currency: Mapped[str] = mapped_column(String(3), default="EUR", nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
|
||||
users: Mapped[list[User]] = relationship(
|
||||
back_populates="household", cascade="all, delete-orphan"
|
||||
)
|
||||
accounts: Mapped[list[Account]] = relationship(
|
||||
back_populates="household", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
household_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("households.id"), nullable=False, index=True
|
||||
)
|
||||
display_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
email: Mapped[str | None] = mapped_column(String(320), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
|
||||
household: Mapped[Household] = relationship(back_populates="users")
|
||||
login_identities: Mapped[list[LoginIdentity]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class LoginIdentity(Base):
|
||||
__tablename__ = "login_identities"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("provider", "provider_subject", name="uq_login_provider_subject"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
|
||||
provider: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
provider_subject: Mapped[str] = mapped_column(String(320), nullable=False)
|
||||
email: Mapped[str | None] = mapped_column(String(320), nullable=True)
|
||||
display_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
|
||||
user: Mapped[User] = relationship(back_populates="login_identities")
|
||||
|
||||
|
||||
class LocalSession(Base):
|
||||
__tablename__ = "local_sessions"
|
||||
__table_args__ = (
|
||||
Index("ix_local_session_token", "token_hash"),
|
||||
Index("ix_local_session_user", "user_id", "expires_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
|
||||
household_id: Mapped[str] = mapped_column(ForeignKey("households.id"), nullable=False, index=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class Account(Base):
|
||||
__tablename__ = "accounts"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
household_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("households.id"), nullable=False, index=True
|
||||
)
|
||||
owner_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=True, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
account_type: Mapped[AccountType] = mapped_column(SqlEnum(AccountType), nullable=False)
|
||||
institution_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
currency: Mapped[str] = mapped_column(String(3), default="EUR", nullable=False)
|
||||
visibility_mode: Mapped[VisibilityMode] = mapped_column(SqlEnum(VisibilityMode), nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
|
||||
household: Mapped[Household] = relationship(back_populates="accounts")
|
||||
|
||||
|
||||
class RawObservation(Base):
|
||||
__tablename__ = "raw_observations"
|
||||
__table_args__ = (
|
||||
Index("ix_observation_dedupe", "household_id", "source_type", "raw_hash"),
|
||||
Index("ix_observation_external_refs", "provider_transaction_id", "message_id"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
household_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("households.id"), nullable=False, index=True
|
||||
)
|
||||
source_type: Mapped[SourceType] = mapped_column(SqlEnum(SourceType), nullable=False, index=True)
|
||||
source_account_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("accounts.id"), nullable=True, index=True
|
||||
)
|
||||
owner_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=True, index=True
|
||||
)
|
||||
observed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
received_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
raw_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
source_reference: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
provider_transaction_id: Mapped[str | None] = mapped_column(
|
||||
String(200), nullable=True, index=True
|
||||
)
|
||||
message_id: Mapped[str | None] = mapped_column(String(500), nullable=True, index=True)
|
||||
raw_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
raw_file_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
raw_payload_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
parsed_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
parser_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
parser_version: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
confidence: Mapped[Decimal] = mapped_column(Money, default=Decimal("1.00"), nullable=False)
|
||||
status: Mapped[ObservationStatus] = mapped_column(
|
||||
SqlEnum(ObservationStatus), default=ObservationStatus.NEW, nullable=False
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
|
||||
event_links: Mapped[list[EventObservationLink]] = relationship(
|
||||
back_populates="observation", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class FinancialEvent(Base):
|
||||
__tablename__ = "financial_events"
|
||||
__table_args__ = (
|
||||
Index("ix_events_household_status_date", "household_id", "status", "event_date"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
household_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("households.id"), nullable=False, index=True
|
||||
)
|
||||
source_account_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("accounts.id"), nullable=True, index=True
|
||||
)
|
||||
payment_account_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("accounts.id"), nullable=True, index=True
|
||||
)
|
||||
payer_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=True, index=True
|
||||
)
|
||||
event_type: Mapped[FinancialEventType] = mapped_column(
|
||||
SqlEnum(FinancialEventType), nullable=False
|
||||
)
|
||||
status: Mapped[FinancialEventStatus] = mapped_column(
|
||||
SqlEnum(FinancialEventStatus), default=FinancialEventStatus.CANDIDATE, nullable=False
|
||||
)
|
||||
event_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
booking_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
|
||||
currency: Mapped[str] = mapped_column(String(3), default="EUR", nullable=False)
|
||||
merchant: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
||||
counterparty: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
category_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("categories.id"), nullable=True, index=True
|
||||
)
|
||||
project_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("projects.id"), nullable=True, index=True
|
||||
)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=True, index=True
|
||||
)
|
||||
fingerprint: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
|
||||
)
|
||||
|
||||
observation_links: Mapped[list[EventObservationLink]] = relationship(
|
||||
back_populates="event", cascade="all, delete-orphan"
|
||||
)
|
||||
split_lines: Mapped[list[SplitLine]] = relationship(
|
||||
back_populates="event", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class EventObservationLink(Base):
|
||||
__tablename__ = "event_observation_links"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"event_id", "observation_id", "relation", name="uq_event_observation_relation"
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
event_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("financial_events.id"), nullable=False, index=True
|
||||
)
|
||||
observation_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("raw_observations.id"), nullable=False, index=True
|
||||
)
|
||||
relation: Mapped[EventObservationRelation] = mapped_column(
|
||||
SqlEnum(EventObservationRelation), nullable=False
|
||||
)
|
||||
confidence: Mapped[Decimal] = mapped_column(Money, default=Decimal("1.00"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
|
||||
event: Mapped[FinancialEvent] = relationship(back_populates="observation_links")
|
||||
observation: Mapped[RawObservation] = relationship(back_populates="event_links")
|
||||
|
||||
|
||||
class EventRelation(Base):
|
||||
__tablename__ = "event_relations"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
from_event_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("financial_events.id"), nullable=False, index=True
|
||||
)
|
||||
to_event_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("financial_events.id"), nullable=False, index=True
|
||||
)
|
||||
relation_type: Mapped[RelationType] = mapped_column(SqlEnum(RelationType), nullable=False)
|
||||
confidence: Mapped[Decimal] = mapped_column(Money, default=Decimal("1.00"), nullable=False)
|
||||
created_by: Mapped[str] = mapped_column(String(40), default="system", nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class ReconciliationDecision(Base):
|
||||
__tablename__ = "reconciliation_decisions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("household_id", "suggestion_id", name="uq_reconciliation_decision"),
|
||||
Index("ix_reconciliation_decision_household", "household_id", "decision"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
household_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("households.id"), nullable=False, index=True
|
||||
)
|
||||
suggestion_id: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
decision: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
relation_type: Mapped[RelationType] = mapped_column(SqlEnum(RelationType), nullable=False)
|
||||
primary_event_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("financial_events.id"), nullable=False, index=True
|
||||
)
|
||||
confirming_event_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("financial_events.id"), nullable=False, index=True
|
||||
)
|
||||
confidence: Mapped[Decimal] = mapped_column(Money, default=Decimal("1.00"), nullable=False)
|
||||
actor_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class SplitLine(Base):
|
||||
__tablename__ = "split_lines"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
event_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("financial_events.id"), nullable=False, index=True
|
||||
)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
|
||||
share_amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
|
||||
share_percent: Mapped[Decimal | None] = mapped_column(Money, nullable=True)
|
||||
reason: Mapped[str] = mapped_column(String(80), default="manual", nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
|
||||
event: Mapped[FinancialEvent] = relationship(back_populates="split_lines")
|
||||
|
||||
|
||||
class Category(Base):
|
||||
__tablename__ = "categories"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
household_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("households.id"), nullable=False, index=True
|
||||
)
|
||||
parent_id: Mapped[str | None] = mapped_column(ForeignKey("categories.id"), nullable=True)
|
||||
name: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
path: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
|
||||
class Tag(Base):
|
||||
__tablename__ = "tags"
|
||||
__table_args__ = (UniqueConstraint("household_id", "name", name="uq_tag_household_name"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
household_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("households.id"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
|
||||
|
||||
class EventTag(Base):
|
||||
__tablename__ = "event_tags"
|
||||
__table_args__ = (UniqueConstraint("event_id", "tag_id", name="uq_event_tag"),)
|
||||
|
||||
event_id: Mapped[str] = mapped_column(ForeignKey("financial_events.id"), primary_key=True)
|
||||
tag_id: Mapped[str] = mapped_column(ForeignKey("tags.id"), primary_key=True)
|
||||
|
||||
|
||||
class Project(Base):
|
||||
__tablename__ = "projects"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
household_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("households.id"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(220), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
default_split_rule_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
status: Mapped[ProjectStatus] = mapped_column(
|
||||
SqlEnum(ProjectStatus), default=ProjectStatus.PLANNING, nullable=False
|
||||
)
|
||||
currency: Mapped[str] = mapped_column(String(3), default="EUR", nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class ProjectParticipant(Base):
|
||||
__tablename__ = "project_participants"
|
||||
__table_args__ = (UniqueConstraint("project_id", "user_id", name="uq_project_participant"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
project_id: Mapped[str] = mapped_column(ForeignKey("projects.id"), nullable=False, index=True)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
|
||||
role: Mapped[str] = mapped_column(String(80), default="participant", nullable=False)
|
||||
default_share_percent: Mapped[Decimal | None] = mapped_column(Money, nullable=True)
|
||||
|
||||
|
||||
class ProjectBudgetLine(Base):
|
||||
__tablename__ = "project_budget_lines"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
project_id: Mapped[str] = mapped_column(ForeignKey("projects.id"), nullable=False, index=True)
|
||||
category_id: Mapped[str | None] = mapped_column(ForeignKey("categories.id"), nullable=True)
|
||||
name: Mapped[str] = mapped_column(String(180), nullable=False)
|
||||
budgeted_amount: Mapped[Decimal] = mapped_column(Money, default=Decimal("0.00"), nullable=False)
|
||||
committed_amount_cached: Mapped[Decimal | None] = mapped_column(Money, nullable=True)
|
||||
paid_amount_cached: Mapped[Decimal | None] = mapped_column(Money, nullable=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class ProjectPlannedItem(Base):
|
||||
__tablename__ = "project_planned_items"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
project_id: Mapped[str] = mapped_column(ForeignKey("projects.id"), nullable=False, index=True)
|
||||
budget_line_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("project_budget_lines.id"), nullable=True, index=True
|
||||
)
|
||||
description: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||
estimated_amount: Mapped[Decimal] = mapped_column(
|
||||
Money, default=Decimal("0.00"), nullable=False
|
||||
)
|
||||
committed_amount: Mapped[Decimal | None] = mapped_column(Money, nullable=True)
|
||||
expected_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
expected_payer_account_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("accounts.id"), nullable=True
|
||||
)
|
||||
split_rule_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
planning_status: Mapped[PlanningStatus] = mapped_column(
|
||||
SqlEnum(PlanningStatus), default=PlanningStatus.IDEA, nullable=False
|
||||
)
|
||||
payment_status: Mapped[PaymentStatus] = mapped_column(
|
||||
SqlEnum(PaymentStatus), default=PaymentStatus.UNPAID, nullable=False
|
||||
)
|
||||
reconciliation_status: Mapped[ReconciliationStatus] = mapped_column(
|
||||
SqlEnum(ReconciliationStatus), default=ReconciliationStatus.NO_EVIDENCE, nullable=False
|
||||
)
|
||||
settlement_status: Mapped[SettlementStatus] = mapped_column(
|
||||
SqlEnum(SettlementStatus), default=SettlementStatus.UNSETTLED, nullable=False
|
||||
)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class PaymentLine(Base):
|
||||
__tablename__ = "payment_lines"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
planned_item_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("project_planned_items.id"), nullable=True, index=True
|
||||
)
|
||||
event_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("financial_events.id"), nullable=True, index=True
|
||||
)
|
||||
amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
|
||||
currency: Mapped[str] = mapped_column(String(3), default="EUR", nullable=False)
|
||||
date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
paid_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=True, index=True
|
||||
)
|
||||
payment_account_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("accounts.id"), nullable=True, index=True
|
||||
)
|
||||
source_observation_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("raw_observations.id"), nullable=True
|
||||
)
|
||||
status: Mapped[PaymentLineStatus] = mapped_column(
|
||||
SqlEnum(PaymentLineStatus), default=PaymentLineStatus.EXPECTED, nullable=False
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class RecurringCommitment(Base):
|
||||
__tablename__ = "recurring_commitments"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
household_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("households.id"), nullable=False, index=True
|
||||
)
|
||||
provider: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
amount_expected: Mapped[Decimal] = mapped_column(Money, nullable=False)
|
||||
amount_min: Mapped[Decimal | None] = mapped_column(Money, nullable=True)
|
||||
amount_max: Mapped[Decimal | None] = mapped_column(Money, nullable=True)
|
||||
currency: Mapped[str] = mapped_column(String(3), default="EUR", nullable=False)
|
||||
interval: Mapped[RecurringInterval] = mapped_column(SqlEnum(RecurringInterval), nullable=False)
|
||||
next_due_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
payer_account_id: Mapped[str | None] = mapped_column(ForeignKey("accounts.id"), nullable=True)
|
||||
split_rule_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
category_id: Mapped[str | None] = mapped_column(ForeignKey("categories.id"), nullable=True)
|
||||
project_id: Mapped[str | None] = mapped_column(ForeignKey("projects.id"), nullable=True)
|
||||
notice_period: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
renewal_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
status: Mapped[RecurringCommitmentStatus] = mapped_column(
|
||||
SqlEnum(RecurringCommitmentStatus), default=RecurringCommitmentStatus.ACTIVE, nullable=False
|
||||
)
|
||||
matching_rule_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class PromisedPayment(Base):
|
||||
__tablename__ = "promised_payments"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
household_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("households.id"), nullable=False, index=True
|
||||
)
|
||||
related_event_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("financial_events.id"), nullable=True, index=True
|
||||
)
|
||||
provider: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
|
||||
currency: Mapped[str] = mapped_column(String(3), default="EUR", nullable=False)
|
||||
created_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
due_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
||||
expected_funding_account_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("accounts.id"), nullable=True
|
||||
)
|
||||
responsible_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(40), default="expected", nullable=False)
|
||||
source_observation_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("raw_observations.id"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class ImportConnection(Base):
|
||||
__tablename__ = "import_connections"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
household_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("households.id"), nullable=False, index=True
|
||||
)
|
||||
owner_user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
|
||||
connection_type: Mapped[ImportConnectionType] = mapped_column(
|
||||
SqlEnum(ImportConnectionType), nullable=False
|
||||
)
|
||||
display_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
status: Mapped[ImportConnectionStatus] = mapped_column(
|
||||
SqlEnum(ImportConnectionStatus), default=ImportConnectionStatus.NEEDS_AUTH, nullable=False
|
||||
)
|
||||
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
secret_storage_ref: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
runs_locally: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
connector_config_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class ImportRun(Base):
|
||||
__tablename__ = "import_runs"
|
||||
__table_args__ = (
|
||||
Index("ix_import_runs_household_started", "household_id", "started_at"),
|
||||
Index("ix_import_runs_connection_started", "connection_id", "started_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
household_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("households.id"), nullable=False, index=True
|
||||
)
|
||||
connection_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("import_connections.id"), nullable=True, index=True
|
||||
)
|
||||
actor_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
source_type: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(40), default="running", nullable=False)
|
||||
imported_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
duplicate_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
created_event_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
skipped_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
summary_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
started_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_log"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
||||
household_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("households.id"), nullable=False, index=True
|
||||
)
|
||||
actor_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
action: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
entity_type: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
entity_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
details_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
1
apps/api/mousehold_api/routers/__init__.py
Normal file
1
apps/api/mousehold_api/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""FastAPI routers."""
|
||||
66
apps/api/mousehold_api/routers/accounts.py
Normal file
66
apps/api/mousehold_api/routers/accounts.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import models
|
||||
from ..db import get_session
|
||||
from ..schemas import AccountCreate, AccountRead, AccountUpdate
|
||||
|
||||
router = APIRouter(tags=["accounts"])
|
||||
|
||||
|
||||
@router.post("/accounts", response_model=AccountRead)
|
||||
def create_account(
|
||||
payload: AccountCreate, session: Session = Depends(get_session)
|
||||
) -> models.Account:
|
||||
if not session.get(models.Household, payload.household_id):
|
||||
raise HTTPException(status_code=404, detail="Household not found")
|
||||
account = models.Account(**payload.model_dump())
|
||||
session.add(account)
|
||||
session.commit()
|
||||
session.refresh(account)
|
||||
return account
|
||||
|
||||
|
||||
@router.get("/accounts", response_model=list[AccountRead])
|
||||
def list_accounts(
|
||||
household_id: str, session: Session = Depends(get_session)
|
||||
) -> list[models.Account]:
|
||||
return list(
|
||||
session.scalars(
|
||||
select(models.Account)
|
||||
.where(models.Account.household_id == household_id)
|
||||
.order_by(models.Account.name)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/accounts/{account_id}", response_model=AccountRead)
|
||||
def update_account(
|
||||
account_id: str,
|
||||
payload: AccountUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
) -> models.Account:
|
||||
account = session.get(models.Account, account_id)
|
||||
if not account:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
if field == "currency" and value is not None:
|
||||
value = value.upper()
|
||||
setattr(account, field, value)
|
||||
session.add(account)
|
||||
session.commit()
|
||||
session.refresh(account)
|
||||
return account
|
||||
|
||||
|
||||
@router.delete("/accounts/{account_id}")
|
||||
def delete_account(account_id: str, session: Session = Depends(get_session)) -> dict[str, bool]:
|
||||
account = session.get(models.Account, account_id)
|
||||
if not account:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
session.delete(account)
|
||||
session.commit()
|
||||
return {"deleted": True}
|
||||
73
apps/api/mousehold_api/routers/auth.py
Normal file
73
apps/api/mousehold_api/routers/auth.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import models
|
||||
from ..auth import (
|
||||
clear_session_cookie,
|
||||
create_local_session,
|
||||
get_optional_local_session,
|
||||
set_session_cookie,
|
||||
)
|
||||
from ..db import get_session
|
||||
from ..schemas import AuthSessionRead, HouseholdRead, LocalLoginRequest, LocalSessionRead, UserRead
|
||||
|
||||
router = APIRouter(tags=["auth"])
|
||||
|
||||
|
||||
def _auth_session_read(
|
||||
session: Session,
|
||||
local_session: models.LocalSession,
|
||||
) -> AuthSessionRead:
|
||||
user = session.get(models.User, local_session.user_id)
|
||||
household = session.get(models.Household, local_session.household_id)
|
||||
if not user or not household:
|
||||
raise HTTPException(status_code=404, detail="Session user or household not found")
|
||||
return AuthSessionRead(
|
||||
session=LocalSessionRead.model_validate(local_session),
|
||||
user=UserRead.model_validate(user),
|
||||
household=HouseholdRead.model_validate(household),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/auth/login", response_model=AuthSessionRead)
|
||||
def local_login(
|
||||
payload: LocalLoginRequest,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
) -> AuthSessionRead:
|
||||
user = session.get(models.User, payload.user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
local_session, token = create_local_session(session, user)
|
||||
session.commit()
|
||||
session.refresh(local_session)
|
||||
set_session_cookie(response, token)
|
||||
return _auth_session_read(session, local_session)
|
||||
|
||||
|
||||
@router.get("/auth/me", response_model=AuthSessionRead | None)
|
||||
def current_local_session(
|
||||
local_session: models.LocalSession | None = Depends(get_optional_local_session),
|
||||
session: Session = Depends(get_session),
|
||||
) -> AuthSessionRead | None:
|
||||
if not local_session:
|
||||
return None
|
||||
return _auth_session_read(session, local_session)
|
||||
|
||||
|
||||
@router.post("/auth/logout")
|
||||
def logout(
|
||||
response: Response,
|
||||
local_session: models.LocalSession | None = Depends(get_optional_local_session),
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict[str, bool]:
|
||||
if local_session:
|
||||
local_session.revoked_at = datetime.now(UTC)
|
||||
session.add(local_session)
|
||||
session.commit()
|
||||
clear_session_cookie(response)
|
||||
return {"logged_out": True}
|
||||
221
apps/api/mousehold_api/routers/backup.py
Normal file
221
apps/api/mousehold_api/routers/backup.py
Normal file
@@ -0,0 +1,221 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from shutil import copy2
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import models
|
||||
from ..auth import get_optional_local_session, require_household_access
|
||||
from ..config import get_settings
|
||||
from ..db import get_session
|
||||
from ..schemas import BackupResult, HouseholdExport
|
||||
|
||||
router = APIRouter(tags=["backup"])
|
||||
|
||||
|
||||
def _row(model: object) -> dict[str, Any]:
|
||||
table = model.__table__
|
||||
return {column.name: getattr(model, column.name) for column in table.columns}
|
||||
|
||||
|
||||
def _rows(items: list[object]) -> list[dict[str, Any]]:
|
||||
return [_row(item) for item in items]
|
||||
|
||||
|
||||
@router.get("/backup/export", response_model=HouseholdExport)
|
||||
def export_household(
|
||||
household_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
local_session: models.LocalSession | None = Depends(get_optional_local_session),
|
||||
) -> HouseholdExport:
|
||||
require_household_access(household_id, local_session)
|
||||
household = session.get(models.Household, household_id)
|
||||
if not household:
|
||||
raise HTTPException(status_code=404, detail="Household not found")
|
||||
|
||||
users = list(session.scalars(select(models.User).where(models.User.household_id == household_id)).all())
|
||||
accounts = list(
|
||||
session.scalars(select(models.Account).where(models.Account.household_id == household_id)).all()
|
||||
)
|
||||
observations = list(
|
||||
session.scalars(
|
||||
select(models.RawObservation).where(models.RawObservation.household_id == household_id)
|
||||
).all()
|
||||
)
|
||||
events = list(
|
||||
session.scalars(
|
||||
select(models.FinancialEvent).where(models.FinancialEvent.household_id == household_id)
|
||||
).all()
|
||||
)
|
||||
categories = list(
|
||||
session.scalars(select(models.Category).where(models.Category.household_id == household_id)).all()
|
||||
)
|
||||
tags = list(session.scalars(select(models.Tag).where(models.Tag.household_id == household_id)).all())
|
||||
projects = list(
|
||||
session.scalars(select(models.Project).where(models.Project.household_id == household_id)).all()
|
||||
)
|
||||
recurring = list(
|
||||
session.scalars(
|
||||
select(models.RecurringCommitment).where(models.RecurringCommitment.household_id == household_id)
|
||||
).all()
|
||||
)
|
||||
promised = list(
|
||||
session.scalars(
|
||||
select(models.PromisedPayment).where(models.PromisedPayment.household_id == household_id)
|
||||
).all()
|
||||
)
|
||||
connections = list(
|
||||
session.scalars(
|
||||
select(models.ImportConnection).where(models.ImportConnection.household_id == household_id)
|
||||
).all()
|
||||
)
|
||||
import_runs = list(
|
||||
session.scalars(select(models.ImportRun).where(models.ImportRun.household_id == household_id)).all()
|
||||
)
|
||||
audit_log = list(
|
||||
session.scalars(select(models.AuditLog).where(models.AuditLog.household_id == household_id)).all()
|
||||
)
|
||||
|
||||
event_ids = [event.id for event in events]
|
||||
observation_ids = [observation.id for observation in observations]
|
||||
project_ids = [project.id for project in projects]
|
||||
event_links = (
|
||||
list(
|
||||
session.scalars(
|
||||
select(models.EventObservationLink).where(
|
||||
or_(
|
||||
models.EventObservationLink.event_id.in_(event_ids),
|
||||
models.EventObservationLink.observation_id.in_(observation_ids),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
if event_ids or observation_ids
|
||||
else []
|
||||
)
|
||||
event_relations = (
|
||||
list(
|
||||
session.scalars(
|
||||
select(models.EventRelation).where(
|
||||
or_(
|
||||
models.EventRelation.from_event_id.in_(event_ids),
|
||||
models.EventRelation.to_event_id.in_(event_ids),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
if event_ids
|
||||
else []
|
||||
)
|
||||
split_lines = (
|
||||
list(
|
||||
session.scalars(select(models.SplitLine).where(models.SplitLine.event_id.in_(event_ids))).all()
|
||||
)
|
||||
if event_ids
|
||||
else []
|
||||
)
|
||||
event_tags = (
|
||||
list(session.scalars(select(models.EventTag).where(models.EventTag.event_id.in_(event_ids))).all())
|
||||
if event_ids
|
||||
else []
|
||||
)
|
||||
project_participants = (
|
||||
list(
|
||||
session.scalars(
|
||||
select(models.ProjectParticipant).where(models.ProjectParticipant.project_id.in_(project_ids))
|
||||
).all()
|
||||
)
|
||||
if project_ids
|
||||
else []
|
||||
)
|
||||
budget_lines = (
|
||||
list(
|
||||
session.scalars(
|
||||
select(models.ProjectBudgetLine).where(models.ProjectBudgetLine.project_id.in_(project_ids))
|
||||
).all()
|
||||
)
|
||||
if project_ids
|
||||
else []
|
||||
)
|
||||
planned_items = (
|
||||
list(
|
||||
session.scalars(
|
||||
select(models.ProjectPlannedItem).where(models.ProjectPlannedItem.project_id.in_(project_ids))
|
||||
).all()
|
||||
)
|
||||
if project_ids
|
||||
else []
|
||||
)
|
||||
planned_item_ids = [item.id for item in planned_items]
|
||||
payment_lines = (
|
||||
list(
|
||||
session.scalars(
|
||||
select(models.PaymentLine).where(
|
||||
or_(
|
||||
models.PaymentLine.event_id.in_(event_ids),
|
||||
models.PaymentLine.planned_item_id.in_(planned_item_ids),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
if event_ids or planned_item_ids
|
||||
else []
|
||||
)
|
||||
|
||||
data = {
|
||||
"users": _rows(users),
|
||||
"accounts": _rows(accounts),
|
||||
"raw_observations": _rows(observations),
|
||||
"financial_events": _rows(events),
|
||||
"event_observation_links": _rows(event_links),
|
||||
"event_relations": _rows(event_relations),
|
||||
"split_lines": _rows(split_lines),
|
||||
"categories": _rows(categories),
|
||||
"tags": _rows(tags),
|
||||
"event_tags": _rows(event_tags),
|
||||
"projects": _rows(projects),
|
||||
"project_participants": _rows(project_participants),
|
||||
"project_budget_lines": _rows(budget_lines),
|
||||
"project_planned_items": _rows(planned_items),
|
||||
"payment_lines": _rows(payment_lines),
|
||||
"recurring_commitments": _rows(recurring),
|
||||
"promised_payments": _rows(promised),
|
||||
"import_connections": _rows(connections),
|
||||
"import_runs": _rows(import_runs),
|
||||
"audit_log": _rows(audit_log),
|
||||
}
|
||||
return HouseholdExport(
|
||||
household=_row(household),
|
||||
exported_at=datetime.now(UTC),
|
||||
counts={key: len(value) for key, value in data.items()},
|
||||
data=data,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/backup/sqlite", response_model=BackupResult)
|
||||
def backup_sqlite() -> BackupResult:
|
||||
settings = get_settings()
|
||||
if not settings.database_url.startswith("sqlite:///"):
|
||||
raise HTTPException(status_code=400, detail="SQLite backup is only available for sqlite:/// databases")
|
||||
db_path_text = settings.database_url.replace("sqlite:///", "", 1)
|
||||
if db_path_text in {"", ":memory:"}:
|
||||
raise HTTPException(status_code=400, detail="In-memory SQLite databases cannot be backed up to a file")
|
||||
db_path = Path(db_path_text)
|
||||
if not db_path.is_absolute():
|
||||
db_path = Path.cwd() / db_path
|
||||
if not db_path.exists():
|
||||
raise HTTPException(status_code=404, detail="SQLite database file not found")
|
||||
backup_dir = db_path.parent / "backups"
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
backup_path = backup_dir / f"{db_path.stem}-{datetime.now(UTC).strftime('%Y%m%d-%H%M%S')}.db"
|
||||
copy2(db_path, backup_path)
|
||||
return BackupResult(
|
||||
file_path=str(backup_path),
|
||||
size_bytes=backup_path.stat().st_size,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
144
apps/api/mousehold_api/routers/connections.py
Normal file
144
apps/api/mousehold_api/routers/connections.py
Normal file
@@ -0,0 +1,144 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from mousehold_domain import ImportConnectionStatus, ImportConnectionType
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import models
|
||||
from ..db import get_session
|
||||
from ..schemas import (
|
||||
ImportConnectionCreate,
|
||||
ImportConnectionRead,
|
||||
ImportConnectionUpdate,
|
||||
LocalAgentHeartbeat,
|
||||
LocalAgentRegister,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["connections"])
|
||||
|
||||
|
||||
@router.get("/connections/supported")
|
||||
def supported_connections() -> dict[str, object]:
|
||||
return {
|
||||
"connectors": [
|
||||
{
|
||||
"type": ImportConnectionType.FINTS,
|
||||
"runs_locally": True,
|
||||
"status": "local-agent-ready",
|
||||
"notes": "PIN/TAN auth runs in the local agent; normalized observations can be synced to the API.",
|
||||
},
|
||||
{
|
||||
"type": ImportConnectionType.PAYPAL_API,
|
||||
"runs_locally": True,
|
||||
"status": "experimental-off-ui",
|
||||
"notes": "PayPal CSV statements and email evidence are the active PoC path.",
|
||||
},
|
||||
{
|
||||
"type": ImportConnectionType.IMAP,
|
||||
"runs_locally": True,
|
||||
"status": "scaffold",
|
||||
"notes": "Local agent can filter Klarna, PayPal, invoices, and order confirmations via IMAP.",
|
||||
},
|
||||
{
|
||||
"type": ImportConnectionType.CSV_FOLDER,
|
||||
"runs_locally": True,
|
||||
"status": "ready-shape",
|
||||
"notes": "CSV rows can be normalized into /observations/sync payloads.",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/connections", response_model=ImportConnectionRead)
|
||||
def create_connection(
|
||||
payload: ImportConnectionCreate,
|
||||
session: Session = Depends(get_session),
|
||||
) -> models.ImportConnection:
|
||||
connection = models.ImportConnection(**payload.model_dump())
|
||||
session.add(connection)
|
||||
session.commit()
|
||||
session.refresh(connection)
|
||||
return connection
|
||||
|
||||
|
||||
@router.get("/connections", response_model=list[ImportConnectionRead])
|
||||
def list_connections(
|
||||
household_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[models.ImportConnection]:
|
||||
return list(
|
||||
session.scalars(
|
||||
select(models.ImportConnection)
|
||||
.where(models.ImportConnection.household_id == household_id)
|
||||
.order_by(models.ImportConnection.created_at.desc())
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/connections/{connection_id}", response_model=ImportConnectionRead)
|
||||
def update_connection(
|
||||
connection_id: str,
|
||||
payload: ImportConnectionUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
) -> models.ImportConnection:
|
||||
connection = session.get(models.ImportConnection, connection_id)
|
||||
if not connection:
|
||||
raise HTTPException(status_code=404, detail="Connection not found")
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(connection, field, value)
|
||||
session.add(connection)
|
||||
session.commit()
|
||||
session.refresh(connection)
|
||||
return connection
|
||||
|
||||
|
||||
@router.delete("/connections/{connection_id}")
|
||||
def delete_connection(connection_id: str, session: Session = Depends(get_session)) -> dict[str, bool]:
|
||||
connection = session.get(models.ImportConnection, connection_id)
|
||||
if not connection:
|
||||
raise HTTPException(status_code=404, detail="Connection not found")
|
||||
session.delete(connection)
|
||||
session.commit()
|
||||
return {"deleted": True}
|
||||
|
||||
|
||||
@router.post("/local-agent/register", response_model=ImportConnectionRead)
|
||||
def register_local_agent(
|
||||
payload: LocalAgentRegister,
|
||||
session: Session = Depends(get_session),
|
||||
) -> models.ImportConnection:
|
||||
connection = models.ImportConnection(
|
||||
household_id=payload.household_id,
|
||||
owner_user_id=payload.owner_user_id,
|
||||
connection_type=ImportConnectionType.LOCAL_AGENT,
|
||||
display_name=payload.display_name,
|
||||
status=ImportConnectionStatus.ACTIVE,
|
||||
runs_locally=True,
|
||||
connector_config_json={
|
||||
"capabilities": ["bank_csv", "paypal_csv", "imap", "fints_placeholder"]
|
||||
},
|
||||
)
|
||||
session.add(connection)
|
||||
session.commit()
|
||||
session.refresh(connection)
|
||||
return connection
|
||||
|
||||
|
||||
@router.post("/local-agent/heartbeat", response_model=ImportConnectionRead)
|
||||
def local_agent_heartbeat(
|
||||
payload: LocalAgentHeartbeat,
|
||||
session: Session = Depends(get_session),
|
||||
) -> models.ImportConnection:
|
||||
connection = session.get(models.ImportConnection, payload.connection_id)
|
||||
if not connection:
|
||||
raise HTTPException(status_code=404, detail="Connection not found")
|
||||
connection.status = payload.status
|
||||
connection.last_error = payload.last_error
|
||||
connection.last_sync_at = datetime.now(UTC)
|
||||
session.add(connection)
|
||||
session.commit()
|
||||
session.refresh(connection)
|
||||
return connection
|
||||
270
apps/api/mousehold_api/routers/events.py
Normal file
270
apps/api/mousehold_api/routers/events.py
Normal file
@@ -0,0 +1,270 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from mousehold_domain import FinancialEventStatus, RelationType
|
||||
from mousehold_matching import compute_normalized_transaction_fingerprint
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from .. import models
|
||||
from ..db import get_session
|
||||
from ..schemas import (
|
||||
EventCreate,
|
||||
EventDetailRead,
|
||||
EventObservationLinkRead,
|
||||
EventRead,
|
||||
EventRelationCreate,
|
||||
EventRelationRead,
|
||||
EventTagsUpdate,
|
||||
EventUpdate,
|
||||
SplitLineCreate,
|
||||
SplitLineRead,
|
||||
TagRead,
|
||||
)
|
||||
from ..services.ingestion import confirm_event
|
||||
|
||||
router = APIRouter(tags=["events"])
|
||||
|
||||
|
||||
def _get_event(session: Session, event_id: str) -> models.FinancialEvent:
|
||||
event = session.scalars(
|
||||
select(models.FinancialEvent)
|
||||
.where(models.FinancialEvent.id == event_id)
|
||||
.options(selectinload(models.FinancialEvent.split_lines))
|
||||
).first()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
_attach_event_tags(session, [event])
|
||||
return event
|
||||
|
||||
|
||||
def _attach_event_tags(session: Session, events: list[models.FinancialEvent]) -> None:
|
||||
if not events:
|
||||
return
|
||||
event_ids = [event.id for event in events]
|
||||
rows = session.execute(
|
||||
select(models.EventTag.event_id, models.EventTag.tag_id).where(
|
||||
models.EventTag.event_id.in_(event_ids)
|
||||
)
|
||||
).all()
|
||||
tags_by_event: dict[str, list[str]] = {event_id: [] for event_id in event_ids}
|
||||
for event_id, tag_id in rows:
|
||||
tags_by_event.setdefault(event_id, []).append(tag_id)
|
||||
for event in events:
|
||||
event.tag_ids = tags_by_event.get(event.id, [])
|
||||
|
||||
|
||||
@router.post("/events", response_model=EventRead)
|
||||
def create_event(
|
||||
payload: EventCreate, session: Session = Depends(get_session)
|
||||
) -> models.FinancialEvent:
|
||||
fingerprint = compute_normalized_transaction_fingerprint(
|
||||
amount=payload.amount,
|
||||
currency=payload.currency,
|
||||
event_date=payload.event_date,
|
||||
merchant=payload.merchant,
|
||||
counterparty=payload.counterparty,
|
||||
)
|
||||
event = models.FinancialEvent(
|
||||
**payload.model_dump(exclude={"split_lines"}),
|
||||
fingerprint=fingerprint,
|
||||
)
|
||||
session.add(event)
|
||||
session.flush()
|
||||
for split in payload.split_lines:
|
||||
session.add(models.SplitLine(event_id=event.id, **split.model_dump()))
|
||||
session.commit()
|
||||
return _get_event(session, event.id)
|
||||
|
||||
|
||||
@router.get("/events", response_model=list[EventRead])
|
||||
def list_events(
|
||||
household_id: str,
|
||||
status: FinancialEventStatus | None = None,
|
||||
hide_related_confirmations: bool = False,
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[models.FinancialEvent]:
|
||||
query = (
|
||||
select(models.FinancialEvent)
|
||||
.where(models.FinancialEvent.household_id == household_id)
|
||||
.options(selectinload(models.FinancialEvent.split_lines))
|
||||
.order_by(models.FinancialEvent.event_date.desc(), models.FinancialEvent.created_at.desc())
|
||||
)
|
||||
if status:
|
||||
query = query.where(models.FinancialEvent.status == status)
|
||||
if hide_related_confirmations:
|
||||
household_event_ids = select(models.FinancialEvent.id).where(
|
||||
models.FinancialEvent.household_id == household_id
|
||||
)
|
||||
hidden_ids = select(models.EventRelation.to_event_id).where(
|
||||
models.EventRelation.from_event_id.in_(household_event_ids),
|
||||
models.EventRelation.relation_type.in_(
|
||||
[RelationType.SAME_ECONOMIC_EVENT_AS, RelationType.DUPLICATE_OF]
|
||||
),
|
||||
)
|
||||
query = query.where(models.FinancialEvent.id.not_in(hidden_ids))
|
||||
events = list(session.scalars(query).all())
|
||||
_attach_event_tags(session, events)
|
||||
return events
|
||||
|
||||
|
||||
@router.get("/events/{event_id}", response_model=EventRead)
|
||||
def get_event(event_id: str, session: Session = Depends(get_session)) -> models.FinancialEvent:
|
||||
return _get_event(session, event_id)
|
||||
|
||||
|
||||
@router.get("/events/{event_id}/details", response_model=EventDetailRead)
|
||||
def get_event_details(event_id: str, session: Session = Depends(get_session)) -> EventDetailRead:
|
||||
event = _get_event(session, event_id)
|
||||
observation_links = list(
|
||||
session.scalars(
|
||||
select(models.EventObservationLink)
|
||||
.where(models.EventObservationLink.event_id == event_id)
|
||||
.order_by(models.EventObservationLink.created_at)
|
||||
).all()
|
||||
)
|
||||
observations: list[models.RawObservation] = []
|
||||
if observation_links:
|
||||
observations = list(
|
||||
session.scalars(
|
||||
select(models.RawObservation).where(
|
||||
models.RawObservation.id.in_([link.observation_id for link in observation_links])
|
||||
)
|
||||
).all()
|
||||
)
|
||||
relations = list(
|
||||
session.scalars(
|
||||
select(models.EventRelation)
|
||||
.where(
|
||||
or_(
|
||||
models.EventRelation.from_event_id == event_id,
|
||||
models.EventRelation.to_event_id == event_id,
|
||||
)
|
||||
)
|
||||
.order_by(models.EventRelation.created_at)
|
||||
).all()
|
||||
)
|
||||
related_event_ids = {
|
||||
relation.to_event_id if relation.from_event_id == event_id else relation.from_event_id
|
||||
for relation in relations
|
||||
}
|
||||
related_events: list[models.FinancialEvent] = []
|
||||
if related_event_ids:
|
||||
related_events = list(
|
||||
session.scalars(
|
||||
select(models.FinancialEvent)
|
||||
.where(models.FinancialEvent.id.in_(related_event_ids))
|
||||
.options(selectinload(models.FinancialEvent.split_lines))
|
||||
.order_by(models.FinancialEvent.event_date.desc())
|
||||
).all()
|
||||
)
|
||||
_attach_event_tags(session, related_events)
|
||||
category = session.get(models.Category, event.category_id) if event.category_id else None
|
||||
tags = (
|
||||
list(session.scalars(select(models.Tag).where(models.Tag.id.in_(event.tag_ids))).all())
|
||||
if event.tag_ids
|
||||
else []
|
||||
)
|
||||
return EventDetailRead(
|
||||
event=event,
|
||||
category=category,
|
||||
tags=tags,
|
||||
observation_links=[EventObservationLinkRead.model_validate(link) for link in observation_links],
|
||||
observations=observations,
|
||||
relations=[EventRelationRead.model_validate(relation) for relation in relations],
|
||||
related_events=related_events,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/events/{event_id}", response_model=EventRead)
|
||||
def update_event(
|
||||
event_id: str,
|
||||
payload: EventUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
) -> models.FinancialEvent:
|
||||
event = _get_event(session, event_id)
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(event, field, value)
|
||||
event.fingerprint = compute_normalized_transaction_fingerprint(
|
||||
amount=event.amount,
|
||||
currency=event.currency,
|
||||
event_date=event.event_date,
|
||||
merchant=event.merchant,
|
||||
counterparty=event.counterparty,
|
||||
)
|
||||
session.add(event)
|
||||
session.commit()
|
||||
return _get_event(session, event_id)
|
||||
|
||||
|
||||
@router.patch("/events/{event_id}/tags", response_model=list[TagRead])
|
||||
def update_event_tags(
|
||||
event_id: str,
|
||||
payload: EventTagsUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[models.Tag]:
|
||||
event = _get_event(session, event_id)
|
||||
tags = (
|
||||
list(
|
||||
session.scalars(
|
||||
select(models.Tag).where(
|
||||
models.Tag.id.in_(payload.tag_ids),
|
||||
models.Tag.household_id == event.household_id,
|
||||
)
|
||||
).all()
|
||||
)
|
||||
if payload.tag_ids
|
||||
else []
|
||||
)
|
||||
if len(tags) != len(set(payload.tag_ids)):
|
||||
raise HTTPException(status_code=400, detail="One or more tags do not belong to this household")
|
||||
session.query(models.EventTag).filter(models.EventTag.event_id == event_id).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
session.add_all([models.EventTag(event_id=event_id, tag_id=tag.id) for tag in tags])
|
||||
session.commit()
|
||||
return tags
|
||||
|
||||
|
||||
@router.post("/events/{event_id}/confirm", response_model=EventRead)
|
||||
def confirm_event_endpoint(
|
||||
event_id: str, session: Session = Depends(get_session)
|
||||
) -> models.FinancialEvent:
|
||||
event = confirm_event(session, _get_event(session, event_id))
|
||||
session.commit()
|
||||
return _get_event(session, event.id)
|
||||
|
||||
|
||||
@router.post("/events/{event_id}/relations", response_model=EventRelationRead)
|
||||
def add_event_relation(
|
||||
event_id: str,
|
||||
payload: EventRelationCreate,
|
||||
session: Session = Depends(get_session),
|
||||
) -> models.EventRelation:
|
||||
if not session.get(models.FinancialEvent, event_id) or not session.get(
|
||||
models.FinancialEvent, payload.to_event_id
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
relation = models.EventRelation(from_event_id=event_id, **payload.model_dump())
|
||||
session.add(relation)
|
||||
session.commit()
|
||||
session.refresh(relation)
|
||||
return relation
|
||||
|
||||
|
||||
@router.post("/events/{event_id}/splits", response_model=list[SplitLineRead])
|
||||
def replace_event_splits(
|
||||
event_id: str,
|
||||
payload: list[SplitLineCreate],
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[models.SplitLine]:
|
||||
event = _get_event(session, event_id)
|
||||
for split in list(event.split_lines):
|
||||
session.delete(split)
|
||||
session.flush()
|
||||
new_splits = [models.SplitLine(event_id=event_id, **split.model_dump()) for split in payload]
|
||||
session.add_all(new_splits)
|
||||
session.commit()
|
||||
return list(
|
||||
session.scalars(select(models.SplitLine).where(models.SplitLine.event_id == event_id)).all()
|
||||
)
|
||||
157
apps/api/mousehold_api/routers/households.py
Normal file
157
apps/api/mousehold_api/routers/households.py
Normal file
@@ -0,0 +1,157 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import models
|
||||
from ..db import get_session
|
||||
from ..schemas import (
|
||||
HouseholdCreate,
|
||||
HouseholdRead,
|
||||
HouseholdUpdate,
|
||||
LoginIdentityCreate,
|
||||
LoginIdentityRead,
|
||||
UserCreate,
|
||||
UserRead,
|
||||
UserUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["households"])
|
||||
|
||||
|
||||
@router.post("/households", response_model=HouseholdRead)
|
||||
def create_household(
|
||||
payload: HouseholdCreate, session: Session = Depends(get_session)
|
||||
) -> models.Household:
|
||||
household = models.Household(
|
||||
name=payload.name, default_currency=payload.default_currency.upper()
|
||||
)
|
||||
session.add(household)
|
||||
session.commit()
|
||||
session.refresh(household)
|
||||
return household
|
||||
|
||||
|
||||
@router.get("/households", response_model=list[HouseholdRead])
|
||||
def list_households(session: Session = Depends(get_session)) -> list[models.Household]:
|
||||
return list(
|
||||
session.scalars(select(models.Household).order_by(models.Household.created_at)).all()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/households/{household_id}", response_model=HouseholdRead)
|
||||
def get_household(household_id: str, session: Session = Depends(get_session)) -> models.Household:
|
||||
household = session.get(models.Household, household_id)
|
||||
if not household:
|
||||
raise HTTPException(status_code=404, detail="Household not found")
|
||||
return household
|
||||
|
||||
|
||||
@router.patch("/households/{household_id}", response_model=HouseholdRead)
|
||||
def update_household(
|
||||
household_id: str,
|
||||
payload: HouseholdUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
) -> models.Household:
|
||||
household = session.get(models.Household, household_id)
|
||||
if not household:
|
||||
raise HTTPException(status_code=404, detail="Household not found")
|
||||
if payload.name is not None:
|
||||
household.name = payload.name
|
||||
if payload.default_currency is not None:
|
||||
household.default_currency = payload.default_currency.upper()
|
||||
session.add(household)
|
||||
session.commit()
|
||||
session.refresh(household)
|
||||
return household
|
||||
|
||||
|
||||
@router.delete("/households/{household_id}")
|
||||
def delete_household(household_id: str, session: Session = Depends(get_session)) -> dict[str, bool]:
|
||||
household = session.get(models.Household, household_id)
|
||||
if not household:
|
||||
raise HTTPException(status_code=404, detail="Household not found")
|
||||
session.delete(household)
|
||||
session.commit()
|
||||
return {"deleted": True}
|
||||
|
||||
|
||||
@router.post("/users", response_model=UserRead)
|
||||
def create_user(payload: UserCreate, session: Session = Depends(get_session)) -> models.User:
|
||||
if not session.get(models.Household, payload.household_id):
|
||||
raise HTTPException(status_code=404, detail="Household not found")
|
||||
user = models.User(
|
||||
household_id=payload.household_id,
|
||||
display_name=payload.display_name,
|
||||
email=payload.email,
|
||||
)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[UserRead])
|
||||
def list_users(household_id: str, session: Session = Depends(get_session)) -> list[models.User]:
|
||||
return list(
|
||||
session.scalars(
|
||||
select(models.User)
|
||||
.where(models.User.household_id == household_id)
|
||||
.order_by(models.User.display_name)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}", response_model=UserRead)
|
||||
def update_user(
|
||||
user_id: str,
|
||||
payload: UserUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
) -> models.User:
|
||||
user = session.get(models.User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(user, field, value)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
def delete_user(user_id: str, session: Session = Depends(get_session)) -> dict[str, bool]:
|
||||
user = session.get(models.User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
session.delete(user)
|
||||
session.commit()
|
||||
return {"deleted": True}
|
||||
|
||||
|
||||
@router.post("/login-identities", response_model=LoginIdentityRead)
|
||||
def create_login_identity(
|
||||
payload: LoginIdentityCreate,
|
||||
session: Session = Depends(get_session),
|
||||
) -> models.LoginIdentity:
|
||||
if not session.get(models.User, payload.user_id):
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
identity = models.LoginIdentity(**payload.model_dump())
|
||||
session.add(identity)
|
||||
session.commit()
|
||||
session.refresh(identity)
|
||||
return identity
|
||||
|
||||
|
||||
@router.get("/login-identities", response_model=list[LoginIdentityRead])
|
||||
def list_login_identities(
|
||||
user_id: str, session: Session = Depends(get_session)
|
||||
) -> list[models.LoginIdentity]:
|
||||
return list(
|
||||
session.scalars(
|
||||
select(models.LoginIdentity)
|
||||
.where(models.LoginIdentity.user_id == user_id)
|
||||
.order_by(models.LoginIdentity.created_at)
|
||||
).all()
|
||||
)
|
||||
644
apps/api/mousehold_api/routers/observations.py
Normal file
644
apps/api/mousehold_api/routers/observations.py
Normal file
@@ -0,0 +1,644 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from mousehold_domain import (
|
||||
EventObservationRelation,
|
||||
FinancialEventStatus,
|
||||
FinancialEventType,
|
||||
ImportConnectionStatus,
|
||||
ObservationStatus,
|
||||
)
|
||||
from mousehold_matching import compute_normalized_transaction_fingerprint
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from .. import models
|
||||
from ..auth import get_optional_local_session, require_household_access
|
||||
from ..db import get_session
|
||||
from ..schemas import (
|
||||
EventRead,
|
||||
ImportRunDetailRead,
|
||||
ImportRunRead,
|
||||
ManualObservationCreate,
|
||||
ObservationAcceptRequest,
|
||||
ObservationBatchRequest,
|
||||
ObservationBatchResponse,
|
||||
ObservationIgnoreRequest,
|
||||
ObservationImportCreate,
|
||||
ObservationLinkRequest,
|
||||
ObservationReviewItem,
|
||||
ObservationUpdateRequest,
|
||||
RawObservationRead,
|
||||
SyncObservationsRequest,
|
||||
SyncObservationsResponse,
|
||||
)
|
||||
from ..services.ingestion import create_observation_with_candidate
|
||||
from .events import _attach_event_tags
|
||||
|
||||
router = APIRouter(tags=["observations"])
|
||||
|
||||
|
||||
def _get_observation(session: Session, observation_id: str) -> models.RawObservation:
|
||||
observation = session.get(models.RawObservation, observation_id)
|
||||
if not observation:
|
||||
raise HTTPException(status_code=404, detail="Observation not found")
|
||||
return observation
|
||||
|
||||
|
||||
def _events_for_observation(
|
||||
session: Session, observation_id: str
|
||||
) -> list[models.FinancialEvent]:
|
||||
events = list(
|
||||
session.scalars(
|
||||
select(models.FinancialEvent)
|
||||
.join(
|
||||
models.EventObservationLink,
|
||||
models.EventObservationLink.event_id == models.FinancialEvent.id,
|
||||
)
|
||||
.where(models.EventObservationLink.observation_id == observation_id)
|
||||
.options(selectinload(models.FinancialEvent.split_lines))
|
||||
.order_by(models.FinancialEvent.created_at.desc())
|
||||
).all()
|
||||
)
|
||||
_attach_event_tags(session, events)
|
||||
return events
|
||||
|
||||
|
||||
def _review_item(session: Session, observation: models.RawObservation) -> ObservationReviewItem:
|
||||
events = _events_for_observation(session, observation.id)
|
||||
if observation.status == ObservationStatus.DUPLICATE_SOURCE:
|
||||
suggested_action = "duplicate"
|
||||
elif observation.status == ObservationStatus.IGNORED:
|
||||
suggested_action = "ignored"
|
||||
elif events:
|
||||
suggested_action = "review_event"
|
||||
else:
|
||||
suggested_action = "create_event"
|
||||
action_required = observation.status not in {
|
||||
ObservationStatus.IGNORED,
|
||||
ObservationStatus.DUPLICATE_SOURCE,
|
||||
}
|
||||
return ObservationReviewItem(
|
||||
observation=RawObservationRead.model_validate(observation),
|
||||
events=[EventRead.model_validate(event) for event in events],
|
||||
action_required=action_required,
|
||||
suggested_action=suggested_action,
|
||||
)
|
||||
|
||||
|
||||
def _date_from_parsed(value: object, field: str) -> date | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(str(value))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Observation has invalid {field}") from exc
|
||||
|
||||
|
||||
def _create_event_from_observation(
|
||||
session: Session,
|
||||
observation: models.RawObservation,
|
||||
event_status: FinancialEventStatus | None,
|
||||
) -> models.FinancialEvent:
|
||||
parsed = observation.parsed_json or {}
|
||||
event_date = _date_from_parsed(parsed.get("event_date"), "event_date")
|
||||
if not event_date:
|
||||
raise HTTPException(status_code=400, detail="Observation does not contain an event date")
|
||||
amount = parsed.get("amount")
|
||||
if amount is None:
|
||||
raise HTTPException(status_code=400, detail="Observation does not contain an amount")
|
||||
event_type = FinancialEventType(str(parsed.get("event_type") or FinancialEventType.EXPENSE))
|
||||
status = event_status or FinancialEventStatus(str(parsed.get("status") or FinancialEventStatus.CANDIDATE))
|
||||
fingerprint = compute_normalized_transaction_fingerprint(
|
||||
amount=Decimal(str(amount)),
|
||||
currency=str(parsed.get("currency") or "EUR"),
|
||||
event_date=event_date,
|
||||
merchant=parsed.get("merchant"),
|
||||
counterparty=parsed.get("counterparty"),
|
||||
external_id=observation.provider_transaction_id,
|
||||
)
|
||||
event = models.FinancialEvent(
|
||||
household_id=observation.household_id,
|
||||
source_account_id=observation.source_account_id,
|
||||
payment_account_id=observation.source_account_id,
|
||||
payer_user_id=observation.owner_user_id,
|
||||
event_type=event_type,
|
||||
status=status,
|
||||
event_date=event_date,
|
||||
booking_date=_date_from_parsed(parsed.get("booking_date"), "booking_date"),
|
||||
due_date=_date_from_parsed(parsed.get("due_date"), "due_date"),
|
||||
amount=Decimal(str(amount)),
|
||||
currency=str(parsed.get("currency") or "EUR"),
|
||||
merchant=parsed.get("merchant"),
|
||||
counterparty=parsed.get("counterparty"),
|
||||
description=parsed.get("description"),
|
||||
created_by_user_id=observation.owner_user_id,
|
||||
fingerprint=fingerprint,
|
||||
)
|
||||
session.add(event)
|
||||
session.flush()
|
||||
session.add(
|
||||
models.EventObservationLink(
|
||||
event_id=event.id,
|
||||
observation_id=observation.id,
|
||||
relation=EventObservationRelation.IMPORTED_FROM,
|
||||
confidence=Decimal("1.00"),
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
return event
|
||||
|
||||
|
||||
def _require_account(session: Session, household_id: str, account_id: str | None) -> None:
|
||||
if not account_id:
|
||||
return
|
||||
account = session.get(models.Account, account_id)
|
||||
if not account or account.household_id != household_id:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
|
||||
|
||||
def _require_user(session: Session, household_id: str, user_id: str | None) -> None:
|
||||
if not user_id:
|
||||
return
|
||||
user = session.get(models.User, user_id)
|
||||
if not user or user.household_id != household_id:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
|
||||
def _parsed_update_payload(payload: ObservationUpdateRequest) -> dict[str, object]:
|
||||
parsed_fields = {
|
||||
"amount",
|
||||
"currency",
|
||||
"event_date",
|
||||
"booking_date",
|
||||
"due_date",
|
||||
"merchant",
|
||||
"counterparty",
|
||||
"description",
|
||||
"event_type",
|
||||
"status",
|
||||
}
|
||||
dumped = payload.model_dump(exclude_unset=True, mode="json")
|
||||
return {key: value for key, value in dumped.items() if key in parsed_fields}
|
||||
|
||||
|
||||
def _sync_event_from_observation(
|
||||
event: models.FinancialEvent,
|
||||
observation: models.RawObservation,
|
||||
parsed_update: dict[str, object],
|
||||
) -> None:
|
||||
parsed = observation.parsed_json or {}
|
||||
if "amount" in parsed_update:
|
||||
event.amount = Decimal(str(parsed["amount"]))
|
||||
if "currency" in parsed_update:
|
||||
event.currency = str(parsed["currency"] or "EUR").upper()
|
||||
if "event_date" in parsed_update:
|
||||
parsed_date = _date_from_parsed(parsed.get("event_date"), "event_date")
|
||||
if parsed_date:
|
||||
event.event_date = parsed_date
|
||||
if "booking_date" in parsed_update:
|
||||
event.booking_date = _date_from_parsed(parsed.get("booking_date"), "booking_date")
|
||||
if "due_date" in parsed_update:
|
||||
event.due_date = _date_from_parsed(parsed.get("due_date"), "due_date")
|
||||
if "merchant" in parsed_update:
|
||||
event.merchant = parsed.get("merchant")
|
||||
if "counterparty" in parsed_update:
|
||||
event.counterparty = parsed.get("counterparty")
|
||||
if "description" in parsed_update:
|
||||
event.description = parsed.get("description")
|
||||
if "event_type" in parsed_update and parsed.get("event_type"):
|
||||
event.event_type = FinancialEventType(str(parsed["event_type"]))
|
||||
if "status" in parsed_update and parsed.get("status"):
|
||||
event.status = FinancialEventStatus(str(parsed["status"]))
|
||||
event.fingerprint = compute_normalized_transaction_fingerprint(
|
||||
amount=event.amount,
|
||||
currency=event.currency,
|
||||
event_date=event.event_date,
|
||||
merchant=event.merchant,
|
||||
counterparty=event.counterparty,
|
||||
external_id=observation.provider_transaction_id,
|
||||
)
|
||||
|
||||
|
||||
def _apply_observation_update(
|
||||
session: Session,
|
||||
observation: models.RawObservation,
|
||||
payload: ObservationUpdateRequest,
|
||||
) -> None:
|
||||
if "source_account_id" in payload.model_fields_set:
|
||||
_require_account(session, observation.household_id, payload.source_account_id)
|
||||
observation.source_account_id = payload.source_account_id
|
||||
if "owner_user_id" in payload.model_fields_set:
|
||||
_require_user(session, observation.household_id, payload.owner_user_id)
|
||||
observation.owner_user_id = payload.owner_user_id
|
||||
if "raw_text" in payload.model_fields_set:
|
||||
observation.raw_text = payload.raw_text
|
||||
parsed_update = _parsed_update_payload(payload)
|
||||
if parsed_update:
|
||||
parsed = dict(observation.parsed_json or {})
|
||||
parsed.update(parsed_update)
|
||||
if "currency" in parsed and parsed["currency"]:
|
||||
parsed["currency"] = str(parsed["currency"]).upper()
|
||||
observation.parsed_json = parsed
|
||||
session.add(observation)
|
||||
session.flush()
|
||||
if payload.sync_linked_events:
|
||||
for event in _events_for_observation(session, observation.id):
|
||||
_sync_event_from_observation(event, observation, parsed_update)
|
||||
if "source_account_id" in payload.model_fields_set:
|
||||
event.source_account_id = observation.source_account_id
|
||||
event.payment_account_id = observation.source_account_id
|
||||
if "owner_user_id" in payload.model_fields_set:
|
||||
event.payer_user_id = observation.owner_user_id
|
||||
session.add(event)
|
||||
session.flush()
|
||||
|
||||
|
||||
def _accept_observation(
|
||||
session: Session,
|
||||
observation: models.RawObservation,
|
||||
event_status: FinancialEventStatus | None,
|
||||
create_event_if_missing: bool = True,
|
||||
) -> None:
|
||||
events = _events_for_observation(session, observation.id)
|
||||
if not events and create_event_if_missing:
|
||||
events = [_create_event_from_observation(session, observation, event_status)]
|
||||
for event in events:
|
||||
if event_status:
|
||||
event.status = event_status
|
||||
session.add(event)
|
||||
if events and observation.status not in {
|
||||
ObservationStatus.DUPLICATE_SOURCE,
|
||||
ObservationStatus.IGNORED,
|
||||
}:
|
||||
observation.status = ObservationStatus.CANDIDATE_CREATED
|
||||
session.add(observation)
|
||||
session.flush()
|
||||
|
||||
|
||||
def _ignore_observation(
|
||||
session: Session,
|
||||
observation: models.RawObservation,
|
||||
ignore_linked_events: bool,
|
||||
actor_user_id: str | None,
|
||||
reason: str | None,
|
||||
) -> None:
|
||||
observation.status = ObservationStatus.IGNORED
|
||||
session.add(observation)
|
||||
if ignore_linked_events:
|
||||
for event in _events_for_observation(session, observation.id):
|
||||
event.status = FinancialEventStatus.IGNORED
|
||||
session.add(event)
|
||||
session.add(
|
||||
models.AuditLog(
|
||||
household_id=observation.household_id,
|
||||
actor_user_id=actor_user_id,
|
||||
action="observation.ignore",
|
||||
entity_type="raw_observation",
|
||||
entity_id=observation.id,
|
||||
details_json={"reason": reason},
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
|
||||
|
||||
@router.post("/observations/manual", response_model=RawObservationRead)
|
||||
def create_manual_observation(
|
||||
payload: ManualObservationCreate,
|
||||
session: Session = Depends(get_session),
|
||||
local_session: models.LocalSession | None = Depends(get_optional_local_session),
|
||||
) -> models.RawObservation:
|
||||
require_household_access(payload.household_id, local_session)
|
||||
observation, _event = create_observation_with_candidate(session, payload)
|
||||
session.commit()
|
||||
session.refresh(observation)
|
||||
return observation
|
||||
|
||||
|
||||
@router.post("/observations/import", response_model=RawObservationRead)
|
||||
def import_observation(
|
||||
payload: ObservationImportCreate,
|
||||
session: Session = Depends(get_session),
|
||||
local_session: models.LocalSession | None = Depends(get_optional_local_session),
|
||||
) -> models.RawObservation:
|
||||
require_household_access(payload.household_id, local_session)
|
||||
observation, _event = create_observation_with_candidate(session, payload)
|
||||
session.commit()
|
||||
session.refresh(observation)
|
||||
return observation
|
||||
|
||||
|
||||
@router.post("/observations/sync", response_model=SyncObservationsResponse)
|
||||
def sync_observations(
|
||||
payload: SyncObservationsRequest,
|
||||
session: Session = Depends(get_session),
|
||||
local_session: models.LocalSession | None = Depends(get_optional_local_session),
|
||||
) -> SyncObservationsResponse:
|
||||
imported_ids: list[str] = []
|
||||
event_ids: list[str] = []
|
||||
duplicate_ids: list[str] = []
|
||||
connection = session.get(models.ImportConnection, payload.connection_id)
|
||||
household_id = (
|
||||
payload.observations[0].household_id
|
||||
if payload.observations
|
||||
else connection.household_id if connection else None
|
||||
)
|
||||
if not household_id:
|
||||
raise HTTPException(status_code=400, detail="No household available for import")
|
||||
require_household_access(household_id, local_session)
|
||||
run = models.ImportRun(
|
||||
household_id=household_id,
|
||||
connection_id=connection.id if connection else None,
|
||||
actor_user_id=local_session.user_id if local_session else None,
|
||||
source_type=str(payload.observations[0].source_type) if payload.observations else None,
|
||||
status="running",
|
||||
summary_json={"requested_count": len(payload.observations)},
|
||||
)
|
||||
session.add(run)
|
||||
session.flush()
|
||||
for observation_payload in payload.observations:
|
||||
if observation_payload.household_id != household_id:
|
||||
raise HTTPException(status_code=400, detail="Import payload mixes households")
|
||||
observation, event = create_observation_with_candidate(session, observation_payload)
|
||||
imported_ids.append(observation.id)
|
||||
if observation.status == ObservationStatus.DUPLICATE_SOURCE:
|
||||
duplicate_ids.append(observation.id)
|
||||
if event:
|
||||
event_ids.append(event.id)
|
||||
if connection:
|
||||
connection.status = ImportConnectionStatus.ACTIVE
|
||||
connection.last_sync_at = datetime.now(UTC)
|
||||
connection.last_error = None
|
||||
config = dict(connection.connector_config_json or {})
|
||||
config["last_imported_count"] = len(imported_ids) - len(duplicate_ids)
|
||||
config["last_duplicate_count"] = len(duplicate_ids)
|
||||
config["last_created_event_count"] = len(event_ids)
|
||||
config["last_import_run_id"] = run.id
|
||||
connection.connector_config_json = config
|
||||
session.add(connection)
|
||||
run.status = "done"
|
||||
run.imported_count = len(imported_ids) - len(duplicate_ids)
|
||||
run.duplicate_count = len(duplicate_ids)
|
||||
run.created_event_count = len(event_ids)
|
||||
run.finished_at = datetime.now(UTC)
|
||||
run.summary_json = {
|
||||
"requested_count": len(payload.observations),
|
||||
"imported_observation_ids": imported_ids,
|
||||
"created_event_ids": event_ids,
|
||||
"duplicate_observation_ids": duplicate_ids,
|
||||
}
|
||||
session.add(run)
|
||||
session.commit()
|
||||
session.refresh(run)
|
||||
return SyncObservationsResponse(
|
||||
imported_observation_ids=imported_ids,
|
||||
created_event_ids=event_ids,
|
||||
duplicate_observation_ids=duplicate_ids,
|
||||
connection_id=connection.id if connection else None,
|
||||
imported_count=len(imported_ids) - len(duplicate_ids),
|
||||
duplicate_count=len(duplicate_ids),
|
||||
created_event_count=len(event_ids),
|
||||
run=ImportRunRead.model_validate(run),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/observations/inbox", response_model=list[RawObservationRead])
|
||||
def get_observation_inbox(
|
||||
household_id: str,
|
||||
status: ObservationStatus | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
local_session: models.LocalSession | None = Depends(get_optional_local_session),
|
||||
) -> list[models.RawObservation]:
|
||||
require_household_access(household_id, local_session)
|
||||
query = (
|
||||
select(models.RawObservation)
|
||||
.where(models.RawObservation.household_id == household_id)
|
||||
.order_by(models.RawObservation.received_at.desc())
|
||||
)
|
||||
if status:
|
||||
query = query.where(models.RawObservation.status == status)
|
||||
return list(session.scalars(query).all())
|
||||
|
||||
|
||||
@router.get("/observations/review", response_model=list[ObservationReviewItem])
|
||||
def get_observation_review_items(
|
||||
household_id: str,
|
||||
status: ObservationStatus | None = None,
|
||||
limit: int = 100,
|
||||
session: Session = Depends(get_session),
|
||||
local_session: models.LocalSession | None = Depends(get_optional_local_session),
|
||||
) -> list[ObservationReviewItem]:
|
||||
require_household_access(household_id, local_session)
|
||||
query = (
|
||||
select(models.RawObservation)
|
||||
.where(models.RawObservation.household_id == household_id)
|
||||
.order_by(models.RawObservation.received_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
if status:
|
||||
query = query.where(models.RawObservation.status == status)
|
||||
return [_review_item(session, observation) for observation in session.scalars(query).all()]
|
||||
|
||||
|
||||
@router.patch("/observations/{observation_id}", response_model=ObservationReviewItem)
|
||||
def update_observation(
|
||||
observation_id: str,
|
||||
payload: ObservationUpdateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
local_session: models.LocalSession | None = Depends(get_optional_local_session),
|
||||
) -> ObservationReviewItem:
|
||||
observation = _get_observation(session, observation_id)
|
||||
require_household_access(observation.household_id, local_session)
|
||||
_apply_observation_update(session, observation, payload)
|
||||
session.commit()
|
||||
session.refresh(observation)
|
||||
return _review_item(session, observation)
|
||||
|
||||
|
||||
@router.post("/observations/{observation_id}/accept", response_model=ObservationReviewItem)
|
||||
def accept_observation(
|
||||
observation_id: str,
|
||||
payload: ObservationAcceptRequest,
|
||||
session: Session = Depends(get_session),
|
||||
local_session: models.LocalSession | None = Depends(get_optional_local_session),
|
||||
) -> ObservationReviewItem:
|
||||
observation = _get_observation(session, observation_id)
|
||||
require_household_access(observation.household_id, local_session)
|
||||
_accept_observation(
|
||||
session,
|
||||
observation,
|
||||
event_status=payload.event_status,
|
||||
create_event_if_missing=payload.create_event_if_missing,
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(observation)
|
||||
return _review_item(session, observation)
|
||||
|
||||
|
||||
@router.post("/observations/{observation_id}/ignore", response_model=ObservationReviewItem)
|
||||
def ignore_observation(
|
||||
observation_id: str,
|
||||
payload: ObservationIgnoreRequest,
|
||||
session: Session = Depends(get_session),
|
||||
local_session: models.LocalSession | None = Depends(get_optional_local_session),
|
||||
) -> ObservationReviewItem:
|
||||
observation = _get_observation(session, observation_id)
|
||||
require_household_access(observation.household_id, local_session)
|
||||
_ignore_observation(
|
||||
session,
|
||||
observation,
|
||||
ignore_linked_events=payload.ignore_linked_events,
|
||||
actor_user_id=local_session.user_id if local_session else None,
|
||||
reason=payload.reason,
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(observation)
|
||||
return _review_item(session, observation)
|
||||
|
||||
|
||||
@router.post("/observations/{observation_id}/link", response_model=ObservationReviewItem)
|
||||
def link_observation(
|
||||
observation_id: str,
|
||||
payload: ObservationLinkRequest,
|
||||
session: Session = Depends(get_session),
|
||||
local_session: models.LocalSession | None = Depends(get_optional_local_session),
|
||||
) -> ObservationReviewItem:
|
||||
observation = _get_observation(session, observation_id)
|
||||
require_household_access(observation.household_id, local_session)
|
||||
event = session.get(models.FinancialEvent, payload.event_id)
|
||||
if not event or event.household_id != observation.household_id:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
exists = session.scalars(
|
||||
select(models.EventObservationLink)
|
||||
.where(models.EventObservationLink.event_id == event.id)
|
||||
.where(models.EventObservationLink.observation_id == observation.id)
|
||||
.where(models.EventObservationLink.relation == payload.relation)
|
||||
.limit(1)
|
||||
).first()
|
||||
if not exists:
|
||||
session.add(
|
||||
models.EventObservationLink(
|
||||
event_id=event.id,
|
||||
observation_id=observation.id,
|
||||
relation=payload.relation,
|
||||
confidence=Decimal("1.00"),
|
||||
)
|
||||
)
|
||||
if observation.status == ObservationStatus.NEW:
|
||||
observation.status = ObservationStatus.PARSED
|
||||
session.add(observation)
|
||||
session.commit()
|
||||
session.refresh(observation)
|
||||
return _review_item(session, observation)
|
||||
|
||||
|
||||
@router.post("/observations/batch", response_model=ObservationBatchResponse)
|
||||
def batch_review_observations(
|
||||
payload: ObservationBatchRequest,
|
||||
session: Session = Depends(get_session),
|
||||
local_session: models.LocalSession | None = Depends(get_optional_local_session),
|
||||
) -> ObservationBatchResponse:
|
||||
if payload.action not in {"accept", "confirm", "ignore"}:
|
||||
raise HTTPException(status_code=400, detail="Unsupported batch action")
|
||||
if not payload.observation_ids:
|
||||
return ObservationBatchResponse(updated_count=0, items=[])
|
||||
items: list[ObservationReviewItem] = []
|
||||
household_id: str | None = None
|
||||
for observation_id in payload.observation_ids:
|
||||
observation = _get_observation(session, observation_id)
|
||||
if household_id is None:
|
||||
household_id = observation.household_id
|
||||
require_household_access(household_id, local_session)
|
||||
elif observation.household_id != household_id:
|
||||
raise HTTPException(status_code=400, detail="Batch payload mixes households")
|
||||
if payload.action == "ignore":
|
||||
_ignore_observation(
|
||||
session,
|
||||
observation,
|
||||
ignore_linked_events=payload.ignore_linked_events,
|
||||
actor_user_id=local_session.user_id if local_session else None,
|
||||
reason="Batch review",
|
||||
)
|
||||
else:
|
||||
_accept_observation(
|
||||
session,
|
||||
observation,
|
||||
event_status=FinancialEventStatus.CONFIRMED
|
||||
if payload.action == "confirm"
|
||||
else payload.event_status,
|
||||
create_event_if_missing=True,
|
||||
)
|
||||
items.append(_review_item(session, observation))
|
||||
session.commit()
|
||||
return ObservationBatchResponse(updated_count=len(items), items=items)
|
||||
|
||||
|
||||
@router.get("/import-runs", response_model=list[ImportRunRead])
|
||||
def list_import_runs(
|
||||
household_id: str,
|
||||
connection_id: str | None = None,
|
||||
limit: int = 25,
|
||||
session: Session = Depends(get_session),
|
||||
local_session: models.LocalSession | None = Depends(get_optional_local_session),
|
||||
) -> list[models.ImportRun]:
|
||||
require_household_access(household_id, local_session)
|
||||
query = (
|
||||
select(models.ImportRun)
|
||||
.where(models.ImportRun.household_id == household_id)
|
||||
.order_by(models.ImportRun.started_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
if connection_id:
|
||||
query = query.where(models.ImportRun.connection_id == connection_id)
|
||||
return list(session.scalars(query).all())
|
||||
|
||||
|
||||
@router.get("/import-runs/{run_id}", response_model=ImportRunDetailRead)
|
||||
def get_import_run_detail(
|
||||
run_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
local_session: models.LocalSession | None = Depends(get_optional_local_session),
|
||||
) -> ImportRunDetailRead:
|
||||
run = session.get(models.ImportRun, run_id)
|
||||
if not run:
|
||||
raise HTTPException(status_code=404, detail="Import run not found")
|
||||
require_household_access(run.household_id, local_session)
|
||||
summary = run.summary_json or {}
|
||||
observation_ids = list(
|
||||
dict.fromkeys(
|
||||
[
|
||||
*summary.get("imported_observation_ids", []),
|
||||
*summary.get("duplicate_observation_ids", []),
|
||||
]
|
||||
)
|
||||
)
|
||||
event_ids = summary.get("created_event_ids", [])
|
||||
observations = (
|
||||
list(
|
||||
session.scalars(
|
||||
select(models.RawObservation).where(models.RawObservation.id.in_(observation_ids))
|
||||
).all()
|
||||
)
|
||||
if observation_ids
|
||||
else []
|
||||
)
|
||||
events = (
|
||||
list(
|
||||
session.scalars(
|
||||
select(models.FinancialEvent)
|
||||
.where(models.FinancialEvent.id.in_(event_ids))
|
||||
.options(selectinload(models.FinancialEvent.split_lines))
|
||||
).all()
|
||||
)
|
||||
if event_ids
|
||||
else []
|
||||
)
|
||||
_attach_event_tags(session, events)
|
||||
return ImportRunDetailRead(
|
||||
run=ImportRunRead.model_validate(run),
|
||||
observations=[RawObservationRead.model_validate(observation) for observation in observations],
|
||||
events=[EventRead.model_validate(event) for event in events],
|
||||
)
|
||||
150
apps/api/mousehold_api/routers/projects.py
Normal file
150
apps/api/mousehold_api/routers/projects.py
Normal file
@@ -0,0 +1,150 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import models
|
||||
from ..db import get_session
|
||||
from ..schemas import (
|
||||
PaymentLineCreate,
|
||||
PaymentLineRead,
|
||||
ProjectBudgetLineCreate,
|
||||
ProjectBudgetLineRead,
|
||||
ProjectCreate,
|
||||
ProjectParticipantCreate,
|
||||
ProjectParticipantRead,
|
||||
ProjectPlannedItemCreate,
|
||||
ProjectPlannedItemRead,
|
||||
ProjectRead,
|
||||
ProjectSummary,
|
||||
SettlementSummary,
|
||||
)
|
||||
from ..services.projects import calculate_project_summary
|
||||
from ..services.settlement import calculate_settlement
|
||||
|
||||
router = APIRouter(tags=["projects"])
|
||||
|
||||
|
||||
def _get_project(session: Session, project_id: str) -> models.Project:
|
||||
project = session.get(models.Project, project_id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return project
|
||||
|
||||
|
||||
@router.post("/projects", response_model=ProjectRead)
|
||||
def create_project(
|
||||
payload: ProjectCreate, session: Session = Depends(get_session)
|
||||
) -> models.Project:
|
||||
project = models.Project(**payload.model_dump(exclude={"participant_user_ids"}))
|
||||
session.add(project)
|
||||
session.flush()
|
||||
for user_id in payload.participant_user_ids:
|
||||
session.add(models.ProjectParticipant(project_id=project.id, user_id=user_id))
|
||||
session.commit()
|
||||
session.refresh(project)
|
||||
return project
|
||||
|
||||
|
||||
@router.get("/projects", response_model=list[ProjectRead])
|
||||
def list_projects(
|
||||
household_id: str, session: Session = Depends(get_session)
|
||||
) -> list[models.Project]:
|
||||
return list(
|
||||
session.scalars(
|
||||
select(models.Project)
|
||||
.where(models.Project.household_id == household_id)
|
||||
.order_by(models.Project.created_at.desc())
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}", response_model=ProjectRead)
|
||||
def get_project(project_id: str, session: Session = Depends(get_session)) -> models.Project:
|
||||
return _get_project(session, project_id)
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/participants", response_model=ProjectParticipantRead)
|
||||
def add_project_participant(
|
||||
project_id: str,
|
||||
payload: ProjectParticipantCreate,
|
||||
session: Session = Depends(get_session),
|
||||
) -> models.ProjectParticipant:
|
||||
_get_project(session, project_id)
|
||||
participant = models.ProjectParticipant(project_id=project_id, **payload.model_dump())
|
||||
session.add(participant)
|
||||
session.commit()
|
||||
session.refresh(participant)
|
||||
return participant
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/budget-lines", response_model=ProjectBudgetLineRead)
|
||||
def add_project_budget_line(
|
||||
project_id: str,
|
||||
payload: ProjectBudgetLineCreate,
|
||||
session: Session = Depends(get_session),
|
||||
) -> models.ProjectBudgetLine:
|
||||
_get_project(session, project_id)
|
||||
budget_line = models.ProjectBudgetLine(project_id=project_id, **payload.model_dump())
|
||||
session.add(budget_line)
|
||||
session.commit()
|
||||
session.refresh(budget_line)
|
||||
return budget_line
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/planned-items", response_model=ProjectPlannedItemRead)
|
||||
def add_project_planned_item(
|
||||
project_id: str,
|
||||
payload: ProjectPlannedItemCreate,
|
||||
session: Session = Depends(get_session),
|
||||
) -> models.ProjectPlannedItem:
|
||||
_get_project(session, project_id)
|
||||
planned_item = models.ProjectPlannedItem(project_id=project_id, **payload.model_dump())
|
||||
session.add(planned_item)
|
||||
session.commit()
|
||||
session.refresh(planned_item)
|
||||
return planned_item
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/payment-lines", response_model=PaymentLineRead)
|
||||
def add_project_payment_line(
|
||||
project_id: str,
|
||||
payload: PaymentLineCreate,
|
||||
session: Session = Depends(get_session),
|
||||
) -> models.PaymentLine:
|
||||
_get_project(session, project_id)
|
||||
if payload.planned_item_id and not session.get(
|
||||
models.ProjectPlannedItem, payload.planned_item_id
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Planned item not found")
|
||||
payment_line = models.PaymentLine(**payload.model_dump())
|
||||
session.add(payment_line)
|
||||
session.commit()
|
||||
session.refresh(payment_line)
|
||||
return payment_line
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/summary", response_model=ProjectSummary)
|
||||
def get_project_summary(project_id: str, session: Session = Depends(get_session)) -> ProjectSummary:
|
||||
try:
|
||||
return calculate_project_summary(session, project_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/settlement", response_model=SettlementSummary)
|
||||
def get_project_settlement(
|
||||
project_id: str,
|
||||
currency: str = "EUR",
|
||||
include_announced: bool = False,
|
||||
session: Session = Depends(get_session),
|
||||
) -> SettlementSummary:
|
||||
project = _get_project(session, project_id)
|
||||
return calculate_settlement(
|
||||
session,
|
||||
household_id=project.household_id,
|
||||
project_id=project_id,
|
||||
currency=currency,
|
||||
include_announced=include_announced,
|
||||
)
|
||||
84
apps/api/mousehold_api/routers/reconciliation.py
Normal file
84
apps/api/mousehold_api/routers/reconciliation.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..db import get_session
|
||||
from ..schemas import (
|
||||
ReconciliationAcceptResponse,
|
||||
ReconciliationDecisionRead,
|
||||
ReconciliationIgnoreRequest,
|
||||
ReconciliationIgnoreResponse,
|
||||
ReconciliationSuggestionRead,
|
||||
)
|
||||
from ..services.reconciliation import (
|
||||
accept_reconciliation_suggestion,
|
||||
ignore_reconciliation_suggestion,
|
||||
list_reconciliation_decisions,
|
||||
list_reconciliation_suggestions,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["reconciliation"])
|
||||
|
||||
|
||||
@router.get("/reconciliation/suggestions", response_model=list[ReconciliationSuggestionRead])
|
||||
def get_reconciliation_suggestions(
|
||||
household_id: str,
|
||||
limit: int = 50,
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[object]:
|
||||
return list_reconciliation_suggestions(session, household_id=household_id, limit=limit)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/reconciliation/suggestions/{suggestion_id}/accept",
|
||||
response_model=ReconciliationAcceptResponse,
|
||||
)
|
||||
def accept_suggestion(
|
||||
suggestion_id: str,
|
||||
household_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
) -> ReconciliationAcceptResponse:
|
||||
try:
|
||||
suggestion = accept_reconciliation_suggestion(
|
||||
session,
|
||||
household_id=household_id,
|
||||
suggestion_id=suggestion_id,
|
||||
)
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
session.commit()
|
||||
return ReconciliationAcceptResponse(accepted=suggestion)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/reconciliation/suggestions/{suggestion_id}/ignore",
|
||||
response_model=ReconciliationIgnoreResponse,
|
||||
)
|
||||
def ignore_suggestion(
|
||||
suggestion_id: str,
|
||||
household_id: str,
|
||||
payload: ReconciliationIgnoreRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
) -> ReconciliationIgnoreResponse:
|
||||
try:
|
||||
suggestion, decision = ignore_reconciliation_suggestion(
|
||||
session,
|
||||
household_id=household_id,
|
||||
suggestion_id=suggestion_id,
|
||||
actor_user_id=payload.actor_user_id if payload else None,
|
||||
reason=payload.reason if payload else None,
|
||||
)
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
session.commit()
|
||||
session.refresh(decision)
|
||||
return ReconciliationIgnoreResponse(ignored=suggestion, decision=decision)
|
||||
|
||||
|
||||
@router.get("/reconciliation/decisions", response_model=list[ReconciliationDecisionRead])
|
||||
def get_reconciliation_decisions(
|
||||
household_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[object]:
|
||||
return list_reconciliation_decisions(session, household_id=household_id)
|
||||
75
apps/api/mousehold_api/routers/recurring.py
Normal file
75
apps/api/mousehold_api/routers/recurring.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import models
|
||||
from ..db import get_session
|
||||
from ..schemas import (
|
||||
RecurringCommitmentCreate,
|
||||
RecurringCommitmentRead,
|
||||
RecurringCommitmentUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["recurring"])
|
||||
|
||||
|
||||
@router.post("/recurring-commitments", response_model=RecurringCommitmentRead)
|
||||
def create_recurring_commitment(
|
||||
payload: RecurringCommitmentCreate,
|
||||
session: Session = Depends(get_session),
|
||||
) -> models.RecurringCommitment:
|
||||
if not session.get(models.Household, payload.household_id):
|
||||
raise HTTPException(status_code=404, detail="Household not found")
|
||||
commitment = models.RecurringCommitment(**payload.model_dump())
|
||||
session.add(commitment)
|
||||
session.commit()
|
||||
session.refresh(commitment)
|
||||
return commitment
|
||||
|
||||
|
||||
@router.get("/recurring-commitments", response_model=list[RecurringCommitmentRead])
|
||||
def list_recurring_commitments(
|
||||
household_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[models.RecurringCommitment]:
|
||||
return list(
|
||||
session.scalars(
|
||||
select(models.RecurringCommitment)
|
||||
.where(models.RecurringCommitment.household_id == household_id)
|
||||
.order_by(models.RecurringCommitment.next_due_date)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/recurring-commitments/{commitment_id}", response_model=RecurringCommitmentRead)
|
||||
def update_recurring_commitment(
|
||||
commitment_id: str,
|
||||
payload: RecurringCommitmentUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
) -> models.RecurringCommitment:
|
||||
commitment = session.get(models.RecurringCommitment, commitment_id)
|
||||
if not commitment:
|
||||
raise HTTPException(status_code=404, detail="Recurring commitment not found")
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
if field == "currency" and value is not None:
|
||||
value = value.upper()
|
||||
setattr(commitment, field, value)
|
||||
session.add(commitment)
|
||||
session.commit()
|
||||
session.refresh(commitment)
|
||||
return commitment
|
||||
|
||||
|
||||
@router.delete("/recurring-commitments/{commitment_id}")
|
||||
def delete_recurring_commitment(
|
||||
commitment_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict[str, bool]:
|
||||
commitment = session.get(models.RecurringCommitment, commitment_id)
|
||||
if not commitment:
|
||||
raise HTTPException(status_code=404, detail="Recurring commitment not found")
|
||||
session.delete(commitment)
|
||||
session.commit()
|
||||
return {"deleted": True}
|
||||
27
apps/api/mousehold_api/routers/settlement.py
Normal file
27
apps/api/mousehold_api/routers/settlement.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..db import get_session
|
||||
from ..schemas import SettlementSummary
|
||||
from ..services.settlement import calculate_settlement
|
||||
|
||||
router = APIRouter(tags=["settlement"])
|
||||
|
||||
|
||||
@router.get("/settlement/summary", response_model=SettlementSummary)
|
||||
def get_settlement_summary(
|
||||
household_id: str,
|
||||
project_id: str | None = None,
|
||||
currency: str = "EUR",
|
||||
include_announced: bool = False,
|
||||
session: Session = Depends(get_session),
|
||||
) -> SettlementSummary:
|
||||
return calculate_settlement(
|
||||
session,
|
||||
household_id=household_id,
|
||||
project_id=project_id,
|
||||
currency=currency,
|
||||
include_announced=include_announced,
|
||||
)
|
||||
188
apps/api/mousehold_api/routers/taxonomy.py
Normal file
188
apps/api/mousehold_api/routers/taxonomy.py
Normal file
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import models
|
||||
from ..db import get_session
|
||||
from ..schemas import (
|
||||
CategoryCreate,
|
||||
CategoryRead,
|
||||
CategoryUpdate,
|
||||
TagCreate,
|
||||
TagRead,
|
||||
TagUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["taxonomy"])
|
||||
|
||||
|
||||
def _require_household(session: Session, household_id: str) -> None:
|
||||
if not session.get(models.Household, household_id):
|
||||
raise HTTPException(status_code=404, detail="Household not found")
|
||||
|
||||
|
||||
def _category_path(
|
||||
session: Session,
|
||||
payload: CategoryCreate | CategoryUpdate,
|
||||
category: models.Category | None = None,
|
||||
) -> str:
|
||||
if payload.path:
|
||||
return payload.path
|
||||
name = payload.name if payload.name is not None else category.name if category else ""
|
||||
parent_id = payload.parent_id if payload.parent_id is not None else category.parent_id if category else None
|
||||
if parent_id:
|
||||
parent = session.get(models.Category, parent_id)
|
||||
if not parent:
|
||||
raise HTTPException(status_code=404, detail="Parent category not found")
|
||||
return f"{parent.path}/{name}"
|
||||
return name
|
||||
|
||||
|
||||
@router.post("/categories", response_model=CategoryRead)
|
||||
def create_category(
|
||||
payload: CategoryCreate,
|
||||
session: Session = Depends(get_session),
|
||||
) -> models.Category:
|
||||
_require_household(session, payload.household_id)
|
||||
if payload.parent_id:
|
||||
parent = session.get(models.Category, payload.parent_id)
|
||||
if not parent or parent.household_id != payload.household_id:
|
||||
raise HTTPException(status_code=404, detail="Parent category not found")
|
||||
category = models.Category(
|
||||
household_id=payload.household_id,
|
||||
parent_id=payload.parent_id,
|
||||
name=payload.name,
|
||||
path=_category_path(session, payload),
|
||||
is_active=payload.is_active,
|
||||
)
|
||||
session.add(category)
|
||||
session.commit()
|
||||
session.refresh(category)
|
||||
return category
|
||||
|
||||
|
||||
@router.get("/categories", response_model=list[CategoryRead])
|
||||
def list_categories(
|
||||
household_id: str,
|
||||
include_inactive: bool = False,
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[models.Category]:
|
||||
query = (
|
||||
select(models.Category)
|
||||
.where(models.Category.household_id == household_id)
|
||||
.order_by(models.Category.path)
|
||||
)
|
||||
if not include_inactive:
|
||||
query = query.where(models.Category.is_active.is_(True))
|
||||
return list(session.scalars(query).all())
|
||||
|
||||
|
||||
@router.patch("/categories/{category_id}", response_model=CategoryRead)
|
||||
def update_category(
|
||||
category_id: str,
|
||||
payload: CategoryUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
) -> models.Category:
|
||||
category = session.get(models.Category, category_id)
|
||||
if not category:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
if payload.parent_id:
|
||||
parent = session.get(models.Category, payload.parent_id)
|
||||
if not parent or parent.household_id != category.household_id:
|
||||
raise HTTPException(status_code=404, detail="Parent category not found")
|
||||
for field, value in payload.model_dump(exclude_unset=True, exclude={"path"}).items():
|
||||
setattr(category, field, value)
|
||||
if {"name", "parent_id", "path"} & payload.model_fields_set:
|
||||
category.path = _category_path(session, payload, category)
|
||||
session.add(category)
|
||||
session.commit()
|
||||
session.refresh(category)
|
||||
return category
|
||||
|
||||
|
||||
@router.delete("/categories/{category_id}")
|
||||
def delete_category(
|
||||
category_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict[str, bool]:
|
||||
category = session.get(models.Category, category_id)
|
||||
if not category:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
for event in session.scalars(
|
||||
select(models.FinancialEvent).where(models.FinancialEvent.category_id == category_id)
|
||||
):
|
||||
event.category_id = None
|
||||
for commitment in session.scalars(
|
||||
select(models.RecurringCommitment).where(models.RecurringCommitment.category_id == category_id)
|
||||
):
|
||||
commitment.category_id = None
|
||||
for budget_line in session.scalars(
|
||||
select(models.ProjectBudgetLine).where(models.ProjectBudgetLine.category_id == category_id)
|
||||
):
|
||||
budget_line.category_id = None
|
||||
for child in session.scalars(select(models.Category).where(models.Category.parent_id == category_id)):
|
||||
child.parent_id = None
|
||||
child.path = child.name
|
||||
session.delete(category)
|
||||
session.commit()
|
||||
return {"deleted": True}
|
||||
|
||||
|
||||
@router.post("/tags", response_model=TagRead)
|
||||
def create_tag(
|
||||
payload: TagCreate,
|
||||
session: Session = Depends(get_session),
|
||||
) -> models.Tag:
|
||||
_require_household(session, payload.household_id)
|
||||
tag = models.Tag(household_id=payload.household_id, name=payload.name)
|
||||
session.add(tag)
|
||||
session.commit()
|
||||
session.refresh(tag)
|
||||
return tag
|
||||
|
||||
|
||||
@router.get("/tags", response_model=list[TagRead])
|
||||
def list_tags(
|
||||
household_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[models.Tag]:
|
||||
return list(
|
||||
session.scalars(
|
||||
select(models.Tag).where(models.Tag.household_id == household_id).order_by(models.Tag.name)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/tags/{tag_id}", response_model=TagRead)
|
||||
def update_tag(
|
||||
tag_id: str,
|
||||
payload: TagUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
) -> models.Tag:
|
||||
tag = session.get(models.Tag, tag_id)
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(tag, field, value)
|
||||
session.add(tag)
|
||||
session.commit()
|
||||
session.refresh(tag)
|
||||
return tag
|
||||
|
||||
|
||||
@router.delete("/tags/{tag_id}")
|
||||
def delete_tag(
|
||||
tag_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict[str, bool]:
|
||||
tag = session.get(models.Tag, tag_id)
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
session.query(models.EventTag).filter(models.EventTag.tag_id == tag_id).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
session.delete(tag)
|
||||
session.commit()
|
||||
return {"deleted": True}
|
||||
782
apps/api/mousehold_api/schemas.py
Normal file
782
apps/api/mousehold_api/schemas.py
Normal file
@@ -0,0 +1,782 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from mousehold_domain import (
|
||||
AccountType,
|
||||
EventObservationRelation,
|
||||
FinancialEventStatus,
|
||||
FinancialEventType,
|
||||
ImportConnectionStatus,
|
||||
ImportConnectionType,
|
||||
ObservationStatus,
|
||||
PaymentLineStatus,
|
||||
PaymentStatus,
|
||||
PlanningStatus,
|
||||
ProjectStatus,
|
||||
ReconciliationStatus,
|
||||
RecurringCommitmentStatus,
|
||||
RecurringInterval,
|
||||
RelationType,
|
||||
SettlementStatus,
|
||||
SourceType,
|
||||
VisibilityMode,
|
||||
)
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ApiModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class HouseholdCreate(ApiModel):
|
||||
name: str
|
||||
default_currency: str = "EUR"
|
||||
|
||||
|
||||
class HouseholdUpdate(ApiModel):
|
||||
name: str | None = None
|
||||
default_currency: str | None = None
|
||||
|
||||
|
||||
class HouseholdRead(ApiModel):
|
||||
id: str
|
||||
name: str
|
||||
default_currency: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class UserCreate(ApiModel):
|
||||
household_id: str
|
||||
display_name: str
|
||||
email: str | None = None
|
||||
|
||||
|
||||
class UserUpdate(ApiModel):
|
||||
display_name: str | None = None
|
||||
email: str | None = None
|
||||
|
||||
|
||||
class UserRead(ApiModel):
|
||||
id: str
|
||||
household_id: str
|
||||
display_name: str
|
||||
email: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class LoginIdentityCreate(ApiModel):
|
||||
user_id: str
|
||||
provider: str
|
||||
provider_subject: str
|
||||
email: str | None = None
|
||||
display_name: str | None = None
|
||||
|
||||
|
||||
class LoginIdentityRead(ApiModel):
|
||||
id: str
|
||||
user_id: str
|
||||
provider: str
|
||||
provider_subject: str
|
||||
email: str | None
|
||||
display_name: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class LocalLoginRequest(ApiModel):
|
||||
user_id: str
|
||||
|
||||
|
||||
class LocalSessionRead(ApiModel):
|
||||
id: str
|
||||
user_id: str
|
||||
household_id: str
|
||||
created_at: datetime
|
||||
last_seen_at: datetime
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class AuthSessionRead(ApiModel):
|
||||
session: LocalSessionRead
|
||||
user: UserRead
|
||||
household: HouseholdRead
|
||||
|
||||
|
||||
class AccountCreate(ApiModel):
|
||||
household_id: str
|
||||
owner_user_id: str | None = None
|
||||
name: str
|
||||
account_type: AccountType
|
||||
institution_name: str | None = None
|
||||
currency: str = "EUR"
|
||||
visibility_mode: VisibilityMode = VisibilityMode.PRIVATE
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class AccountUpdate(ApiModel):
|
||||
owner_user_id: str | None = None
|
||||
name: str | None = None
|
||||
account_type: AccountType | None = None
|
||||
institution_name: str | None = None
|
||||
currency: str | None = None
|
||||
visibility_mode: VisibilityMode | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class AccountRead(ApiModel):
|
||||
id: str
|
||||
household_id: str
|
||||
owner_user_id: str | None
|
||||
name: str
|
||||
account_type: AccountType
|
||||
institution_name: str | None
|
||||
currency: str
|
||||
visibility_mode: VisibilityMode
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class SplitLineCreate(ApiModel):
|
||||
user_id: str
|
||||
share_amount: Decimal
|
||||
share_percent: Decimal | None = None
|
||||
reason: str = "manual"
|
||||
|
||||
|
||||
class SplitLineRead(ApiModel):
|
||||
id: str
|
||||
event_id: str
|
||||
user_id: str
|
||||
share_amount: Decimal
|
||||
share_percent: Decimal | None
|
||||
reason: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ManualObservationCreate(ApiModel):
|
||||
household_id: str
|
||||
source_type: SourceType = SourceType.MANUAL
|
||||
source_account_id: str | None = None
|
||||
owner_user_id: str | None = None
|
||||
observed_at: datetime | None = None
|
||||
source_reference: str | None = None
|
||||
provider_transaction_id: str | None = None
|
||||
message_id: str | None = None
|
||||
raw_text: str | None = None
|
||||
raw_payload_json: dict[str, Any] | None = None
|
||||
amount: Decimal
|
||||
currency: str = "EUR"
|
||||
event_date: date
|
||||
booking_date: date | None = None
|
||||
due_date: date | None = None
|
||||
merchant: str | None = None
|
||||
counterparty: str | None = None
|
||||
description: str | None = None
|
||||
event_type: FinancialEventType = FinancialEventType.EXPENSE
|
||||
status: FinancialEventStatus | None = None
|
||||
payer_user_id: str | None = None
|
||||
payment_account_id: str | None = None
|
||||
category_id: str | None = None
|
||||
project_id: str | None = None
|
||||
split_lines: list[SplitLineCreate] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ObservationImportCreate(ManualObservationCreate):
|
||||
source_type: SourceType
|
||||
|
||||
|
||||
class RawObservationRead(ApiModel):
|
||||
id: str
|
||||
household_id: str
|
||||
source_type: SourceType
|
||||
source_account_id: str | None
|
||||
owner_user_id: str | None
|
||||
observed_at: datetime | None
|
||||
received_at: datetime
|
||||
raw_hash: str
|
||||
source_reference: str | None
|
||||
provider_transaction_id: str | None
|
||||
message_id: str | None
|
||||
raw_text: str | None
|
||||
raw_file_path: str | None
|
||||
raw_payload_json: dict[str, Any] | None
|
||||
parsed_json: dict[str, Any] | None
|
||||
parser_name: str | None
|
||||
parser_version: str | None
|
||||
confidence: Decimal
|
||||
status: ObservationStatus
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class EventObservationLinkRead(ApiModel):
|
||||
id: str
|
||||
event_id: str
|
||||
observation_id: str
|
||||
relation: EventObservationRelation
|
||||
confidence: Decimal
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class EventCreate(ApiModel):
|
||||
household_id: str
|
||||
source_account_id: str | None = None
|
||||
payment_account_id: str | None = None
|
||||
payer_user_id: str | None = None
|
||||
event_type: FinancialEventType = FinancialEventType.EXPENSE
|
||||
status: FinancialEventStatus = FinancialEventStatus.CANDIDATE
|
||||
event_date: date
|
||||
booking_date: date | None = None
|
||||
due_date: date | None = None
|
||||
amount: Decimal
|
||||
currency: str = "EUR"
|
||||
merchant: str | None = None
|
||||
counterparty: str | None = None
|
||||
description: str | None = None
|
||||
category_id: str | None = None
|
||||
project_id: str | None = None
|
||||
created_by_user_id: str | None = None
|
||||
split_lines: list[SplitLineCreate] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EventUpdate(ApiModel):
|
||||
source_account_id: str | None = None
|
||||
payment_account_id: str | None = None
|
||||
payer_user_id: str | None = None
|
||||
event_type: FinancialEventType | None = None
|
||||
status: FinancialEventStatus | None = None
|
||||
event_date: date | None = None
|
||||
booking_date: date | None = None
|
||||
due_date: date | None = None
|
||||
amount: Decimal | None = None
|
||||
currency: str | None = None
|
||||
merchant: str | None = None
|
||||
counterparty: str | None = None
|
||||
description: str | None = None
|
||||
category_id: str | None = None
|
||||
project_id: str | None = None
|
||||
|
||||
|
||||
class EventRead(ApiModel):
|
||||
id: str
|
||||
household_id: str
|
||||
source_account_id: str | None
|
||||
payment_account_id: str | None
|
||||
payer_user_id: str | None
|
||||
event_type: FinancialEventType
|
||||
status: FinancialEventStatus
|
||||
event_date: date
|
||||
booking_date: date | None
|
||||
due_date: date | None
|
||||
amount: Decimal
|
||||
currency: str
|
||||
merchant: str | None
|
||||
counterparty: str | None
|
||||
description: str | None
|
||||
category_id: str | None
|
||||
project_id: str | None
|
||||
created_by_user_id: str | None
|
||||
fingerprint: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
split_lines: list[SplitLineRead] = Field(default_factory=list)
|
||||
tag_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EventRelationCreate(ApiModel):
|
||||
to_event_id: str
|
||||
relation_type: RelationType
|
||||
confidence: Decimal = Decimal("1.00")
|
||||
created_by: str = "user"
|
||||
|
||||
|
||||
class EventRelationRead(ApiModel):
|
||||
id: str
|
||||
from_event_id: str
|
||||
to_event_id: str
|
||||
relation_type: RelationType
|
||||
confidence: Decimal
|
||||
created_by: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CategoryCreate(ApiModel):
|
||||
household_id: str
|
||||
name: str
|
||||
parent_id: str | None = None
|
||||
path: str | None = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class CategoryUpdate(ApiModel):
|
||||
name: str | None = None
|
||||
parent_id: str | None = None
|
||||
path: str | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class CategoryRead(ApiModel):
|
||||
id: str
|
||||
household_id: str
|
||||
parent_id: str | None
|
||||
name: str
|
||||
path: str
|
||||
is_active: bool
|
||||
|
||||
|
||||
class TagCreate(ApiModel):
|
||||
household_id: str
|
||||
name: str
|
||||
|
||||
|
||||
class TagUpdate(ApiModel):
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class TagRead(ApiModel):
|
||||
id: str
|
||||
household_id: str
|
||||
name: str
|
||||
|
||||
|
||||
class EventTagsUpdate(ApiModel):
|
||||
tag_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EventDetailRead(ApiModel):
|
||||
event: EventRead
|
||||
category: CategoryRead | None = None
|
||||
tags: list[TagRead] = Field(default_factory=list)
|
||||
observation_links: list[EventObservationLinkRead] = Field(default_factory=list)
|
||||
observations: list[RawObservationRead] = Field(default_factory=list)
|
||||
relations: list[EventRelationRead] = Field(default_factory=list)
|
||||
related_events: list[EventRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ObservationReviewItem(ApiModel):
|
||||
observation: RawObservationRead
|
||||
events: list[EventRead] = Field(default_factory=list)
|
||||
action_required: bool = True
|
||||
suggested_action: str = "review"
|
||||
|
||||
|
||||
class ObservationAcceptRequest(ApiModel):
|
||||
event_status: FinancialEventStatus | None = None
|
||||
create_event_if_missing: bool = True
|
||||
|
||||
|
||||
class ObservationUpdateRequest(ApiModel):
|
||||
source_account_id: str | None = None
|
||||
owner_user_id: str | None = None
|
||||
raw_text: str | None = None
|
||||
amount: Decimal | None = None
|
||||
currency: str | None = None
|
||||
event_date: date | None = None
|
||||
booking_date: date | None = None
|
||||
due_date: date | None = None
|
||||
merchant: str | None = None
|
||||
counterparty: str | None = None
|
||||
description: str | None = None
|
||||
event_type: FinancialEventType | None = None
|
||||
status: FinancialEventStatus | None = None
|
||||
sync_linked_events: bool = True
|
||||
|
||||
|
||||
class ObservationIgnoreRequest(ApiModel):
|
||||
ignore_linked_events: bool = False
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class ObservationLinkRequest(ApiModel):
|
||||
event_id: str
|
||||
relation: EventObservationRelation = EventObservationRelation.EVIDENCE_FOR
|
||||
|
||||
|
||||
class ObservationBatchRequest(ApiModel):
|
||||
observation_ids: list[str]
|
||||
action: str
|
||||
event_status: FinancialEventStatus | None = None
|
||||
ignore_linked_events: bool = False
|
||||
|
||||
|
||||
class ObservationBatchResponse(ApiModel):
|
||||
updated_count: int
|
||||
items: list[ObservationReviewItem]
|
||||
|
||||
|
||||
class SettlementTransfer(ApiModel):
|
||||
from_user_id: str
|
||||
to_user_id: str
|
||||
amount: Decimal
|
||||
currency: str
|
||||
|
||||
|
||||
class SettlementUserBalance(ApiModel):
|
||||
user_id: str
|
||||
paid: Decimal
|
||||
share: Decimal
|
||||
net: Decimal
|
||||
|
||||
|
||||
class SettlementSummary(ApiModel):
|
||||
household_id: str
|
||||
project_id: str | None
|
||||
currency: str
|
||||
included_event_ids: list[str]
|
||||
excluded_event_ids: list[str]
|
||||
balances: list[SettlementUserBalance]
|
||||
suggested_transfers: list[SettlementTransfer]
|
||||
explanation: list[str]
|
||||
|
||||
|
||||
class ProjectCreate(ApiModel):
|
||||
household_id: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
start_date: date | None = None
|
||||
end_date: date | None = None
|
||||
default_split_rule_id: str | None = None
|
||||
status: ProjectStatus = ProjectStatus.PLANNING
|
||||
currency: str = "EUR"
|
||||
participant_user_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ProjectRead(ApiModel):
|
||||
id: str
|
||||
household_id: str
|
||||
name: str
|
||||
description: str | None
|
||||
start_date: date | None
|
||||
end_date: date | None
|
||||
default_split_rule_id: str | None
|
||||
status: ProjectStatus
|
||||
currency: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ProjectParticipantCreate(ApiModel):
|
||||
user_id: str
|
||||
role: str = "participant"
|
||||
default_share_percent: Decimal | None = None
|
||||
|
||||
|
||||
class ProjectParticipantRead(ApiModel):
|
||||
id: str
|
||||
project_id: str
|
||||
user_id: str
|
||||
role: str
|
||||
default_share_percent: Decimal | None
|
||||
|
||||
|
||||
class ProjectBudgetLineCreate(ApiModel):
|
||||
category_id: str | None = None
|
||||
name: str
|
||||
budgeted_amount: Decimal = Decimal("0.00")
|
||||
sort_order: int = 0
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ProjectBudgetLineRead(ApiModel):
|
||||
id: str
|
||||
project_id: str
|
||||
category_id: str | None
|
||||
name: str
|
||||
budgeted_amount: Decimal
|
||||
committed_amount_cached: Decimal | None
|
||||
paid_amount_cached: Decimal | None
|
||||
sort_order: int
|
||||
notes: str | None
|
||||
|
||||
|
||||
class ProjectPlannedItemCreate(ApiModel):
|
||||
budget_line_id: str | None = None
|
||||
description: str
|
||||
estimated_amount: Decimal = Decimal("0.00")
|
||||
committed_amount: Decimal | None = None
|
||||
expected_date: date | None = None
|
||||
due_date: date | None = None
|
||||
expected_payer_account_id: str | None = None
|
||||
split_rule_id: str | None = None
|
||||
planning_status: PlanningStatus = PlanningStatus.IDEA
|
||||
payment_status: PaymentStatus = PaymentStatus.UNPAID
|
||||
reconciliation_status: ReconciliationStatus = ReconciliationStatus.NO_EVIDENCE
|
||||
settlement_status: SettlementStatus = SettlementStatus.UNSETTLED
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ProjectPlannedItemRead(ApiModel):
|
||||
id: str
|
||||
project_id: str
|
||||
budget_line_id: str | None
|
||||
description: str
|
||||
estimated_amount: Decimal
|
||||
committed_amount: Decimal | None
|
||||
expected_date: date | None
|
||||
due_date: date | None
|
||||
expected_payer_account_id: str | None
|
||||
split_rule_id: str | None
|
||||
planning_status: PlanningStatus
|
||||
payment_status: PaymentStatus
|
||||
reconciliation_status: ReconciliationStatus
|
||||
settlement_status: SettlementStatus
|
||||
notes: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class PaymentLineCreate(ApiModel):
|
||||
planned_item_id: str | None = None
|
||||
event_id: str | None = None
|
||||
amount: Decimal
|
||||
currency: str = "EUR"
|
||||
date: date
|
||||
paid_by_user_id: str | None = None
|
||||
payment_account_id: str | None = None
|
||||
source_observation_id: str | None = None
|
||||
status: PaymentLineStatus = PaymentLineStatus.EXPECTED
|
||||
|
||||
|
||||
class PaymentLineRead(ApiModel):
|
||||
id: str
|
||||
planned_item_id: str | None
|
||||
event_id: str | None
|
||||
amount: Decimal
|
||||
currency: str
|
||||
date: date
|
||||
paid_by_user_id: str | None
|
||||
payment_account_id: str | None
|
||||
source_observation_id: str | None
|
||||
status: PaymentLineStatus
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ProjectSummary(ApiModel):
|
||||
project_id: str
|
||||
currency: str
|
||||
budgeted_total: Decimal
|
||||
committed_total: Decimal
|
||||
paid_total: Decimal
|
||||
reconciled_total: Decimal
|
||||
settled_total: Decimal
|
||||
due_later_total: Decimal
|
||||
remaining_uncommitted_budget: Decimal
|
||||
open_actions: list[str]
|
||||
|
||||
|
||||
class ImportConnectionCreate(ApiModel):
|
||||
household_id: str
|
||||
owner_user_id: str
|
||||
connection_type: ImportConnectionType
|
||||
display_name: str
|
||||
status: ImportConnectionStatus = ImportConnectionStatus.NEEDS_AUTH
|
||||
secret_storage_ref: str | None = None
|
||||
runs_locally: bool = True
|
||||
connector_config_json: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ImportConnectionUpdate(ApiModel):
|
||||
owner_user_id: str | None = None
|
||||
connection_type: ImportConnectionType | None = None
|
||||
display_name: str | None = None
|
||||
status: ImportConnectionStatus | None = None
|
||||
secret_storage_ref: str | None = None
|
||||
runs_locally: bool | None = None
|
||||
connector_config_json: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ImportConnectionRead(ApiModel):
|
||||
id: str
|
||||
household_id: str
|
||||
owner_user_id: str
|
||||
connection_type: ImportConnectionType
|
||||
display_name: str
|
||||
status: ImportConnectionStatus
|
||||
last_sync_at: datetime | None
|
||||
last_error: str | None
|
||||
secret_storage_ref: str | None
|
||||
runs_locally: bool
|
||||
connector_config_json: dict[str, Any] | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ImportRunRead(ApiModel):
|
||||
id: str
|
||||
household_id: str
|
||||
connection_id: str | None
|
||||
actor_user_id: str | None
|
||||
source_type: str | None
|
||||
status: str
|
||||
imported_count: int
|
||||
duplicate_count: int
|
||||
created_event_count: int
|
||||
skipped_count: int
|
||||
error: str | None
|
||||
summary_json: dict[str, Any] | None
|
||||
started_at: datetime
|
||||
finished_at: datetime | None
|
||||
|
||||
|
||||
class ImportRunDetailRead(ApiModel):
|
||||
run: ImportRunRead
|
||||
observations: list[RawObservationRead] = Field(default_factory=list)
|
||||
events: list[EventRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class LocalAgentRegister(ApiModel):
|
||||
household_id: str
|
||||
owner_user_id: str
|
||||
display_name: str = "Local connector agent"
|
||||
|
||||
|
||||
class LocalAgentHeartbeat(ApiModel):
|
||||
connection_id: str
|
||||
status: ImportConnectionStatus = ImportConnectionStatus.ACTIVE
|
||||
last_error: str | None = None
|
||||
|
||||
|
||||
class SyncObservationsRequest(ApiModel):
|
||||
connection_id: str
|
||||
observations: list[ObservationImportCreate]
|
||||
|
||||
|
||||
class SyncObservationsResponse(ApiModel):
|
||||
imported_observation_ids: list[str]
|
||||
created_event_ids: list[str]
|
||||
duplicate_observation_ids: list[str]
|
||||
connection_id: str | None = None
|
||||
imported_count: int = 0
|
||||
duplicate_count: int = 0
|
||||
created_event_count: int = 0
|
||||
run: ImportRunRead | None = None
|
||||
|
||||
|
||||
class BackupResult(ApiModel):
|
||||
file_path: str
|
||||
size_bytes: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class HouseholdExport(ApiModel):
|
||||
household: dict[str, Any]
|
||||
exported_at: datetime
|
||||
counts: dict[str, int]
|
||||
data: dict[str, list[dict[str, Any]]]
|
||||
|
||||
|
||||
class RecurringCommitmentCreate(ApiModel):
|
||||
household_id: str
|
||||
provider: str
|
||||
description: str | None = None
|
||||
amount_expected: Decimal
|
||||
amount_min: Decimal | None = None
|
||||
amount_max: Decimal | None = None
|
||||
currency: str = "EUR"
|
||||
interval: RecurringInterval
|
||||
next_due_date: date
|
||||
payer_account_id: str | None = None
|
||||
category_id: str | None = None
|
||||
project_id: str | None = None
|
||||
notice_period: str | None = None
|
||||
renewal_date: date | None = None
|
||||
status: RecurringCommitmentStatus = RecurringCommitmentStatus.ACTIVE
|
||||
matching_rule_json: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class RecurringCommitmentUpdate(ApiModel):
|
||||
provider: str | None = None
|
||||
description: str | None = None
|
||||
amount_expected: Decimal | None = None
|
||||
amount_min: Decimal | None = None
|
||||
amount_max: Decimal | None = None
|
||||
currency: str | None = None
|
||||
interval: RecurringInterval | None = None
|
||||
next_due_date: date | None = None
|
||||
payer_account_id: str | None = None
|
||||
category_id: str | None = None
|
||||
project_id: str | None = None
|
||||
notice_period: str | None = None
|
||||
renewal_date: date | None = None
|
||||
status: RecurringCommitmentStatus | None = None
|
||||
matching_rule_json: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class RecurringCommitmentRead(ApiModel):
|
||||
id: str
|
||||
household_id: str
|
||||
provider: str
|
||||
description: str | None
|
||||
amount_expected: Decimal
|
||||
amount_min: Decimal | None
|
||||
amount_max: Decimal | None
|
||||
currency: str
|
||||
interval: RecurringInterval
|
||||
next_due_date: date
|
||||
payer_account_id: str | None
|
||||
category_id: str | None
|
||||
project_id: str | None
|
||||
notice_period: str | None
|
||||
renewal_date: date | None
|
||||
status: RecurringCommitmentStatus
|
||||
matching_rule_json: dict[str, Any] | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ReconciliationEventSummary(ApiModel):
|
||||
id: str
|
||||
source_type: str | None
|
||||
event_type: str
|
||||
status: str
|
||||
event_date: date
|
||||
amount: Decimal
|
||||
currency: str
|
||||
merchant: str | None
|
||||
counterparty: str | None
|
||||
description: str | None
|
||||
source_reference: str | None
|
||||
provider_transaction_id: str | None
|
||||
observation_id: str | None
|
||||
|
||||
|
||||
class ReconciliationSuggestionRead(ApiModel):
|
||||
id: str
|
||||
kind: str
|
||||
relation_type: RelationType
|
||||
primary_event: ReconciliationEventSummary
|
||||
confirming_event: ReconciliationEventSummary
|
||||
confidence: Decimal
|
||||
reasons: list[str]
|
||||
action: str
|
||||
|
||||
|
||||
class ReconciliationAcceptResponse(ApiModel):
|
||||
accepted: ReconciliationSuggestionRead
|
||||
|
||||
|
||||
class ReconciliationIgnoreRequest(ApiModel):
|
||||
actor_user_id: str | None = None
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class ReconciliationDecisionRead(ApiModel):
|
||||
id: str
|
||||
household_id: str
|
||||
suggestion_id: str
|
||||
decision: str
|
||||
kind: str
|
||||
relation_type: RelationType
|
||||
primary_event_id: str
|
||||
confirming_event_id: str
|
||||
confidence: Decimal
|
||||
actor_user_id: str | None
|
||||
reason: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ReconciliationIgnoreResponse(ApiModel):
|
||||
ignored: ReconciliationSuggestionRead
|
||||
decision: ReconciliationDecisionRead
|
||||
1
apps/api/mousehold_api/services/__init__.py
Normal file
1
apps/api/mousehold_api/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Application services for ledger ingestion, reconciliation, and summaries."""
|
||||
192
apps/api/mousehold_api/services/ingestion.py
Normal file
192
apps/api/mousehold_api/services/ingestion.py
Normal file
@@ -0,0 +1,192 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from mousehold_domain import (
|
||||
EventObservationRelation,
|
||||
FinancialEventStatus,
|
||||
FinancialEventType,
|
||||
ObservationStatus,
|
||||
SourceType,
|
||||
)
|
||||
from mousehold_matching import (
|
||||
compute_normalized_transaction_fingerprint,
|
||||
compute_raw_observation_hash,
|
||||
)
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import models
|
||||
from ..schemas import ManualObservationCreate, ObservationImportCreate
|
||||
|
||||
|
||||
def infer_event_status(payload: ManualObservationCreate) -> FinancialEventStatus:
|
||||
if payload.status:
|
||||
return payload.status
|
||||
if payload.source_type in {
|
||||
SourceType.BANK_CSV,
|
||||
SourceType.FINTS,
|
||||
SourceType.PSD2_AGGREGATOR,
|
||||
SourceType.PAYPAL_API,
|
||||
SourceType.PAYPAL_CSV,
|
||||
SourceType.KLARNA_CSV,
|
||||
}:
|
||||
return FinancialEventStatus.CONFIRMED
|
||||
if (
|
||||
payload.source_type in {SourceType.KLARNA_EMAIL, SourceType.PAYPAL_EMAIL}
|
||||
and payload.due_date
|
||||
):
|
||||
return FinancialEventStatus.OUTSTANDING
|
||||
if payload.source_type in {
|
||||
SourceType.ORDER_CONFIRMATION_EMAIL,
|
||||
SourceType.IMAP_EMAIL,
|
||||
SourceType.PDF_INVOICE,
|
||||
}:
|
||||
return FinancialEventStatus.ANNOUNCED
|
||||
return FinancialEventStatus.CANDIDATE
|
||||
|
||||
|
||||
def infer_event_type(payload: ManualObservationCreate) -> FinancialEventType:
|
||||
if payload.event_type != FinancialEventType.EXPENSE:
|
||||
return payload.event_type
|
||||
if payload.source_type not in {SourceType.BANK_CSV, SourceType.FINTS, SourceType.PSD2_AGGREGATOR}:
|
||||
return payload.event_type
|
||||
text = f"{payload.merchant or ''} {payload.counterparty or ''} {payload.description or ''}".lower()
|
||||
liability_markers = (
|
||||
"paypal",
|
||||
"klarna",
|
||||
"visa",
|
||||
"mastercard",
|
||||
"amex",
|
||||
"american express",
|
||||
"credit card",
|
||||
"kreditkarte",
|
||||
"kartenabrechnung",
|
||||
"card repayment",
|
||||
)
|
||||
if any(marker in text for marker in liability_markers):
|
||||
return FinancialEventType.LIABILITY_PAYMENT
|
||||
return payload.event_type
|
||||
|
||||
|
||||
def create_observation_with_candidate(
|
||||
session: Session,
|
||||
payload: ManualObservationCreate | ObservationImportCreate,
|
||||
) -> tuple[models.RawObservation, models.FinancialEvent | None]:
|
||||
raw_payload = payload.raw_payload_json or payload.model_dump(
|
||||
mode="json", exclude={"split_lines"}
|
||||
)
|
||||
event_type = infer_event_type(payload)
|
||||
event_status = infer_event_status(payload)
|
||||
raw_hash = compute_raw_observation_hash(
|
||||
source_type=str(payload.source_type),
|
||||
source_reference=payload.source_reference
|
||||
or payload.provider_transaction_id
|
||||
or payload.message_id,
|
||||
raw_text=payload.raw_text,
|
||||
raw_payload=raw_payload,
|
||||
)
|
||||
|
||||
duplicate = session.scalars(
|
||||
select(models.RawObservation)
|
||||
.where(models.RawObservation.household_id == payload.household_id)
|
||||
.where(models.RawObservation.source_type == payload.source_type)
|
||||
.where(models.RawObservation.raw_hash == raw_hash)
|
||||
.limit(1)
|
||||
).first()
|
||||
|
||||
observation = models.RawObservation(
|
||||
household_id=payload.household_id,
|
||||
source_type=payload.source_type,
|
||||
source_account_id=payload.source_account_id,
|
||||
owner_user_id=payload.owner_user_id,
|
||||
observed_at=payload.observed_at,
|
||||
raw_hash=raw_hash,
|
||||
source_reference=payload.source_reference,
|
||||
provider_transaction_id=payload.provider_transaction_id,
|
||||
message_id=payload.message_id,
|
||||
raw_text=payload.raw_text,
|
||||
raw_payload_json=raw_payload,
|
||||
parsed_json={
|
||||
"amount": str(payload.amount),
|
||||
"currency": payload.currency,
|
||||
"event_date": payload.event_date.isoformat(),
|
||||
"booking_date": payload.booking_date.isoformat() if payload.booking_date else None,
|
||||
"due_date": payload.due_date.isoformat() if payload.due_date else None,
|
||||
"merchant": payload.merchant,
|
||||
"counterparty": payload.counterparty,
|
||||
"description": payload.description,
|
||||
"event_type": str(event_type),
|
||||
"status": str(event_status),
|
||||
},
|
||||
parser_name="manual-or-normalized-import",
|
||||
parser_version="0.1.0",
|
||||
confidence=Decimal("1.00"),
|
||||
status=ObservationStatus.DUPLICATE_SOURCE
|
||||
if duplicate
|
||||
else ObservationStatus.CANDIDATE_CREATED,
|
||||
)
|
||||
session.add(observation)
|
||||
session.flush()
|
||||
|
||||
if duplicate:
|
||||
return observation, None
|
||||
|
||||
fingerprint = compute_normalized_transaction_fingerprint(
|
||||
amount=payload.amount,
|
||||
currency=payload.currency,
|
||||
event_date=payload.event_date,
|
||||
merchant=payload.merchant,
|
||||
counterparty=payload.counterparty,
|
||||
external_id=payload.provider_transaction_id,
|
||||
)
|
||||
event = models.FinancialEvent(
|
||||
household_id=payload.household_id,
|
||||
source_account_id=payload.source_account_id,
|
||||
payment_account_id=payload.payment_account_id or payload.source_account_id,
|
||||
payer_user_id=payload.payer_user_id or payload.owner_user_id,
|
||||
event_type=event_type,
|
||||
status=event_status,
|
||||
event_date=payload.event_date,
|
||||
booking_date=payload.booking_date,
|
||||
due_date=payload.due_date,
|
||||
amount=payload.amount,
|
||||
currency=payload.currency,
|
||||
merchant=payload.merchant,
|
||||
counterparty=payload.counterparty,
|
||||
description=payload.description,
|
||||
category_id=payload.category_id,
|
||||
project_id=payload.project_id,
|
||||
created_by_user_id=payload.owner_user_id,
|
||||
fingerprint=fingerprint,
|
||||
)
|
||||
session.add(event)
|
||||
session.flush()
|
||||
|
||||
session.add(
|
||||
models.EventObservationLink(
|
||||
event_id=event.id,
|
||||
observation_id=observation.id,
|
||||
relation=EventObservationRelation.IMPORTED_FROM,
|
||||
confidence=Decimal("1.00"),
|
||||
)
|
||||
)
|
||||
for split in payload.split_lines:
|
||||
session.add(
|
||||
models.SplitLine(
|
||||
event_id=event.id,
|
||||
user_id=split.user_id,
|
||||
share_amount=split.share_amount,
|
||||
share_percent=split.share_percent,
|
||||
reason=split.reason,
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
return observation, event
|
||||
|
||||
|
||||
def confirm_event(session: Session, event: models.FinancialEvent) -> models.FinancialEvent:
|
||||
event.status = FinancialEventStatus.CONFIRMED
|
||||
session.add(event)
|
||||
session.flush()
|
||||
return event
|
||||
136
apps/api/mousehold_api/services/projects.py
Normal file
136
apps/api/mousehold_api/services/projects.py
Normal file
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from mousehold_domain import (
|
||||
FinancialEventStatus,
|
||||
PaymentLineStatus,
|
||||
PaymentStatus,
|
||||
PlanningStatus,
|
||||
ReconciliationStatus,
|
||||
SettlementStatus,
|
||||
)
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import models
|
||||
from ..schemas import ProjectSummary
|
||||
|
||||
|
||||
def _money(value: Decimal | int | str = "0.00") -> Decimal:
|
||||
return Decimal(value).quantize(Decimal("0.01"))
|
||||
|
||||
|
||||
def calculate_project_summary(session: Session, project_id: str) -> ProjectSummary:
|
||||
project = session.get(models.Project, project_id)
|
||||
if not project:
|
||||
raise ValueError("Project not found")
|
||||
|
||||
budget_lines = list(
|
||||
session.scalars(
|
||||
select(models.ProjectBudgetLine).where(
|
||||
models.ProjectBudgetLine.project_id == project_id
|
||||
)
|
||||
).all()
|
||||
)
|
||||
planned_items = list(
|
||||
session.scalars(
|
||||
select(models.ProjectPlannedItem).where(
|
||||
models.ProjectPlannedItem.project_id == project_id
|
||||
)
|
||||
).all()
|
||||
)
|
||||
payment_lines = list(
|
||||
session.scalars(
|
||||
select(models.PaymentLine)
|
||||
.join(
|
||||
models.ProjectPlannedItem,
|
||||
models.PaymentLine.planned_item_id == models.ProjectPlannedItem.id,
|
||||
)
|
||||
.where(models.ProjectPlannedItem.project_id == project_id)
|
||||
).all()
|
||||
)
|
||||
linked_events = list(
|
||||
session.scalars(
|
||||
select(models.FinancialEvent).where(models.FinancialEvent.project_id == project_id)
|
||||
).all()
|
||||
)
|
||||
|
||||
budgeted_total = _money(sum((line.budgeted_amount for line in budget_lines), Decimal("0.00")))
|
||||
committed_total = _money(
|
||||
sum(
|
||||
(
|
||||
item.committed_amount
|
||||
if item.committed_amount is not None
|
||||
else item.estimated_amount
|
||||
if item.planning_status == PlanningStatus.COMMITTED
|
||||
else Decimal("0.00")
|
||||
)
|
||||
for item in planned_items
|
||||
)
|
||||
)
|
||||
paid_total = _money(
|
||||
sum(
|
||||
(
|
||||
line.amount
|
||||
for line in payment_lines
|
||||
if line.status in {PaymentLineStatus.PAID, PaymentLineStatus.RECONCILED}
|
||||
),
|
||||
Decimal("0.00"),
|
||||
)
|
||||
+ sum(
|
||||
(
|
||||
event.amount
|
||||
for event in linked_events
|
||||
if event.status
|
||||
in {
|
||||
FinancialEventStatus.CONFIRMED,
|
||||
FinancialEventStatus.BOOKED,
|
||||
FinancialEventStatus.SETTLED,
|
||||
}
|
||||
),
|
||||
Decimal("0.00"),
|
||||
)
|
||||
)
|
||||
reconciled_total = _money(
|
||||
sum(
|
||||
(line.amount for line in payment_lines if line.status == PaymentLineStatus.RECONCILED),
|
||||
Decimal("0.00"),
|
||||
)
|
||||
)
|
||||
settled_total = _money(
|
||||
sum(
|
||||
(
|
||||
item.committed_amount or item.estimated_amount
|
||||
for item in planned_items
|
||||
if item.settlement_status == SettlementStatus.SETTLED
|
||||
),
|
||||
Decimal("0.00"),
|
||||
)
|
||||
)
|
||||
due_later_total = _money(max(Decimal("0.00"), committed_total - paid_total))
|
||||
remaining_uncommitted_budget = _money(budgeted_total - committed_total)
|
||||
|
||||
open_actions: list[str] = []
|
||||
for item in planned_items:
|
||||
expected = item.committed_amount or item.estimated_amount
|
||||
if item.planning_status == PlanningStatus.COMMITTED and item.payment_status in {
|
||||
PaymentStatus.UNPAID,
|
||||
PaymentStatus.PARTIALLY_PAID,
|
||||
}:
|
||||
open_actions.append(f"{item.description} still has payment due.")
|
||||
if item.reconciliation_status == ReconciliationStatus.NO_EVIDENCE and expected > 0:
|
||||
open_actions.append(f"{item.description} needs evidence.")
|
||||
|
||||
return ProjectSummary(
|
||||
project_id=project.id,
|
||||
currency=project.currency,
|
||||
budgeted_total=budgeted_total,
|
||||
committed_total=committed_total,
|
||||
paid_total=paid_total,
|
||||
reconciled_total=reconciled_total,
|
||||
settled_total=settled_total,
|
||||
due_later_total=due_later_total,
|
||||
remaining_uncommitted_budget=remaining_uncommitted_budget,
|
||||
open_actions=open_actions,
|
||||
)
|
||||
532
apps/api/mousehold_api/services/reconciliation.py
Normal file
532
apps/api/mousehold_api/services/reconciliation.py
Normal file
@@ -0,0 +1,532 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from mousehold_domain import (
|
||||
EventObservationRelation,
|
||||
FinancialEventStatus,
|
||||
RelationType,
|
||||
SourceType,
|
||||
)
|
||||
from mousehold_matching import score_event_match
|
||||
from mousehold_matching.fingerprints import normalize_counterparty
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from .. import models
|
||||
|
||||
ANNOUNCEMENT_SOURCES = {
|
||||
SourceType.PAYPAL_EMAIL,
|
||||
SourceType.KLARNA_EMAIL,
|
||||
SourceType.ORDER_CONFIRMATION_EMAIL,
|
||||
SourceType.IMAP_EMAIL,
|
||||
}
|
||||
CONFIRMATION_SOURCES = {
|
||||
SourceType.PAYPAL_API,
|
||||
SourceType.PAYPAL_CSV,
|
||||
SourceType.KLARNA_CSV,
|
||||
SourceType.BANK_CSV,
|
||||
SourceType.FINTS,
|
||||
SourceType.PSD2_AGGREGATOR,
|
||||
}
|
||||
BANK_CONFIRMATION_SOURCES = {SourceType.BANK_CSV, SourceType.FINTS, SourceType.PSD2_AGGREGATOR}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EventSummary:
|
||||
id: str
|
||||
source_type: str | None
|
||||
event_type: str
|
||||
status: str
|
||||
event_date: date
|
||||
amount: Decimal
|
||||
currency: str
|
||||
merchant: str | None
|
||||
counterparty: str | None
|
||||
description: str | None
|
||||
source_reference: str | None
|
||||
provider_transaction_id: str | None
|
||||
observation_id: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReconciliationSuggestion:
|
||||
id: str
|
||||
kind: str
|
||||
relation_type: RelationType
|
||||
primary_event: EventSummary
|
||||
confirming_event: EventSummary
|
||||
confidence: Decimal
|
||||
reasons: list[str]
|
||||
action: str
|
||||
|
||||
|
||||
def list_reconciliation_suggestions(
|
||||
session: Session,
|
||||
*,
|
||||
household_id: str,
|
||||
limit: int = 50,
|
||||
include_ignored: bool = False,
|
||||
) -> list[ReconciliationSuggestion]:
|
||||
events = _events_with_observations(session, household_id=household_id)
|
||||
suggestions: list[ReconciliationSuggestion] = []
|
||||
existing_pairs = _existing_relation_pairs(session, household_id=household_id)
|
||||
ignored_suggestion_ids = (
|
||||
set() if include_ignored else _decision_suggestion_ids(session, household_id=household_id, decision="ignored")
|
||||
)
|
||||
|
||||
for primary in events:
|
||||
primary_source = _first_source_type(primary)
|
||||
if primary_source not in ANNOUNCEMENT_SOURCES:
|
||||
continue
|
||||
if primary.status in {
|
||||
FinancialEventStatus.CONFIRMED,
|
||||
FinancialEventStatus.CANCELLED,
|
||||
FinancialEventStatus.IGNORED,
|
||||
}:
|
||||
continue
|
||||
for confirming in events:
|
||||
if primary.id == confirming.id or (primary.id, confirming.id) in existing_pairs:
|
||||
continue
|
||||
confirming_source = _first_source_type(confirming)
|
||||
if confirming_source not in CONFIRMATION_SOURCES:
|
||||
continue
|
||||
suggestion = _suggest_event_pair(primary, confirming)
|
||||
if suggestion:
|
||||
suggestions.append(suggestion)
|
||||
|
||||
for source in events:
|
||||
source_type = _first_source_type(source)
|
||||
if source_type not in {SourceType.PAYPAL_CSV, SourceType.PAYPAL_API, SourceType.KLARNA_CSV}:
|
||||
continue
|
||||
for bank_event in events:
|
||||
if source.id == bank_event.id or (source.id, bank_event.id) in existing_pairs:
|
||||
continue
|
||||
if _first_source_type(bank_event) not in BANK_CONFIRMATION_SOURCES:
|
||||
continue
|
||||
suggestion = _suggest_event_pair(source, bank_event, kind="statement_bank")
|
||||
if suggestion:
|
||||
suggestions.append(suggestion)
|
||||
|
||||
unique: dict[str, ReconciliationSuggestion] = {}
|
||||
for suggestion in sorted(suggestions, key=lambda item: item.confidence, reverse=True):
|
||||
if suggestion.id not in ignored_suggestion_ids:
|
||||
unique.setdefault(suggestion.id, suggestion)
|
||||
return list(unique.values())[:limit]
|
||||
|
||||
|
||||
def accept_reconciliation_suggestion(
|
||||
session: Session,
|
||||
*,
|
||||
household_id: str,
|
||||
suggestion_id: str,
|
||||
) -> ReconciliationSuggestion:
|
||||
suggestions = list_reconciliation_suggestions(session, household_id=household_id, limit=500)
|
||||
suggestion = next((item for item in suggestions if item.id == suggestion_id), None)
|
||||
if suggestion is None:
|
||||
raise LookupError("Reconciliation suggestion not found")
|
||||
|
||||
primary = session.get(models.FinancialEvent, suggestion.primary_event.id)
|
||||
confirming = session.get(models.FinancialEvent, suggestion.confirming_event.id)
|
||||
if not primary or not confirming:
|
||||
raise LookupError("Reconciliation event not found")
|
||||
if primary.household_id != household_id or confirming.household_id != household_id:
|
||||
raise LookupError("Reconciliation event not found")
|
||||
|
||||
_ensure_event_relation(
|
||||
session,
|
||||
from_event_id=primary.id,
|
||||
to_event_id=confirming.id,
|
||||
relation_type=suggestion.relation_type,
|
||||
confidence=suggestion.confidence,
|
||||
)
|
||||
if suggestion.confirming_event.observation_id:
|
||||
_ensure_observation_link(
|
||||
session,
|
||||
event_id=primary.id,
|
||||
observation_id=suggestion.confirming_event.observation_id,
|
||||
relation=EventObservationRelation.EVIDENCE_FOR,
|
||||
confidence=suggestion.confidence,
|
||||
)
|
||||
if suggestion.primary_event.observation_id:
|
||||
_ensure_observation_link(
|
||||
session,
|
||||
event_id=confirming.id,
|
||||
observation_id=suggestion.primary_event.observation_id,
|
||||
relation=EventObservationRelation.EVIDENCE_FOR,
|
||||
confidence=suggestion.confidence,
|
||||
)
|
||||
|
||||
if confirming.status == FinancialEventStatus.CONFIRMED:
|
||||
primary.status = FinancialEventStatus.CONFIRMED
|
||||
if not primary.payment_account_id and confirming.payment_account_id:
|
||||
primary.payment_account_id = confirming.payment_account_id
|
||||
if not primary.source_account_id and confirming.source_account_id:
|
||||
primary.source_account_id = confirming.source_account_id
|
||||
if not primary.payer_user_id and confirming.payer_user_id:
|
||||
primary.payer_user_id = confirming.payer_user_id
|
||||
session.add(primary)
|
||||
_record_reconciliation_decision(
|
||||
session,
|
||||
household_id=household_id,
|
||||
suggestion=suggestion,
|
||||
decision="accepted",
|
||||
)
|
||||
return suggestion
|
||||
|
||||
|
||||
def ignore_reconciliation_suggestion(
|
||||
session: Session,
|
||||
*,
|
||||
household_id: str,
|
||||
suggestion_id: str,
|
||||
actor_user_id: str | None = None,
|
||||
reason: str | None = None,
|
||||
) -> tuple[ReconciliationSuggestion, models.ReconciliationDecision]:
|
||||
suggestions = list_reconciliation_suggestions(
|
||||
session,
|
||||
household_id=household_id,
|
||||
limit=500,
|
||||
include_ignored=True,
|
||||
)
|
||||
suggestion = next((item for item in suggestions if item.id == suggestion_id), None)
|
||||
if suggestion is None:
|
||||
raise LookupError("Reconciliation suggestion not found")
|
||||
decision = _record_reconciliation_decision(
|
||||
session,
|
||||
household_id=household_id,
|
||||
suggestion=suggestion,
|
||||
decision="ignored",
|
||||
actor_user_id=actor_user_id,
|
||||
reason=reason,
|
||||
)
|
||||
return suggestion, decision
|
||||
|
||||
|
||||
def list_reconciliation_decisions(
|
||||
session: Session,
|
||||
*,
|
||||
household_id: str,
|
||||
) -> list[models.ReconciliationDecision]:
|
||||
return list(
|
||||
session.scalars(
|
||||
select(models.ReconciliationDecision)
|
||||
.where(models.ReconciliationDecision.household_id == household_id)
|
||||
.order_by(models.ReconciliationDecision.updated_at.desc())
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def _suggest_event_pair(
|
||||
primary: models.FinancialEvent,
|
||||
confirming: models.FinancialEvent,
|
||||
*,
|
||||
kind: str = "evidence_confirmation",
|
||||
) -> ReconciliationSuggestion | None:
|
||||
if primary.currency.upper() != confirming.currency.upper():
|
||||
return None
|
||||
if abs(primary.amount - confirming.amount) > Decimal("0.01"):
|
||||
return None
|
||||
days = abs((primary.event_date - confirming.event_date).days)
|
||||
if days > _max_date_window(primary, confirming, kind):
|
||||
return None
|
||||
|
||||
reasons: list[str] = ["amount_exact"]
|
||||
if days == 0:
|
||||
reasons.append("same_date")
|
||||
else:
|
||||
reasons.append(f"date_within_{days}_days")
|
||||
|
||||
reference_score = _reference_score(primary, confirming)
|
||||
if reference_score:
|
||||
reasons.append("shared_reference")
|
||||
|
||||
party_a = _party_text(primary)
|
||||
party_b = _party_text(confirming)
|
||||
score = Decimal(
|
||||
str(
|
||||
score_event_match(
|
||||
amount_a=primary.amount,
|
||||
amount_b=confirming.amount,
|
||||
currency_a=primary.currency,
|
||||
currency_b=confirming.currency,
|
||||
date_a=primary.event_date,
|
||||
date_b=confirming.event_date,
|
||||
party_a=party_a,
|
||||
party_b=party_b,
|
||||
)
|
||||
)
|
||||
)
|
||||
party_overlap = _party_overlap_score(party_a, party_b)
|
||||
if party_overlap >= Decimal("0.60"):
|
||||
reasons.append("party_overlap")
|
||||
if _provider_bridge(primary, confirming):
|
||||
reasons.append("provider_bridge")
|
||||
score += Decimal("0.12")
|
||||
if kind == "statement_bank" and str(confirming.event_type) == "liability_payment":
|
||||
reasons.append("liability_payment")
|
||||
score += Decimal("0.10")
|
||||
score += reference_score
|
||||
if kind == "statement_bank":
|
||||
score += Decimal("0.05")
|
||||
|
||||
confidence = min(score, Decimal("0.99")).quantize(Decimal("0.01"))
|
||||
if confidence < Decimal("0.72"):
|
||||
return None
|
||||
|
||||
relation_type = (
|
||||
RelationType.SAME_ECONOMIC_EVENT_AS
|
||||
if kind == "evidence_confirmation"
|
||||
else RelationType.SETTLES
|
||||
)
|
||||
primary_summary = _event_summary(primary)
|
||||
confirming_summary = _event_summary(confirming)
|
||||
return ReconciliationSuggestion(
|
||||
id=_suggestion_id(kind, primary.id, confirming.id),
|
||||
kind=kind,
|
||||
relation_type=relation_type,
|
||||
primary_event=primary_summary,
|
||||
confirming_event=confirming_summary,
|
||||
confidence=confidence,
|
||||
reasons=reasons,
|
||||
action=(
|
||||
"Attach confirming evidence and mark primary event confirmed"
|
||||
if kind == "evidence_confirmation"
|
||||
else "Link statement event to cash movement"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _events_with_observations(session: Session, *, household_id: str) -> list[models.FinancialEvent]:
|
||||
return list(
|
||||
session.scalars(
|
||||
select(models.FinancialEvent)
|
||||
.where(models.FinancialEvent.household_id == household_id)
|
||||
.options(
|
||||
selectinload(models.FinancialEvent.observation_links).selectinload(
|
||||
models.EventObservationLink.observation
|
||||
)
|
||||
)
|
||||
.order_by(models.FinancialEvent.event_date.desc(), models.FinancialEvent.created_at.desc())
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def _existing_relation_pairs(session: Session, *, household_id: str) -> set[tuple[str, str]]:
|
||||
event_ids = select(models.FinancialEvent.id).where(models.FinancialEvent.household_id == household_id)
|
||||
rows = session.execute(
|
||||
select(models.EventRelation.from_event_id, models.EventRelation.to_event_id)
|
||||
.where(models.EventRelation.from_event_id.in_(event_ids))
|
||||
).all()
|
||||
return {(row[0], row[1]) for row in rows} | {(row[1], row[0]) for row in rows}
|
||||
|
||||
|
||||
def _decision_suggestion_ids(session: Session, *, household_id: str, decision: str) -> set[str]:
|
||||
return set(
|
||||
session.scalars(
|
||||
select(models.ReconciliationDecision.suggestion_id)
|
||||
.where(models.ReconciliationDecision.household_id == household_id)
|
||||
.where(models.ReconciliationDecision.decision == decision)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def _record_reconciliation_decision(
|
||||
session: Session,
|
||||
*,
|
||||
household_id: str,
|
||||
suggestion: ReconciliationSuggestion,
|
||||
decision: str,
|
||||
actor_user_id: str | None = None,
|
||||
reason: str | None = None,
|
||||
) -> models.ReconciliationDecision:
|
||||
existing = session.scalars(
|
||||
select(models.ReconciliationDecision)
|
||||
.where(models.ReconciliationDecision.household_id == household_id)
|
||||
.where(models.ReconciliationDecision.suggestion_id == suggestion.id)
|
||||
.limit(1)
|
||||
).first()
|
||||
if existing:
|
||||
existing.decision = decision
|
||||
existing.kind = suggestion.kind
|
||||
existing.relation_type = suggestion.relation_type
|
||||
existing.primary_event_id = suggestion.primary_event.id
|
||||
existing.confirming_event_id = suggestion.confirming_event.id
|
||||
existing.confidence = suggestion.confidence
|
||||
existing.actor_user_id = actor_user_id
|
||||
existing.reason = reason
|
||||
session.add(existing)
|
||||
return existing
|
||||
row = models.ReconciliationDecision(
|
||||
household_id=household_id,
|
||||
suggestion_id=suggestion.id,
|
||||
decision=decision,
|
||||
kind=suggestion.kind,
|
||||
relation_type=suggestion.relation_type,
|
||||
primary_event_id=suggestion.primary_event.id,
|
||||
confirming_event_id=suggestion.confirming_event.id,
|
||||
confidence=suggestion.confidence,
|
||||
actor_user_id=actor_user_id,
|
||||
reason=reason,
|
||||
)
|
||||
session.add(row)
|
||||
session.flush()
|
||||
return row
|
||||
|
||||
|
||||
def _first_observation(event: models.FinancialEvent) -> models.RawObservation | None:
|
||||
for link in event.observation_links:
|
||||
if link.observation:
|
||||
return link.observation
|
||||
return None
|
||||
|
||||
|
||||
def _first_source_type(event: models.FinancialEvent) -> SourceType | None:
|
||||
observation = _first_observation(event)
|
||||
return observation.source_type if observation else None
|
||||
|
||||
|
||||
def _event_summary(event: models.FinancialEvent) -> EventSummary:
|
||||
observation = _first_observation(event)
|
||||
return EventSummary(
|
||||
id=event.id,
|
||||
source_type=str(observation.source_type) if observation else None,
|
||||
event_type=str(event.event_type),
|
||||
status=str(event.status),
|
||||
event_date=event.event_date,
|
||||
amount=event.amount,
|
||||
currency=event.currency,
|
||||
merchant=event.merchant,
|
||||
counterparty=event.counterparty,
|
||||
description=event.description,
|
||||
source_reference=observation.source_reference if observation else None,
|
||||
provider_transaction_id=observation.provider_transaction_id if observation else None,
|
||||
observation_id=observation.id if observation else None,
|
||||
)
|
||||
|
||||
|
||||
def _suggestion_id(kind: str, primary_id: str, confirming_id: str) -> str:
|
||||
return f"{kind}:{primary_id}:{confirming_id}"
|
||||
|
||||
|
||||
def _party_text(event: models.FinancialEvent) -> str:
|
||||
parts = [event.merchant, event.counterparty, event.description]
|
||||
observation = _first_observation(event)
|
||||
if observation and observation.raw_payload_json:
|
||||
refs = observation.raw_payload_json.get("references")
|
||||
if isinstance(refs, dict):
|
||||
parts.extend(str(value) for value in refs.values() if value)
|
||||
return " ".join(part for part in parts if part)
|
||||
|
||||
|
||||
def _party_overlap_score(a: str, b: str) -> Decimal:
|
||||
left = set(normalize_counterparty(a).split())
|
||||
right = set(normalize_counterparty(b).split())
|
||||
if not left or not right:
|
||||
return Decimal("0")
|
||||
return Decimal(len(left & right)) / Decimal(max(len(left), len(right)))
|
||||
|
||||
|
||||
def _reference_score(a: models.FinancialEvent, b: models.FinancialEvent) -> Decimal:
|
||||
left = _references(a)
|
||||
right = _references(b)
|
||||
if not left or not right:
|
||||
return Decimal("0")
|
||||
return Decimal("0.22") if left & right else Decimal("0")
|
||||
|
||||
|
||||
def _references(event: models.FinancialEvent) -> set[str]:
|
||||
refs: set[str] = set()
|
||||
observation = _first_observation(event)
|
||||
if not observation:
|
||||
return refs
|
||||
for value in (observation.source_reference, observation.provider_transaction_id, observation.message_id):
|
||||
if value:
|
||||
refs.add(normalize_counterparty(value))
|
||||
payload = observation.raw_payload_json or {}
|
||||
raw_refs = payload.get("references")
|
||||
if isinstance(raw_refs, dict):
|
||||
refs.update(normalize_counterparty(str(value)) for value in raw_refs.values() if value)
|
||||
return {value for value in refs if value}
|
||||
|
||||
|
||||
def _provider_bridge(primary: models.FinancialEvent, confirming: models.FinancialEvent) -> bool:
|
||||
party = normalize_counterparty(_party_text(confirming))
|
||||
primary_source = _first_source_type(primary)
|
||||
confirming_source = _first_source_type(confirming)
|
||||
if primary_source in {SourceType.PAYPAL_EMAIL, SourceType.PAYPAL_CSV, SourceType.PAYPAL_API}:
|
||||
return "paypal" in party or confirming_source in {SourceType.PAYPAL_CSV, SourceType.PAYPAL_API}
|
||||
if primary_source in {SourceType.KLARNA_EMAIL, SourceType.KLARNA_CSV}:
|
||||
return "klarna" in party or confirming_source == SourceType.KLARNA_CSV
|
||||
return False
|
||||
|
||||
|
||||
def _max_date_window(primary: models.FinancialEvent, confirming: models.FinancialEvent, kind: str) -> int:
|
||||
primary_source = _first_source_type(primary)
|
||||
confirming_source = _first_source_type(confirming)
|
||||
if kind == "statement_bank":
|
||||
return 45
|
||||
if primary_source in {SourceType.KLARNA_EMAIL, SourceType.PAYPAL_EMAIL} and confirming_source in {
|
||||
SourceType.KLARNA_CSV,
|
||||
SourceType.PAYPAL_CSV,
|
||||
SourceType.PAYPAL_API,
|
||||
}:
|
||||
return 45
|
||||
return 21
|
||||
|
||||
|
||||
def _ensure_event_relation(
|
||||
session: Session,
|
||||
*,
|
||||
from_event_id: str,
|
||||
to_event_id: str,
|
||||
relation_type: RelationType,
|
||||
confidence: Decimal,
|
||||
) -> None:
|
||||
existing = session.scalars(
|
||||
select(models.EventRelation)
|
||||
.where(models.EventRelation.from_event_id == from_event_id)
|
||||
.where(models.EventRelation.to_event_id == to_event_id)
|
||||
.where(models.EventRelation.relation_type == relation_type)
|
||||
.limit(1)
|
||||
).first()
|
||||
if existing:
|
||||
return
|
||||
session.add(
|
||||
models.EventRelation(
|
||||
from_event_id=from_event_id,
|
||||
to_event_id=to_event_id,
|
||||
relation_type=relation_type,
|
||||
confidence=confidence,
|
||||
created_by="reconciliation",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _ensure_observation_link(
|
||||
session: Session,
|
||||
*,
|
||||
event_id: str,
|
||||
observation_id: str,
|
||||
relation: EventObservationRelation,
|
||||
confidence: Decimal,
|
||||
) -> None:
|
||||
existing = session.scalars(
|
||||
select(models.EventObservationLink)
|
||||
.where(models.EventObservationLink.event_id == event_id)
|
||||
.where(models.EventObservationLink.observation_id == observation_id)
|
||||
.where(models.EventObservationLink.relation == relation)
|
||||
.limit(1)
|
||||
).first()
|
||||
if existing:
|
||||
return
|
||||
session.add(
|
||||
models.EventObservationLink(
|
||||
event_id=event_id,
|
||||
observation_id=observation_id,
|
||||
relation=relation,
|
||||
confidence=confidence,
|
||||
)
|
||||
)
|
||||
166
apps/api/mousehold_api/services/settlement.py
Normal file
166
apps/api/mousehold_api/services/settlement.py
Normal file
@@ -0,0 +1,166 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from mousehold_domain import AccountType, FinancialEventStatus, FinancialEventType
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from .. import models
|
||||
from ..schemas import SettlementSummary, SettlementTransfer, SettlementUserBalance
|
||||
|
||||
SETTLEMENT_STATUSES = {
|
||||
FinancialEventStatus.CONFIRMED,
|
||||
FinancialEventStatus.BOOKED,
|
||||
FinancialEventStatus.SETTLED,
|
||||
}
|
||||
SETTLEMENT_EVENT_TYPES = {
|
||||
FinancialEventType.EXPENSE,
|
||||
FinancialEventType.FEE,
|
||||
FinancialEventType.INTEREST,
|
||||
FinancialEventType.REFUND,
|
||||
FinancialEventType.REIMBURSEMENT,
|
||||
}
|
||||
|
||||
|
||||
def _money(value: Decimal | int | str = "0.00") -> Decimal:
|
||||
return Decimal(value).quantize(Decimal("0.01"))
|
||||
|
||||
|
||||
def calculate_settlement(
|
||||
session: Session,
|
||||
*,
|
||||
household_id: str,
|
||||
project_id: str | None = None,
|
||||
currency: str = "EUR",
|
||||
include_announced: bool = False,
|
||||
) -> SettlementSummary:
|
||||
accepted_statuses = set(SETTLEMENT_STATUSES)
|
||||
if include_announced:
|
||||
accepted_statuses.update({FinancialEventStatus.ANNOUNCED, FinancialEventStatus.OUTSTANDING})
|
||||
|
||||
query = (
|
||||
select(models.FinancialEvent)
|
||||
.where(models.FinancialEvent.household_id == household_id)
|
||||
.options(selectinload(models.FinancialEvent.split_lines))
|
||||
.order_by(models.FinancialEvent.event_date, models.FinancialEvent.created_at)
|
||||
)
|
||||
if project_id:
|
||||
query = query.where(models.FinancialEvent.project_id == project_id)
|
||||
events = list(session.scalars(query).all())
|
||||
|
||||
accounts_by_id = {
|
||||
account.id: account
|
||||
for account in session.scalars(
|
||||
select(models.Account).where(models.Account.household_id == household_id)
|
||||
).all()
|
||||
}
|
||||
paid: dict[str, Decimal] = {}
|
||||
share: dict[str, Decimal] = {}
|
||||
included: list[str] = []
|
||||
excluded: list[str] = []
|
||||
explanation: list[str] = []
|
||||
|
||||
for event in events:
|
||||
if (
|
||||
event.currency != currency
|
||||
or event.status not in accepted_statuses
|
||||
or event.event_type not in SETTLEMENT_EVENT_TYPES
|
||||
):
|
||||
excluded.append(event.id)
|
||||
continue
|
||||
payment_account = accounts_by_id.get(
|
||||
event.payment_account_id or event.source_account_id or ""
|
||||
)
|
||||
if (
|
||||
payment_account
|
||||
and payment_account.account_type == AccountType.SHARED_CHECKING
|
||||
and not payment_account.owner_user_id
|
||||
):
|
||||
excluded.append(event.id)
|
||||
explanation.append(f"{event.id} excluded because a neutral shared account paid it.")
|
||||
continue
|
||||
if not event.payer_user_id or not event.split_lines:
|
||||
excluded.append(event.id)
|
||||
explanation.append(f"{event.id} excluded because payer or split lines are missing.")
|
||||
continue
|
||||
|
||||
included.append(event.id)
|
||||
amount = _money(event.amount)
|
||||
multiplier = (
|
||||
Decimal("-1.00") if event.event_type == FinancialEventType.REFUND else Decimal("1.00")
|
||||
)
|
||||
paid[event.payer_user_id] = _money(
|
||||
paid.get(event.payer_user_id, Decimal("0.00")) + (amount * multiplier)
|
||||
)
|
||||
for split in event.split_lines:
|
||||
share[split.user_id] = _money(
|
||||
share.get(split.user_id, Decimal("0.00")) + (split.share_amount * multiplier)
|
||||
)
|
||||
|
||||
user_ids = sorted(set(paid) | set(share))
|
||||
balances = [
|
||||
SettlementUserBalance(
|
||||
user_id=user_id,
|
||||
paid=_money(paid.get(user_id, Decimal("0.00"))),
|
||||
share=_money(share.get(user_id, Decimal("0.00"))),
|
||||
net=_money(paid.get(user_id, Decimal("0.00")) - share.get(user_id, Decimal("0.00"))),
|
||||
)
|
||||
for user_id in user_ids
|
||||
]
|
||||
transfers = _minimize_transfers(balances, currency)
|
||||
explanation.append(
|
||||
f"Included {len(included)} confirmed/booked/settled events"
|
||||
+ (" plus announced/outstanding events." if include_announced else ".")
|
||||
)
|
||||
if not include_announced:
|
||||
explanation.append(
|
||||
"Announced and outstanding events are excluded from settlement by default."
|
||||
)
|
||||
|
||||
return SettlementSummary(
|
||||
household_id=household_id,
|
||||
project_id=project_id,
|
||||
currency=currency,
|
||||
included_event_ids=included,
|
||||
excluded_event_ids=excluded,
|
||||
balances=balances,
|
||||
suggested_transfers=transfers,
|
||||
explanation=explanation,
|
||||
)
|
||||
|
||||
|
||||
def _minimize_transfers(
|
||||
balances: list[SettlementUserBalance], currency: str
|
||||
) -> list[SettlementTransfer]:
|
||||
debtors = [
|
||||
{"user_id": balance.user_id, "amount": _money(-balance.net)}
|
||||
for balance in balances
|
||||
if balance.net < 0
|
||||
]
|
||||
creditors = [
|
||||
{"user_id": balance.user_id, "amount": _money(balance.net)}
|
||||
for balance in balances
|
||||
if balance.net > 0
|
||||
]
|
||||
transfers: list[SettlementTransfer] = []
|
||||
debtor_index = 0
|
||||
creditor_index = 0
|
||||
while debtor_index < len(debtors) and creditor_index < len(creditors):
|
||||
amount = min(debtors[debtor_index]["amount"], creditors[creditor_index]["amount"])
|
||||
if amount > 0:
|
||||
transfers.append(
|
||||
SettlementTransfer(
|
||||
from_user_id=debtors[debtor_index]["user_id"],
|
||||
to_user_id=creditors[creditor_index]["user_id"],
|
||||
amount=_money(amount),
|
||||
currency=currency,
|
||||
)
|
||||
)
|
||||
debtors[debtor_index]["amount"] = _money(debtors[debtor_index]["amount"] - amount)
|
||||
creditors[creditor_index]["amount"] = _money(creditors[creditor_index]["amount"] - amount)
|
||||
if debtors[debtor_index]["amount"] == 0:
|
||||
debtor_index += 1
|
||||
if creditors[creditor_index]["amount"] == 0:
|
||||
creditor_index += 1
|
||||
return transfers
|
||||
1
apps/local-agent/mousehold_agent/__init__.py
Normal file
1
apps/local-agent/mousehold_agent/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
__version__ = "0.1.0"
|
||||
250
apps/local-agent/mousehold_agent/cli.py
Normal file
250
apps/local-agent/mousehold_agent/cli.py
Normal file
@@ -0,0 +1,250 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .csv_importer import read_transaction_csv
|
||||
from .fints_connector import (
|
||||
account_to_dict,
|
||||
config_from_args,
|
||||
date_range_from_args,
|
||||
display_accounts,
|
||||
display_transactions,
|
||||
fetch_transactions,
|
||||
list_accounts,
|
||||
)
|
||||
from .http import post_json
|
||||
from .imap_importer import fetch_imap_observations
|
||||
from .server import run as run_local_agent_server
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(prog="mousehold-agent")
|
||||
parser.add_argument("--api-url", default="http://127.0.0.1:8000")
|
||||
subcommands = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
register = subcommands.add_parser("register")
|
||||
register.add_argument("--household-id", required=True)
|
||||
register.add_argument("--owner-user-id", required=True)
|
||||
register.add_argument("--display-name", default="Local connector agent")
|
||||
|
||||
csv_sync = subcommands.add_parser("sync-csv")
|
||||
csv_sync.add_argument("--connection-id", required=True)
|
||||
csv_sync.add_argument("--household-id", required=True)
|
||||
csv_sync.add_argument("--source-type", choices=["bank_csv", "paypal_csv"], required=True)
|
||||
csv_sync.add_argument("--path", type=Path, required=True)
|
||||
csv_sync.add_argument("--source-account-id")
|
||||
csv_sync.add_argument("--owner-user-id")
|
||||
|
||||
imap_sync = subcommands.add_parser("sync-imap")
|
||||
imap_sync.add_argument("--connection-id", required=True)
|
||||
imap_sync.add_argument("--household-id", required=True)
|
||||
imap_sync.add_argument("--owner-user-id")
|
||||
imap_sync.add_argument("--source-account-id")
|
||||
imap_sync.add_argument("--limit", type=int, default=25)
|
||||
|
||||
demo = subcommands.add_parser("sync-demo")
|
||||
demo.add_argument("--connection-id", required=True)
|
||||
demo.add_argument("--household-id", required=True)
|
||||
demo.add_argument("--owner-user-id")
|
||||
demo.add_argument("--source-account-id")
|
||||
|
||||
serve = subcommands.add_parser("serve")
|
||||
serve.add_argument("--host", default="127.0.0.1")
|
||||
serve.add_argument("--port", type=int, default=8765)
|
||||
|
||||
fints_accounts = subcommands.add_parser("fints-accounts")
|
||||
_add_fints_common_args(fints_accounts)
|
||||
fints_accounts.add_argument("--with-balances", action="store_true")
|
||||
fints_accounts.add_argument("--json", action="store_true")
|
||||
|
||||
fints_transactions = subcommands.add_parser("fints-transactions")
|
||||
_add_fints_common_args(fints_transactions)
|
||||
_add_fints_transaction_args(fints_transactions)
|
||||
fints_transactions.add_argument("--json", action="store_true")
|
||||
|
||||
fints_sync = subcommands.add_parser("sync-fints")
|
||||
_add_fints_common_args(fints_sync)
|
||||
_add_fints_transaction_args(fints_sync)
|
||||
fints_sync.add_argument("--connection-id", required=True)
|
||||
fints_sync.add_argument("--household-id", required=True)
|
||||
fints_sync.add_argument("--owner-user-id")
|
||||
fints_sync.add_argument("--source-account-id")
|
||||
fints_sync.add_argument("--display", action="store_true")
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.command == "register":
|
||||
result = post_json(
|
||||
f"{args.api_url}/local-agent/register",
|
||||
{
|
||||
"household_id": args.household_id,
|
||||
"owner_user_id": args.owner_user_id,
|
||||
"display_name": args.display_name,
|
||||
},
|
||||
)
|
||||
elif args.command == "sync-csv":
|
||||
observations = read_transaction_csv(
|
||||
args.path,
|
||||
household_id=args.household_id,
|
||||
source_type=args.source_type,
|
||||
source_account_id=args.source_account_id,
|
||||
owner_user_id=args.owner_user_id,
|
||||
)
|
||||
result = _sync(args.api_url, args.connection_id, observations)
|
||||
elif args.command == "sync-imap":
|
||||
observations = fetch_imap_observations(
|
||||
household_id=args.household_id,
|
||||
owner_user_id=args.owner_user_id,
|
||||
source_account_id=args.source_account_id,
|
||||
limit=args.limit,
|
||||
)
|
||||
result = _sync(args.api_url, args.connection_id, observations)
|
||||
elif args.command == "sync-demo":
|
||||
result = _sync(
|
||||
args.api_url,
|
||||
args.connection_id,
|
||||
_demo_observations(args.household_id, args.owner_user_id, args.source_account_id),
|
||||
)
|
||||
elif args.command == "serve":
|
||||
run_local_agent_server(host=args.host, port=args.port)
|
||||
return
|
||||
elif args.command == "fints-accounts":
|
||||
accounts = list_accounts(config_from_args(args), include_balances=args.with_balances)
|
||||
if args.json:
|
||||
result = accounts
|
||||
else:
|
||||
display_accounts(accounts)
|
||||
return
|
||||
elif args.command == "fints-transactions":
|
||||
start_date, end_date = date_range_from_args(args)
|
||||
fetched = fetch_transactions(
|
||||
config_from_args(args),
|
||||
iban=args.iban,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
include_pending=args.include_pending,
|
||||
)
|
||||
if args.json:
|
||||
result = {
|
||||
"account": account_to_dict(fetched.account),
|
||||
"observations": fetched.observations,
|
||||
}
|
||||
else:
|
||||
display_transactions(fetched.observations)
|
||||
return
|
||||
elif args.command == "sync-fints":
|
||||
start_date, end_date = date_range_from_args(args)
|
||||
fetched = fetch_transactions(
|
||||
config_from_args(args),
|
||||
iban=args.iban,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
include_pending=args.include_pending,
|
||||
household_id=args.household_id,
|
||||
source_account_id=args.source_account_id,
|
||||
owner_user_id=args.owner_user_id,
|
||||
)
|
||||
if args.display:
|
||||
display_transactions(fetched.observations)
|
||||
result = _sync(args.api_url, args.connection_id, fetched.observations)
|
||||
else:
|
||||
raise AssertionError(args.command)
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
def _sync(api_url: str, connection_id: str, observations: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
return post_json(
|
||||
f"{api_url}/observations/sync",
|
||||
{
|
||||
"connection_id": connection_id,
|
||||
"observations": observations,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _add_fints_common_args(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--profile", default="default")
|
||||
parser.add_argument("--state-dir", default="~/.config/mousehold/fints")
|
||||
parser.add_argument("--no-state", action="store_true")
|
||||
parser.add_argument("--no-bootstrap", action="store_true")
|
||||
parser.add_argument("--blz", help="Bank identifier/BLZ. Env fallback: MOUSEHOLD_FINTS_BLZ")
|
||||
parser.add_argument("--server", help="FinTS endpoint URL. Env fallback: MOUSEHOLD_FINTS_SERVER")
|
||||
parser.add_argument("--bank-user-id", help="FinTS login/user ID. Env fallback: MOUSEHOLD_FINTS_USER_ID")
|
||||
parser.add_argument("--customer-id", help="Optional customer ID. Env fallback: MOUSEHOLD_FINTS_CUSTOMER_ID")
|
||||
parser.add_argument("--pin", help="FinTS PIN. Prefer prompt or MOUSEHOLD_FINTS_PIN over shell history.")
|
||||
parser.add_argument("--pin-env", default="MOUSEHOLD_FINTS_PIN")
|
||||
parser.add_argument("--tan-mechanism", help="Optional TAN mechanism. Env fallback: MOUSEHOLD_FINTS_TAN_MECHANISM")
|
||||
parser.add_argument("--tan-medium", help="Optional TAN medium name. Env fallback: MOUSEHOLD_FINTS_TAN_MEDIUM")
|
||||
parser.add_argument("--product-id", help="Registered FinTS product ID. Env fallback: MOUSEHOLD_FINTS_PRODUCT_ID")
|
||||
parser.add_argument(
|
||||
"--product-version",
|
||||
help="Optional product version. Env fallback: MOUSEHOLD_FINTS_PRODUCT_VERSION",
|
||||
)
|
||||
|
||||
|
||||
def _add_fints_transaction_args(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--iban", help="IBAN to fetch. Defaults to the first account returned by the bank.")
|
||||
parser.add_argument("--days", type=int, default=30)
|
||||
parser.add_argument("--start-date", help="YYYY-MM-DD")
|
||||
parser.add_argument("--end-date", help="YYYY-MM-DD")
|
||||
parser.add_argument("--include-pending", action="store_true")
|
||||
|
||||
|
||||
def _demo_observations(
|
||||
household_id: str,
|
||||
owner_user_id: str | None,
|
||||
source_account_id: str | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
today = date.today().isoformat()
|
||||
return [
|
||||
{
|
||||
"household_id": household_id,
|
||||
"source_type": "bank_csv",
|
||||
"source_account_id": source_account_id,
|
||||
"owner_user_id": owner_user_id,
|
||||
"source_reference": "demo-bank-001",
|
||||
"provider_transaction_id": "demo-bank-001",
|
||||
"amount": "42.50",
|
||||
"currency": "EUR",
|
||||
"event_date": today,
|
||||
"merchant": "REWE",
|
||||
"description": "Groceries",
|
||||
"event_type": "expense",
|
||||
"status": "confirmed",
|
||||
},
|
||||
{
|
||||
"household_id": household_id,
|
||||
"source_type": "paypal_email",
|
||||
"source_account_id": source_account_id,
|
||||
"owner_user_id": owner_user_id,
|
||||
"source_reference": "demo-paypal-email-001",
|
||||
"message_id": "demo-paypal-email-001",
|
||||
"amount": "18.99",
|
||||
"currency": "EUR",
|
||||
"event_date": today,
|
||||
"due_date": today,
|
||||
"merchant": "PayPal Merchant",
|
||||
"description": "PayPal purchase announced by email",
|
||||
"event_type": "promised_payment",
|
||||
"status": "outstanding",
|
||||
},
|
||||
{
|
||||
"household_id": household_id,
|
||||
"source_type": "klarna_email",
|
||||
"source_account_id": source_account_id,
|
||||
"owner_user_id": owner_user_id,
|
||||
"source_reference": "demo-klarna-email-001",
|
||||
"message_id": "demo-klarna-email-001",
|
||||
"amount": "84.00",
|
||||
"currency": "EUR",
|
||||
"event_date": today,
|
||||
"due_date": today,
|
||||
"merchant": "Klarna Shop",
|
||||
"description": "Klarna pay-later obligation",
|
||||
"event_type": "promised_payment",
|
||||
"status": "outstanding",
|
||||
},
|
||||
]
|
||||
86
apps/local-agent/mousehold_agent/csv_importer.py
Normal file
86
apps/local-agent/mousehold_agent/csv_importer.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def read_transaction_csv(
|
||||
path: Path,
|
||||
*,
|
||||
household_id: str,
|
||||
source_type: str,
|
||||
source_account_id: str | None,
|
||||
owner_user_id: str | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
with path.open(newline="", encoding="utf-8-sig") as handle:
|
||||
reader = csv.DictReader(handle)
|
||||
for row in reader:
|
||||
event_date = _pick(
|
||||
row, "event_date", "date", "booking_date", "Buchungstag", "Valutadatum"
|
||||
)
|
||||
amount = _pick(row, "amount", "Betrag", "value")
|
||||
if not event_date or not amount:
|
||||
continue
|
||||
merchant = _pick(
|
||||
row,
|
||||
"merchant",
|
||||
"counterparty",
|
||||
"Auftraggeber/Empfänger",
|
||||
"Name Zahlungsbeteiligter",
|
||||
)
|
||||
description = _pick(row, "description", "purpose", "Verwendungszweck", "Buchungstext")
|
||||
currency = _pick(row, "currency", "Waehrung", "Währung") or "EUR"
|
||||
provider_transaction_id = _pick(
|
||||
row, "provider_transaction_id", "transaction_id", "Umsatz ID"
|
||||
)
|
||||
normalized = {
|
||||
"household_id": household_id,
|
||||
"source_type": source_type,
|
||||
"source_account_id": source_account_id,
|
||||
"owner_user_id": owner_user_id,
|
||||
"source_reference": provider_transaction_id,
|
||||
"provider_transaction_id": provider_transaction_id,
|
||||
"raw_payload_json": row,
|
||||
"amount": str(_parse_amount(amount)),
|
||||
"currency": currency,
|
||||
"event_date": _parse_date(event_date).isoformat(),
|
||||
"merchant": merchant,
|
||||
"description": description,
|
||||
"event_type": "expense" if _parse_amount(amount) < 0 else "income",
|
||||
"status": "confirmed",
|
||||
}
|
||||
normalized["amount"] = str(abs(_parse_amount(amount)))
|
||||
rows.append(normalized)
|
||||
return rows
|
||||
|
||||
|
||||
def _pick(row: dict[str, str], *keys: str) -> str | None:
|
||||
lowered = {key.lower(): value for key, value in row.items()}
|
||||
for key in keys:
|
||||
value = row.get(key) or lowered.get(key.lower())
|
||||
if value:
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _parse_amount(value: str) -> Decimal:
|
||||
normalized = value.strip().replace(".", "").replace(",", ".")
|
||||
return Decimal(normalized)
|
||||
|
||||
|
||||
def _parse_date(value: str) -> date:
|
||||
stripped = value.strip()
|
||||
for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y"):
|
||||
try:
|
||||
return (
|
||||
date.fromisoformat(stripped)
|
||||
if fmt == "%Y-%m-%d"
|
||||
else __import__("datetime").datetime.strptime(stripped, fmt).date()
|
||||
)
|
||||
except ValueError:
|
||||
continue
|
||||
raise ValueError(f"Unsupported date format: {value}")
|
||||
582
apps/local-agent/mousehold_agent/fints_connector.py
Normal file
582
apps/local-agent/mousehold_agent/fints_connector.py
Normal file
@@ -0,0 +1,582 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import getpass
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fints.client import FinTS3PinTanClient, NeedTANResponse
|
||||
from fints.models import SEPAAccount
|
||||
from fints.utils import minimal_interactive_cli_bootstrap
|
||||
|
||||
DEFAULT_PROFILE = "default"
|
||||
DEFAULT_STATE_DIR = Path.home() / ".config" / "mousehold" / "fints"
|
||||
PRODUCT_ID_HELP = (
|
||||
"FinTS product_id is required by python-fints >=4. Register one with Deutsche Kreditwirtschaft/ZKA "
|
||||
"and provide it as MOUSEHOLD_FINTS_PRODUCT_ID, --product-id, or in the FinTS web form."
|
||||
)
|
||||
|
||||
|
||||
class FinTSConfigurationError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FinTSConfig:
|
||||
bank_identifier: str
|
||||
user_id: str
|
||||
pin: str
|
||||
server: str
|
||||
product_id: str
|
||||
customer_id: str | None = None
|
||||
product_version: str | None = None
|
||||
tan_mechanism: str | None = None
|
||||
tan_medium: str | None = None
|
||||
profile: str = DEFAULT_PROFILE
|
||||
state_dir: Path = DEFAULT_STATE_DIR
|
||||
store_state: bool = True
|
||||
interactive_bootstrap: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FinTSFetchResult:
|
||||
account: SEPAAccount
|
||||
transactions: list[Any]
|
||||
observations: list[dict[str, Any]]
|
||||
|
||||
|
||||
class _HTMLTextExtractor(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.parts: list[str] = []
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
stripped = data.strip()
|
||||
if stripped:
|
||||
self.parts.append(stripped)
|
||||
|
||||
def text(self) -> str:
|
||||
return " ".join(self.parts)
|
||||
|
||||
|
||||
class FinTSProfileStore:
|
||||
def __init__(self, state_dir: Path, profile: str) -> None:
|
||||
self.state_dir = state_dir
|
||||
self.profile = profile
|
||||
self.path = state_dir / f"{profile}.json"
|
||||
|
||||
def load_client_data(self) -> bytes | None:
|
||||
if not self.path.exists():
|
||||
return None
|
||||
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
client_data = data.get("client_data")
|
||||
if not client_data:
|
||||
return None
|
||||
return base64.b64decode(client_data.encode("ascii"))
|
||||
|
||||
def save_client_data(self, client: FinTS3PinTanClient, config: FinTSConfig) -> None:
|
||||
self.state_dir.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"profile": self.profile,
|
||||
"bank_identifier": config.bank_identifier,
|
||||
"server": config.server,
|
||||
"user_id": config.user_id,
|
||||
"customer_id": config.customer_id,
|
||||
"updated_at": datetime.now().isoformat(timespec="seconds"),
|
||||
"client_data": base64.b64encode(client.deconstruct()).decode("ascii"),
|
||||
}
|
||||
tmp_path = self.path.with_suffix(".tmp")
|
||||
tmp_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
||||
os.chmod(tmp_path, 0o600)
|
||||
tmp_path.replace(self.path)
|
||||
os.chmod(self.path, 0o600)
|
||||
|
||||
|
||||
def config_from_args(args: Any) -> FinTSConfig:
|
||||
pin = getattr(args, "pin", None) or os.environ.get(getattr(args, "pin_env", "MOUSEHOLD_FINTS_PIN"))
|
||||
if not pin:
|
||||
pin = getpass.getpass("FinTS PIN: ")
|
||||
|
||||
product_id = _required_arg(args, "product_id", "MOUSEHOLD_FINTS_PRODUCT_ID", PRODUCT_ID_HELP)
|
||||
product_version = getattr(args, "product_version", None) or os.environ.get("MOUSEHOLD_FINTS_PRODUCT_VERSION")
|
||||
|
||||
return FinTSConfig(
|
||||
bank_identifier=_required_arg(args, "blz", "MOUSEHOLD_FINTS_BLZ"),
|
||||
user_id=_required_arg(args, "bank_user_id", "MOUSEHOLD_FINTS_USER_ID"),
|
||||
pin=pin,
|
||||
server=_required_arg(args, "server", "MOUSEHOLD_FINTS_SERVER"),
|
||||
customer_id=getattr(args, "customer_id", None) or os.environ.get("MOUSEHOLD_FINTS_CUSTOMER_ID"),
|
||||
product_id=product_id,
|
||||
product_version=product_version,
|
||||
tan_mechanism=getattr(args, "tan_mechanism", None) or os.environ.get("MOUSEHOLD_FINTS_TAN_MECHANISM"),
|
||||
tan_medium=getattr(args, "tan_medium", None) or os.environ.get("MOUSEHOLD_FINTS_TAN_MEDIUM"),
|
||||
profile=getattr(args, "profile", DEFAULT_PROFILE),
|
||||
state_dir=Path(getattr(args, "state_dir", DEFAULT_STATE_DIR)).expanduser(),
|
||||
store_state=not getattr(args, "no_state", False),
|
||||
interactive_bootstrap=not getattr(args, "no_bootstrap", False),
|
||||
)
|
||||
|
||||
|
||||
def create_client(config: FinTSConfig) -> FinTS3PinTanClient:
|
||||
if not config.product_id:
|
||||
raise FinTSConfigurationError(PRODUCT_ID_HELP)
|
||||
|
||||
kwargs: dict[str, Any] = {"product_id": config.product_id}
|
||||
if config.product_version:
|
||||
kwargs["product_version"] = config.product_version
|
||||
|
||||
store = FinTSProfileStore(config.state_dir, config.profile)
|
||||
if config.store_state and (client_data := store.load_client_data()):
|
||||
kwargs["from_data"] = client_data
|
||||
|
||||
return FinTS3PinTanClient(
|
||||
config.bank_identifier,
|
||||
config.user_id,
|
||||
config.pin,
|
||||
config.server,
|
||||
customer_id=config.customer_id,
|
||||
tan_medium=config.tan_medium,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def bootstrap_client(client: FinTS3PinTanClient, config: FinTSConfig) -> None:
|
||||
if config.tan_mechanism and not client.get_current_tan_mechanism():
|
||||
client.set_tan_mechanism(config.tan_mechanism)
|
||||
if config.interactive_bootstrap:
|
||||
minimal_interactive_cli_bootstrap(client)
|
||||
|
||||
|
||||
def persist_client(client: FinTS3PinTanClient, config: FinTSConfig) -> None:
|
||||
if config.store_state:
|
||||
FinTSProfileStore(config.state_dir, config.profile).save_client_data(client, config)
|
||||
|
||||
|
||||
def with_fints_dialog(config: FinTSConfig, operation: Any) -> Any:
|
||||
client = create_client(config)
|
||||
bootstrap_client(client, config)
|
||||
try:
|
||||
with client:
|
||||
while isinstance(client.init_tan_response, NeedTANResponse):
|
||||
client.init_tan_response = answer_tan_challenge(client, client.init_tan_response, config)
|
||||
return operation(client)
|
||||
finally:
|
||||
persist_client(client, config)
|
||||
|
||||
|
||||
def list_accounts(config: FinTSConfig, *, include_balances: bool = False) -> list[dict[str, Any]]:
|
||||
def operation(client: FinTS3PinTanClient) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for index, account in enumerate(client.get_sepa_accounts()):
|
||||
row = account_to_dict(account)
|
||||
row["index"] = index
|
||||
if include_balances:
|
||||
balance = resolve_tan_response(client, client.get_balance(account), config)
|
||||
row["balance"] = balance_to_dict(balance)
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
return with_fints_dialog(config, operation)
|
||||
|
||||
|
||||
def fetch_transactions(
|
||||
config: FinTSConfig,
|
||||
*,
|
||||
iban: str | None,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
include_pending: bool = False,
|
||||
household_id: str | None = None,
|
||||
source_account_id: str | None = None,
|
||||
owner_user_id: str | None = None,
|
||||
) -> FinTSFetchResult:
|
||||
def operation(client: FinTS3PinTanClient) -> FinTSFetchResult:
|
||||
account = select_account(client.get_sepa_accounts(), iban)
|
||||
response = client.get_transactions(
|
||||
account,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
include_pending=include_pending,
|
||||
)
|
||||
transactions = resolve_tan_response(client, response, config)
|
||||
observations = [
|
||||
transaction_to_observation(
|
||||
transaction,
|
||||
account=account,
|
||||
household_id=household_id,
|
||||
source_account_id=source_account_id,
|
||||
owner_user_id=owner_user_id,
|
||||
)
|
||||
for transaction in transactions
|
||||
]
|
||||
return FinTSFetchResult(account=account, transactions=list(transactions), observations=observations)
|
||||
|
||||
return with_fints_dialog(config, operation)
|
||||
|
||||
|
||||
def resolve_tan_response(
|
||||
client: FinTS3PinTanClient,
|
||||
response: Any,
|
||||
config: FinTSConfig,
|
||||
) -> Any:
|
||||
while isinstance(response, NeedTANResponse):
|
||||
response = answer_tan_challenge(client, response, config)
|
||||
return response
|
||||
|
||||
|
||||
def answer_tan_challenge(
|
||||
client: FinTS3PinTanClient,
|
||||
response: NeedTANResponse,
|
||||
config: FinTSConfig,
|
||||
) -> Any:
|
||||
print("\nTAN required by bank.")
|
||||
challenge_text = challenge_to_text(response)
|
||||
if challenge_text:
|
||||
print(challenge_text)
|
||||
|
||||
challenge_file = write_binary_challenge(response, config)
|
||||
if challenge_file:
|
||||
print(f"Challenge file: {challenge_file}")
|
||||
|
||||
if getattr(response, "challenge_hhduc", None):
|
||||
print("A chipTAN/flicker challenge is available.")
|
||||
print("Use your bank reader/app if it can read the challenge shown by your terminal.")
|
||||
|
||||
if getattr(response, "decoupled", False):
|
||||
input("Confirm the login/order in your banking app, then press Enter here.")
|
||||
return client.send_tan(response, "")
|
||||
|
||||
tan = getpass.getpass("TAN: ")
|
||||
return client.send_tan(response, tan)
|
||||
|
||||
|
||||
def tan_challenge_to_dict(response: NeedTANResponse) -> dict[str, Any]:
|
||||
matrix = getattr(response, "challenge_matrix", None)
|
||||
matrix_payload = None
|
||||
if matrix:
|
||||
mime_type, payload = matrix
|
||||
matrix_payload = {
|
||||
"mime_type": str(mime_type),
|
||||
"data_base64": base64.b64encode(payload).decode("ascii"),
|
||||
}
|
||||
return {
|
||||
"challenge": challenge_to_text(response),
|
||||
"challenge_html": str(getattr(response, "challenge_html", "") or ""),
|
||||
"challenge_hhduc": getattr(response, "challenge_hhduc", None),
|
||||
"challenge_matrix": matrix_payload,
|
||||
"decoupled": bool(getattr(response, "decoupled", False)),
|
||||
}
|
||||
|
||||
|
||||
def challenge_to_text(response: NeedTANResponse) -> str:
|
||||
if getattr(response, "challenge", None):
|
||||
return str(response.challenge)
|
||||
if getattr(response, "challenge_html", None):
|
||||
parser = _HTMLTextExtractor()
|
||||
parser.feed(str(response.challenge_html))
|
||||
return parser.text()
|
||||
if getattr(response, "challenge_raw", None):
|
||||
return str(response.challenge_raw)
|
||||
return ""
|
||||
|
||||
|
||||
def write_binary_challenge(response: NeedTANResponse, config: FinTSConfig) -> Path | None:
|
||||
matrix = getattr(response, "challenge_matrix", None)
|
||||
if not matrix:
|
||||
return None
|
||||
|
||||
mime_type, payload = matrix
|
||||
suffix = ".bin"
|
||||
if "png" in str(mime_type).lower():
|
||||
suffix = ".png"
|
||||
elif "jpeg" in str(mime_type).lower() or "jpg" in str(mime_type).lower():
|
||||
suffix = ".jpg"
|
||||
|
||||
output_dir = config.state_dir / "challenges"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = output_dir / f"{config.profile}-{datetime.now().strftime('%Y%m%d%H%M%S')}{suffix}"
|
||||
path.write_bytes(payload)
|
||||
os.chmod(path, 0o600)
|
||||
return path
|
||||
|
||||
|
||||
def select_account(accounts: list[SEPAAccount], iban: str | None) -> SEPAAccount:
|
||||
if not accounts:
|
||||
raise RuntimeError("No SEPA accounts returned by the bank.")
|
||||
if not iban:
|
||||
if len(accounts) > 1:
|
||||
print("Multiple accounts returned; using the first one. Pass --iban to select explicitly.")
|
||||
return accounts[0]
|
||||
normalized = iban.replace(" ", "").upper()
|
||||
for account in accounts:
|
||||
if (account.iban or "").replace(" ", "").upper() == normalized:
|
||||
return account
|
||||
available = ", ".join(account.iban or "<no iban>" for account in accounts)
|
||||
raise RuntimeError(f"IBAN not found. Available accounts: {available}")
|
||||
|
||||
|
||||
def date_range_from_args(args: Any) -> tuple[date, date]:
|
||||
end_date = _parse_date_arg(getattr(args, "end_date", None)) or date.today()
|
||||
start_date = _parse_date_arg(getattr(args, "start_date", None))
|
||||
if not start_date:
|
||||
start_date = end_date - timedelta(days=getattr(args, "days", 30))
|
||||
return start_date, end_date
|
||||
|
||||
|
||||
def transaction_to_observation(
|
||||
transaction: Any,
|
||||
*,
|
||||
account: SEPAAccount,
|
||||
household_id: str | None,
|
||||
source_account_id: str | None,
|
||||
owner_user_id: str | None,
|
||||
) -> dict[str, Any]:
|
||||
raw_data = transaction_data(transaction)
|
||||
normalized_data = jsonable(raw_data)
|
||||
amount, currency = extract_amount(raw_data)
|
||||
booking_date = extract_date(raw_data, "date", "booking_date", "entry_date")
|
||||
value_date = extract_date(raw_data, "entry_date", "valuta_date", "date")
|
||||
counterparty = pick_text(
|
||||
raw_data,
|
||||
"applicant_name",
|
||||
"applicant_iban",
|
||||
"applicant_bin",
|
||||
"counterparty",
|
||||
"name",
|
||||
)
|
||||
description = pick_text(
|
||||
raw_data,
|
||||
"purpose",
|
||||
"transaction_details",
|
||||
"extra_details",
|
||||
"posting_text",
|
||||
"prima_nota",
|
||||
"additional_position_reference",
|
||||
)
|
||||
reference = pick_text(
|
||||
raw_data,
|
||||
"id",
|
||||
"transaction_reference",
|
||||
"customer_reference",
|
||||
"bank_reference",
|
||||
"end_to_end_reference",
|
||||
"eref",
|
||||
"mref",
|
||||
"svwz",
|
||||
)
|
||||
fallback_reference = stable_transaction_reference(account, normalized_data)
|
||||
signed_amount = amount
|
||||
|
||||
return {
|
||||
"household_id": household_id,
|
||||
"source_type": "fints",
|
||||
"source_account_id": source_account_id,
|
||||
"owner_user_id": owner_user_id,
|
||||
"source_reference": reference or fallback_reference,
|
||||
"provider_transaction_id": reference or fallback_reference,
|
||||
"raw_payload_json": {
|
||||
"account": account_to_dict(account),
|
||||
"transaction": normalized_data,
|
||||
"source": "fints",
|
||||
},
|
||||
"amount": str(abs(signed_amount)),
|
||||
"currency": currency or "EUR",
|
||||
"event_date": (booking_date or value_date or date.today()).isoformat(),
|
||||
"booking_date": booking_date.isoformat() if booking_date else None,
|
||||
"merchant": counterparty,
|
||||
"counterparty": counterparty,
|
||||
"description": description,
|
||||
"event_type": infer_event_type(signed_amount, counterparty, description),
|
||||
"status": "confirmed",
|
||||
}
|
||||
|
||||
|
||||
def transaction_data(transaction: Any) -> dict[str, Any]:
|
||||
data = getattr(transaction, "data", transaction)
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
if hasattr(data, "_asdict"):
|
||||
return data._asdict()
|
||||
return {"value": data}
|
||||
|
||||
|
||||
def extract_amount(data: dict[str, Any]) -> tuple[Decimal, str | None]:
|
||||
value = data.get("amount") or data.get("transaction_amount")
|
||||
currency = data.get("currency")
|
||||
if hasattr(value, "amount"):
|
||||
currency = getattr(value, "currency", currency)
|
||||
return Decimal(str(value.amount)), currency
|
||||
if isinstance(value, dict):
|
||||
amount = value.get("amount") or value.get("value")
|
||||
currency = value.get("currency", currency)
|
||||
return Decimal(str(amount)), currency
|
||||
if value is None:
|
||||
raise RuntimeError(f"Transaction has no amount: {data}")
|
||||
return Decimal(str(value).replace(",", ".")), currency
|
||||
|
||||
|
||||
def extract_date(data: dict[str, Any], *keys: str) -> date | None:
|
||||
for key in keys:
|
||||
value = data.get(key)
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
if isinstance(value, datetime):
|
||||
return value.date()
|
||||
if isinstance(value, str) and value:
|
||||
for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%Y%m%d"):
|
||||
try:
|
||||
return datetime.strptime(value, fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def pick_text(data: dict[str, Any], *keys: str) -> str | None:
|
||||
values: list[str] = []
|
||||
for key in keys:
|
||||
value = data.get(key)
|
||||
if value is None or value == "":
|
||||
continue
|
||||
if isinstance(value, list | tuple):
|
||||
values.extend(str(item).strip() for item in value if str(item).strip())
|
||||
else:
|
||||
values.append(str(value).strip())
|
||||
if not values:
|
||||
return None
|
||||
return " ".join(dict.fromkeys(values))[:500]
|
||||
|
||||
|
||||
def infer_event_type(amount: Decimal, counterparty: str | None, description: str | None) -> str:
|
||||
if amount >= 0:
|
||||
return "income"
|
||||
combined = f"{counterparty or ''} {description or ''}".lower()
|
||||
liability_markers = (
|
||||
"paypal",
|
||||
"klarna",
|
||||
"kreditkarte",
|
||||
"credit card",
|
||||
"visa",
|
||||
"mastercard",
|
||||
"american express",
|
||||
"amex",
|
||||
)
|
||||
if any(marker in combined for marker in liability_markers):
|
||||
return "liability_payment"
|
||||
return "expense"
|
||||
|
||||
|
||||
def account_to_dict(account: SEPAAccount) -> dict[str, Any]:
|
||||
return {
|
||||
"iban": account.iban,
|
||||
"bic": account.bic,
|
||||
"accountnumber": account.accountnumber,
|
||||
"subaccount": account.subaccount,
|
||||
"blz": account.blz,
|
||||
}
|
||||
|
||||
|
||||
def balance_to_dict(balance: Any) -> dict[str, Any]:
|
||||
amount = getattr(balance, "amount", None)
|
||||
return {
|
||||
"amount": str(getattr(amount, "amount", amount)),
|
||||
"currency": getattr(amount, "currency", None),
|
||||
"date": getattr(balance, "date", None).isoformat()
|
||||
if isinstance(getattr(balance, "date", None), date)
|
||||
else str(getattr(balance, "date", "")),
|
||||
"status": getattr(balance, "status", None),
|
||||
}
|
||||
|
||||
|
||||
def stable_transaction_reference(account: SEPAAccount, payload: dict[str, Any]) -> str:
|
||||
digest = hashlib.sha256(
|
||||
json.dumps(
|
||||
{"account": account_to_dict(account), "transaction": payload},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
return f"fints:{account.iban or account.accountnumber}:{digest}"
|
||||
|
||||
|
||||
def jsonable(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {str(key): jsonable(item) for key, item in value.items()}
|
||||
if isinstance(value, list | tuple | set):
|
||||
return [jsonable(item) for item in value]
|
||||
if isinstance(value, datetime | date):
|
||||
return value.isoformat()
|
||||
if isinstance(value, Decimal):
|
||||
return str(value)
|
||||
if hasattr(value, "amount"):
|
||||
return {
|
||||
"amount": str(value.amount),
|
||||
"currency": getattr(value, "currency", None),
|
||||
}
|
||||
if hasattr(value, "_asdict"):
|
||||
return jsonable(value._asdict())
|
||||
return value if isinstance(value, str | int | float | bool | type(None)) else str(value)
|
||||
|
||||
|
||||
def display_accounts(accounts: list[dict[str, Any]]) -> None:
|
||||
rows = []
|
||||
for account in accounts:
|
||||
balance = account.get("balance")
|
||||
balance_text = ""
|
||||
if isinstance(balance, dict) and balance.get("amount"):
|
||||
balance_text = f"{balance['amount']} {balance.get('currency') or ''}".strip()
|
||||
rows.append(
|
||||
[
|
||||
str(account.get("index", "")),
|
||||
account.get("iban") or "",
|
||||
account.get("bic") or "",
|
||||
account.get("accountnumber") or "",
|
||||
balance_text,
|
||||
]
|
||||
)
|
||||
print_table(["#", "IBAN", "BIC", "Account", "Balance"], rows)
|
||||
|
||||
|
||||
def display_transactions(observations: list[dict[str, Any]]) -> None:
|
||||
rows = [
|
||||
[
|
||||
observation.get("event_date") or "",
|
||||
observation.get("event_type") or "",
|
||||
observation.get("amount") or "",
|
||||
observation.get("currency") or "",
|
||||
observation.get("counterparty") or "",
|
||||
observation.get("description") or "",
|
||||
]
|
||||
for observation in observations
|
||||
]
|
||||
print_table(["Date", "Type", "Amount", "Cur", "Counterparty", "Description"], rows)
|
||||
|
||||
|
||||
def print_table(headers: list[str], rows: list[list[str]]) -> None:
|
||||
widths = [
|
||||
max(len(headers[index]), *(len(row[index]) for row in rows)) if rows else len(headers[index])
|
||||
for index in range(len(headers))
|
||||
]
|
||||
print(" ".join(header.ljust(widths[index]) for index, header in enumerate(headers)))
|
||||
print(" ".join("-" * width for width in widths))
|
||||
for row in rows:
|
||||
print(" ".join(row[index].ljust(widths[index]) for index in range(len(headers))))
|
||||
|
||||
|
||||
def _parse_date_arg(value: str | None) -> date | None:
|
||||
if not value:
|
||||
return None
|
||||
return date.fromisoformat(value)
|
||||
|
||||
|
||||
def _required_arg(args: Any, attribute: str, env_name: str, message: str | None = None) -> str:
|
||||
value = getattr(args, attribute, None) or os.environ.get(env_name)
|
||||
if not value:
|
||||
raise RuntimeError(message or f"{attribute.replace('_', '-')} or {env_name} is required")
|
||||
return value
|
||||
17
apps/local-agent/mousehold_agent/http.py
Normal file
17
apps/local-agent/mousehold_agent/http.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from urllib import request
|
||||
|
||||
|
||||
def post_json(url: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
req = request.Request(
|
||||
url,
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with request.urlopen(req, timeout=30) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
676
apps/local-agent/mousehold_agent/imap_importer.py
Normal file
676
apps/local-agent/mousehold_agent/imap_importer.py
Normal file
@@ -0,0 +1,676 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import email
|
||||
import imaplib
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from email import policy
|
||||
from email.header import decode_header
|
||||
from email.message import EmailMessage, Message
|
||||
from email.utils import getaddresses, parsedate_to_datetime
|
||||
from html.parser import HTMLParser
|
||||
from typing import Any
|
||||
|
||||
DEFAULT_SENDER_FILTERS = (
|
||||
"klarna",
|
||||
"paypal",
|
||||
"rechnung",
|
||||
"invoice",
|
||||
"receipt",
|
||||
"order",
|
||||
"bestellung",
|
||||
"quittung",
|
||||
)
|
||||
|
||||
PARSER_NAME = "mousehold-imap-email"
|
||||
PARSER_VERSION = "0.2.0"
|
||||
MAX_RAW_TEXT_LENGTH = 20_000
|
||||
|
||||
_AMOUNT_VALUE = r"[+-]?(?:\d{1,3}(?:[.\s']\d{3})+|\d+)(?:[,.]\d{2})"
|
||||
_AMOUNT_PATTERNS = (
|
||||
re.compile(rf"(?P<currency>€|EUR)\s*(?P<amount>{_AMOUNT_VALUE})", re.IGNORECASE),
|
||||
re.compile(rf"(?P<amount>{_AMOUNT_VALUE})\s*(?P<currency>€|EUR)", re.IGNORECASE),
|
||||
)
|
||||
_DATE_VALUE = r"(?P<date>\d{4}-\d{2}-\d{2}|\d{1,2}[./-]\d{1,2}[./-]\d{2,4})"
|
||||
_DUE_DATE_PATTERNS = (
|
||||
re.compile(rf"\b(?:fällig(?:\s+am|\s+bis)?|zahlbar\s+bis|bezahlen\s+bis)\s*:?\s*{_DATE_VALUE}", re.IGNORECASE),
|
||||
re.compile(rf"\b(?:due(?:\s+on|\s+by)?|pay(?:ment)?\s+due|pay\s+by)\s*:?\s*{_DATE_VALUE}", re.IGNORECASE),
|
||||
re.compile(rf"\b(?:abbuchung\s+am|wird\s+am|debit(?:ed)?\s+on)\s*:?\s*{_DATE_VALUE}", re.IGNORECASE),
|
||||
)
|
||||
_REFERENCE_PATTERNS: dict[str, tuple[re.Pattern[str], ...]] = {
|
||||
"order_number": (
|
||||
re.compile(
|
||||
r"\b(?:bestell(?:nummer|nr\.?)|order\s+(?:number|no\.?))\s*[:#-]?\s*"
|
||||
r"([A-Z0-9][A-Z0-9._/-]{2,})",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
re.compile(r"\b(?:bestellung|order)\s*[:#-]\s*([A-Z0-9][A-Z0-9._/-]{2,})", re.IGNORECASE),
|
||||
),
|
||||
"invoice_number": (
|
||||
re.compile(
|
||||
r"\b(?:rechnungs(?:nummer|nr\.?)|invoice\s+(?:number|no\.?))\s*[:#-]?\s*"
|
||||
r"([A-Z0-9][A-Z0-9._/-]{2,})",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
re.compile(r"\b(?:rechnung|invoice)\s*[:#-]\s*([A-Z0-9][A-Z0-9._/-]{2,})", re.IGNORECASE),
|
||||
),
|
||||
"paypal_transaction_id": (
|
||||
re.compile(
|
||||
r"\b(?:paypal\s+)?(?:transaction|transaktion)(?:\s+id|\s+nr\.?|\s+number)?\s*[:#-]?\s*"
|
||||
r"([A-Z0-9][A-Z0-9._-]{6,})",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
),
|
||||
"klarna_reference": (
|
||||
re.compile(
|
||||
r"\b(?:klarna\s+)?(?:referenz|reference|zahlung(?:s)?referenz)\s*[:#-]?\s*"
|
||||
r"([A-Z0-9][A-Z0-9._/-]{4,})",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IMAPConfig:
|
||||
host: str
|
||||
username: str
|
||||
password: str
|
||||
port: int = 993
|
||||
folder: str = "INBOX"
|
||||
use_ssl: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AmountMatch:
|
||||
amount: Decimal
|
||||
currency: str
|
||||
context: str
|
||||
score: int
|
||||
|
||||
|
||||
class _HTMLTextExtractor(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.parts: list[str] = []
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
del attrs
|
||||
if tag.lower() in {"br", "p", "div", "li", "tr"}:
|
||||
self.parts.append("\n")
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
self.parts.append(data)
|
||||
|
||||
def text(self) -> str:
|
||||
return _clean_text(" ".join(self.parts))
|
||||
|
||||
|
||||
def config_from_env() -> IMAPConfig:
|
||||
return IMAPConfig(
|
||||
host=_required_env("MOUSEHOLD_IMAP_HOST"),
|
||||
port=int(os.environ.get("MOUSEHOLD_IMAP_PORT", "993")),
|
||||
username=_required_env("MOUSEHOLD_IMAP_USERNAME"),
|
||||
password=_required_env("MOUSEHOLD_IMAP_PASSWORD"),
|
||||
folder=os.environ.get("MOUSEHOLD_IMAP_FOLDER", "INBOX"),
|
||||
use_ssl=_env_bool("MOUSEHOLD_IMAP_USE_SSL", default=True),
|
||||
)
|
||||
|
||||
|
||||
def fetch_imap_observations(
|
||||
*,
|
||||
household_id: str,
|
||||
owner_user_id: str | None,
|
||||
source_account_id: str | None,
|
||||
limit: int = 25,
|
||||
) -> list[dict[str, Any]]:
|
||||
sender_filters = tuple(
|
||||
value.strip().lower()
|
||||
for value in os.environ.get(
|
||||
"MOUSEHOLD_IMAP_SENDER_FILTERS", ",".join(DEFAULT_SENDER_FILTERS)
|
||||
).split(",")
|
||||
if value.strip()
|
||||
)
|
||||
result = scan_imap_mailbox(
|
||||
config_from_env(),
|
||||
household_id=household_id,
|
||||
owner_user_id=owner_user_id,
|
||||
source_account_id=source_account_id,
|
||||
limit=limit,
|
||||
sender_filters=sender_filters,
|
||||
)
|
||||
return result["observations"]
|
||||
|
||||
|
||||
def list_imap_folders(config: IMAPConfig) -> list[dict[str, Any]]:
|
||||
with _connect(config) as mailbox:
|
||||
_login(mailbox, config)
|
||||
status, data = mailbox.list()
|
||||
_ensure_ok(status, data, "list folders")
|
||||
return [_parse_folder_response(item) for item in data if item]
|
||||
|
||||
|
||||
def scan_imap_mailbox(
|
||||
config: IMAPConfig,
|
||||
*,
|
||||
household_id: str,
|
||||
owner_user_id: str | None = None,
|
||||
source_account_id: str | None = None,
|
||||
limit: int = 25,
|
||||
folder: str | None = None,
|
||||
search_query: str = "ALL",
|
||||
sender_filters: tuple[str, ...] | list[str] | None = DEFAULT_SENDER_FILTERS,
|
||||
) -> dict[str, Any]:
|
||||
selected_folder = folder or config.folder
|
||||
filters = _normalize_filters(sender_filters)
|
||||
messages: list[dict[str, Any]] = []
|
||||
observations: list[dict[str, Any]] = []
|
||||
scanned_count = 0
|
||||
filtered_count = 0
|
||||
|
||||
with _connect(config) as mailbox:
|
||||
_login(mailbox, config)
|
||||
status, data = mailbox.select(_mailbox_name(selected_folder), readonly=True)
|
||||
_ensure_ok(status, data, f"select folder {selected_folder}")
|
||||
status, data = mailbox.search(None, *_search_criteria(search_query))
|
||||
_ensure_ok(status, data, f"search {search_query}")
|
||||
ids = data[0].split()[-limit:] if data and data[0] else []
|
||||
scanned_count = len(ids)
|
||||
|
||||
for message_id in reversed(ids):
|
||||
status, fetched = mailbox.fetch(message_id, "(RFC822)")
|
||||
if _status_text(status) != "OK" or not fetched or not isinstance(fetched[0], tuple):
|
||||
continue
|
||||
message = email.message_from_bytes(fetched[0][1], policy=policy.default)
|
||||
headers_text = _headers_text(message)
|
||||
body = _message_text(message)
|
||||
if filters and not _matches_filters(filters, headers_text, body):
|
||||
filtered_count += 1
|
||||
continue
|
||||
|
||||
parsed = parse_email_message(
|
||||
message,
|
||||
household_id=household_id,
|
||||
owner_user_id=owner_user_id,
|
||||
source_account_id=source_account_id,
|
||||
folder=selected_folder,
|
||||
body=body,
|
||||
)
|
||||
messages.append(parsed["message"])
|
||||
if parsed["observation"]:
|
||||
observations.append(parsed["observation"])
|
||||
|
||||
return {
|
||||
"folder": selected_folder,
|
||||
"scanned_count": scanned_count,
|
||||
"filtered_count": filtered_count,
|
||||
"matched_count": len(messages),
|
||||
"importable_count": len(observations),
|
||||
"messages": messages,
|
||||
"observations": observations,
|
||||
}
|
||||
|
||||
|
||||
def parse_email_message(
|
||||
message: Message,
|
||||
*,
|
||||
household_id: str,
|
||||
owner_user_id: str | None,
|
||||
source_account_id: str | None,
|
||||
folder: str = "INBOX",
|
||||
body: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
subject = _decode_mime_header(message.get("Subject"))
|
||||
from_header = _decode_mime_header(message.get("From"))
|
||||
to_header = _decode_mime_header(message.get("To"))
|
||||
message_id = _compact_header_value(message.get("Message-ID"))
|
||||
received_date = _message_date(message) or date.today()
|
||||
text = body if body is not None else _message_text(message)
|
||||
search_text = f"{subject}\n{text}"
|
||||
source_type = _source_type(from_header, subject, text)
|
||||
amount_match = _extract_amount_match(search_text)
|
||||
due_date = _extract_due_date(search_text)
|
||||
references = _extract_references(subject, text)
|
||||
provider_transaction_id = _provider_transaction_id(references)
|
||||
merchant = _merchant(from_header, subject, text, source_type)
|
||||
event_type = _event_type(source_type, subject, text, due_date)
|
||||
event_status = _event_status(source_type, event_type, due_date, subject, text)
|
||||
source_reference = (
|
||||
message_id
|
||||
or provider_transaction_id
|
||||
or _fallback_source_reference(from_header, subject, received_date)
|
||||
)
|
||||
if subject:
|
||||
description = subject
|
||||
elif merchant:
|
||||
description = f"Email from {merchant}"
|
||||
else:
|
||||
description = "Email import"
|
||||
|
||||
preview = {
|
||||
"message_id": message_id,
|
||||
"source_reference": source_reference,
|
||||
"from": from_header,
|
||||
"to": to_header,
|
||||
"subject": subject,
|
||||
"date": received_date.isoformat(),
|
||||
"folder": folder,
|
||||
"source_type": source_type,
|
||||
"event_type": event_type,
|
||||
"status": event_status,
|
||||
"amount": str(amount_match.amount) if amount_match else None,
|
||||
"currency": amount_match.currency if amount_match else None,
|
||||
"merchant": merchant,
|
||||
"due_date": due_date.isoformat() if due_date else None,
|
||||
"references": references,
|
||||
"importable": amount_match is not None,
|
||||
"skip_reason": None if amount_match else "No EUR amount found",
|
||||
}
|
||||
|
||||
if not amount_match:
|
||||
return {"message": preview, "observation": None}
|
||||
|
||||
observation = {
|
||||
"household_id": household_id,
|
||||
"source_type": source_type,
|
||||
"source_account_id": source_account_id,
|
||||
"owner_user_id": owner_user_id,
|
||||
"message_id": message_id,
|
||||
"source_reference": source_reference,
|
||||
"provider_transaction_id": provider_transaction_id,
|
||||
"raw_text": text[:MAX_RAW_TEXT_LENGTH],
|
||||
"raw_payload_json": {
|
||||
"from": from_header,
|
||||
"to": to_header,
|
||||
"subject": subject,
|
||||
"date": _compact_header_value(message.get("Date")),
|
||||
"folder": folder,
|
||||
"message_id": message_id,
|
||||
"references": references,
|
||||
"amount_context": amount_match.context,
|
||||
"parser_name": PARSER_NAME,
|
||||
"parser_version": PARSER_VERSION,
|
||||
},
|
||||
"amount": str(amount_match.amount),
|
||||
"currency": amount_match.currency,
|
||||
"event_date": received_date.isoformat(),
|
||||
"due_date": due_date.isoformat() if due_date else None,
|
||||
"merchant": merchant,
|
||||
"description": description[:240],
|
||||
"event_type": event_type,
|
||||
"status": event_status,
|
||||
"payer_user_id": owner_user_id,
|
||||
"payment_account_id": source_account_id,
|
||||
}
|
||||
return {"message": preview, "observation": observation}
|
||||
|
||||
|
||||
def _connect(config: IMAPConfig) -> imaplib.IMAP4:
|
||||
if config.use_ssl:
|
||||
return imaplib.IMAP4_SSL(config.host, config.port)
|
||||
return imaplib.IMAP4(config.host, config.port)
|
||||
|
||||
|
||||
def _login(mailbox: imaplib.IMAP4, config: IMAPConfig) -> None:
|
||||
status, data = mailbox.login(config.username, config.password)
|
||||
_ensure_ok(status, data, "login")
|
||||
|
||||
|
||||
def _required_env(name: str) -> str:
|
||||
value = os.environ.get(name)
|
||||
if not value:
|
||||
raise RuntimeError(f"{name} is required")
|
||||
return value
|
||||
|
||||
|
||||
def _env_bool(name: str, *, default: bool) -> bool:
|
||||
value = os.environ.get(name)
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _status_text(status: str | bytes) -> str:
|
||||
return status.decode("ascii", errors="replace").upper() if isinstance(status, bytes) else status.upper()
|
||||
|
||||
|
||||
def _ensure_ok(status: str | bytes, data: object, action: str) -> None:
|
||||
if _status_text(status) != "OK":
|
||||
raise RuntimeError(f"IMAP {action} failed: {data!r}")
|
||||
|
||||
|
||||
def _mailbox_name(folder: str) -> str:
|
||||
if folder.startswith('"') and folder.endswith('"'):
|
||||
return folder
|
||||
if any(char in folder for char in ' "()[\\]'):
|
||||
return '"' + folder.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
return folder
|
||||
|
||||
|
||||
def _search_criteria(search_query: str) -> list[str]:
|
||||
query = search_query.strip() or "ALL"
|
||||
try:
|
||||
parts = shlex.split(query)
|
||||
except ValueError:
|
||||
parts = query.split()
|
||||
return parts or ["ALL"]
|
||||
|
||||
|
||||
def _normalize_filters(sender_filters: tuple[str, ...] | list[str] | None) -> tuple[str, ...]:
|
||||
if sender_filters is None:
|
||||
return ()
|
||||
return tuple(token.strip().lower() for token in sender_filters if token.strip())
|
||||
|
||||
|
||||
def _matches_filters(filters: tuple[str, ...], headers_text: str, body: str) -> bool:
|
||||
haystack = f"{headers_text}\n{body[:5000]}".lower()
|
||||
return any(token in haystack for token in filters)
|
||||
|
||||
|
||||
def _headers_text(message: Message) -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
_decode_mime_header(message.get("From")),
|
||||
_decode_mime_header(message.get("To")),
|
||||
_decode_mime_header(message.get("Subject")),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _decode_mime_header(value: str | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
parts: list[str] = []
|
||||
for content, charset in decode_header(value):
|
||||
if isinstance(content, bytes):
|
||||
parts.append(content.decode(charset or "utf-8", errors="replace"))
|
||||
else:
|
||||
parts.append(content)
|
||||
return _clean_text("".join(parts))
|
||||
|
||||
|
||||
def _compact_header_value(value: str | None) -> str | None:
|
||||
cleaned = _decode_mime_header(value)
|
||||
return cleaned or None
|
||||
|
||||
|
||||
def _message_text(message: Message) -> str:
|
||||
if isinstance(message, EmailMessage):
|
||||
body = message.get_body(preferencelist=("plain", "html"))
|
||||
if body:
|
||||
content = body.get_content()
|
||||
if body.get_content_type() == "text/html":
|
||||
return _html_to_text(content)
|
||||
return _clean_text(content)
|
||||
|
||||
if message.is_multipart():
|
||||
plain_parts: list[str] = []
|
||||
html_parts: list[str] = []
|
||||
for part in message.walk():
|
||||
if part.get_content_maintype() != "text":
|
||||
continue
|
||||
payload = part.get_payload(decode=True)
|
||||
if not payload:
|
||||
continue
|
||||
text = payload.decode(part.get_content_charset() or "utf-8", errors="replace")
|
||||
if part.get_content_type() == "text/plain":
|
||||
plain_parts.append(text)
|
||||
elif part.get_content_type() == "text/html":
|
||||
html_parts.append(_html_to_text(text))
|
||||
return _clean_text("\n".join(plain_parts or html_parts))
|
||||
|
||||
payload = message.get_payload(decode=True)
|
||||
if payload:
|
||||
text = payload.decode(message.get_content_charset() or "utf-8", errors="replace")
|
||||
else:
|
||||
text = str(message.get_payload())
|
||||
if message.get_content_type() == "text/html":
|
||||
return _html_to_text(text)
|
||||
return _clean_text(text)
|
||||
|
||||
|
||||
def _html_to_text(html: str) -> str:
|
||||
extractor = _HTMLTextExtractor()
|
||||
extractor.feed(html)
|
||||
return extractor.text()
|
||||
|
||||
|
||||
def _clean_text(text: str) -> str:
|
||||
text = text.replace("\xa0", " ")
|
||||
text = re.sub(r"[ \t\r\f\v]+", " ", text)
|
||||
text = re.sub(r"\n\s+", "\n", text)
|
||||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _message_date(message: Message) -> date | None:
|
||||
header = message.get("Date")
|
||||
if not header:
|
||||
return None
|
||||
try:
|
||||
parsed = parsedate_to_datetime(header)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed.date()
|
||||
|
||||
|
||||
def _extract_amount_match(text: str) -> AmountMatch | None:
|
||||
candidates: list[AmountMatch] = []
|
||||
for pattern in _AMOUNT_PATTERNS:
|
||||
for match in pattern.finditer(text):
|
||||
amount = _parse_amount(match.group("amount"))
|
||||
if amount is None:
|
||||
continue
|
||||
start = max(match.start() - 90, 0)
|
||||
end = min(match.end() + 90, len(text))
|
||||
context = _clean_text(text[start:end])
|
||||
candidates.append(
|
||||
AmountMatch(
|
||||
amount=amount,
|
||||
currency="EUR",
|
||||
context=context,
|
||||
score=_amount_context_score(context),
|
||||
)
|
||||
)
|
||||
if not candidates:
|
||||
return None
|
||||
return sorted(candidates, key=lambda item: (item.score, item.amount), reverse=True)[0]
|
||||
|
||||
|
||||
def _parse_amount(value: str) -> Decimal | None:
|
||||
cleaned = value.replace("\xa0", "").replace(" ", "").replace("'", "")
|
||||
if "," in cleaned and "." in cleaned:
|
||||
if cleaned.rfind(",") > cleaned.rfind("."):
|
||||
cleaned = cleaned.replace(".", "").replace(",", ".")
|
||||
else:
|
||||
cleaned = cleaned.replace(",", "")
|
||||
elif "," in cleaned:
|
||||
cleaned = cleaned.replace(".", "").replace(",", ".")
|
||||
elif "." in cleaned and len(cleaned.rsplit(".", maxsplit=1)[-1]) != 2:
|
||||
cleaned = cleaned.replace(".", "")
|
||||
try:
|
||||
return abs(Decimal(cleaned)).quantize(Decimal("0.01"))
|
||||
except InvalidOperation:
|
||||
return None
|
||||
|
||||
|
||||
def _amount_context_score(context: str) -> int:
|
||||
lowered = context.lower()
|
||||
score = 0
|
||||
positive = (
|
||||
"gesamt",
|
||||
"gesamtbetrag",
|
||||
"summe",
|
||||
"total",
|
||||
"amount",
|
||||
"betrag",
|
||||
"zahlbetrag",
|
||||
"payment",
|
||||
"zahlung",
|
||||
"fällig",
|
||||
"due",
|
||||
)
|
||||
negative = ("mwst", "vat", "tax", "steuer", "versand", "shipping", "rabatt", "discount", "gutschein")
|
||||
if any(token in lowered for token in positive):
|
||||
score += 20
|
||||
if any(token in lowered for token in ("fällig", "due", "zahlbar", "pay by")):
|
||||
score += 8
|
||||
if any(token in lowered for token in negative):
|
||||
score -= 10
|
||||
return score
|
||||
|
||||
|
||||
def _extract_due_date(text: str) -> date | None:
|
||||
for pattern in _DUE_DATE_PATTERNS:
|
||||
match = pattern.search(text)
|
||||
if match:
|
||||
parsed = _parse_date_value(match.group("date"))
|
||||
if parsed:
|
||||
return parsed
|
||||
return None
|
||||
|
||||
|
||||
def _parse_date_value(value: str) -> date | None:
|
||||
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
|
||||
try:
|
||||
return datetime.strptime(value, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
return None
|
||||
parts = re.split(r"[./-]", value)
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
day, month, year = parts
|
||||
try:
|
||||
numeric_year = int(year)
|
||||
if numeric_year < 100:
|
||||
numeric_year += 2000
|
||||
return date(numeric_year, int(month), int(day))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_references(subject: str, text: str) -> dict[str, str]:
|
||||
haystack = f"{subject}\n{text}"
|
||||
references: dict[str, str] = {}
|
||||
for key, patterns in _REFERENCE_PATTERNS.items():
|
||||
for pattern in patterns:
|
||||
match = pattern.search(haystack)
|
||||
if match:
|
||||
references[key] = match.group(1).strip(" .,:;")
|
||||
break
|
||||
return references
|
||||
|
||||
|
||||
def _provider_transaction_id(references: dict[str, str]) -> str | None:
|
||||
for key in ("paypal_transaction_id", "klarna_reference", "order_number", "invoice_number"):
|
||||
if references.get(key):
|
||||
return references[key]
|
||||
return None
|
||||
|
||||
|
||||
def _source_type(sender: str, subject: str, text: str) -> str:
|
||||
combined = f"{sender}\n{subject}\n{text[:3000]}".lower()
|
||||
if "klarna" in combined:
|
||||
return "klarna_email"
|
||||
if "paypal" in combined or "pay pal" in combined:
|
||||
return "paypal_email"
|
||||
if any(token in combined for token in ("bestellung", "order confirmation", "your order", "deine bestellung")):
|
||||
return "order_confirmation_email"
|
||||
return "imap_email"
|
||||
|
||||
|
||||
def _event_type(source_type: str, subject: str, text: str, due_date: date | None) -> str:
|
||||
combined = f"{subject}\n{text[:3000]}".lower()
|
||||
if any(token in combined for token in ("refund", "erstattung", "rückerstattung")):
|
||||
return "refund"
|
||||
if source_type == "klarna_email":
|
||||
return "promised_payment"
|
||||
if source_type == "paypal_email" and (
|
||||
due_date or any(token in combined for token in ("pay later", "später bezahlen", "fällig", "due"))
|
||||
):
|
||||
return "promised_payment"
|
||||
return "expense"
|
||||
|
||||
|
||||
def _event_status(
|
||||
source_type: str,
|
||||
event_type: str,
|
||||
due_date: date | None,
|
||||
subject: str,
|
||||
text: str,
|
||||
) -> str:
|
||||
del subject, text
|
||||
if source_type == "klarna_email" or event_type == "promised_payment":
|
||||
return "outstanding" if due_date else "announced"
|
||||
return "announced"
|
||||
|
||||
|
||||
def _merchant(sender: str, subject: str, text: str, source_type: str) -> str | None:
|
||||
subject_merchant = _merchant_from_subject(subject)
|
||||
if subject_merchant:
|
||||
return subject_merchant
|
||||
text_merchant = _merchant_from_text(text)
|
||||
if text_merchant:
|
||||
return text_merchant
|
||||
addresses = getaddresses([sender])
|
||||
display_name = addresses[0][0] if addresses else ""
|
||||
candidate = _clean_party_name(display_name or sender)
|
||||
if source_type == "klarna_email" and not candidate:
|
||||
return "Klarna"
|
||||
if source_type == "paypal_email" and not candidate:
|
||||
return "PayPal"
|
||||
return candidate or None
|
||||
|
||||
|
||||
def _merchant_from_subject(subject: str) -> str | None:
|
||||
patterns = (
|
||||
r"\b(?:deine|ihre)\s+bestellung\s+(?:bei|von)\s+(.+)$",
|
||||
r"\byour\s+order\s+(?:from|with|at)\s+(.+)$",
|
||||
r"\b(?:zahlung|payment)\s+(?:an|to)\s+(.+)$",
|
||||
r"\b(?:rechnung|invoice)\s+(?:von|from)\s+(.+)$",
|
||||
)
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, subject, re.IGNORECASE)
|
||||
if match:
|
||||
return _clean_party_name(match.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def _merchant_from_text(text: str) -> str | None:
|
||||
patterns = (
|
||||
r"\b(?:händler|merchant|seller|shop)\s*:?\s*([^\n]{2,80})",
|
||||
r"\b(?:zahlung\s+an|payment\s+to)\s*:?\s*([^\n]{2,80})",
|
||||
)
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, text, re.IGNORECASE)
|
||||
if match:
|
||||
return _clean_party_name(match.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def _clean_party_name(value: str) -> str:
|
||||
cleaned = re.sub(r"<[^>]+>", "", value)
|
||||
cleaned = re.split(r"\s[-|:]\s", cleaned, maxsplit=1)[0]
|
||||
cleaned = re.sub(r"\s+", " ", cleaned).strip(" .,:;\"'")
|
||||
return cleaned[:120]
|
||||
|
||||
|
||||
def _fallback_source_reference(sender: str, subject: str, received_date: date) -> str:
|
||||
return f"imap:{received_date.isoformat()}:{sender}:{subject}"[:240]
|
||||
|
||||
|
||||
def _parse_folder_response(raw: bytes | str) -> dict[str, Any]:
|
||||
text = raw.decode("utf-8", errors="replace") if isinstance(raw, bytes) else raw
|
||||
match = re.match(r"\((?P<attrs>.*?)\)\s+\"?(?P<delimiter>.*?)\"?\s+(?P<name>.+)$", text)
|
||||
if not match:
|
||||
return {"name": text, "delimiter": None, "attributes": []}
|
||||
name = match.group("name").strip()
|
||||
if name.startswith('"') and name.endswith('"'):
|
||||
name = name[1:-1].replace('\\"', '"').replace("\\\\", "\\")
|
||||
attrs = [item.strip("\\") for item in match.group("attrs").split() if item.strip()]
|
||||
delimiter = match.group("delimiter") or None
|
||||
return {"name": name, "delimiter": delimiter, "attributes": attrs}
|
||||
459
apps/local-agent/mousehold_agent/paypal_connector.py
Normal file
459
apps/local-agent/mousehold_agent/paypal_connector.py
Normal file
@@ -0,0 +1,459 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, date, datetime, time
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any, Literal
|
||||
|
||||
PAYPAL_LIVE_BASE_URL = "https://api-m.paypal.com"
|
||||
PAYPAL_SANDBOX_BASE_URL = "https://api-m.sandbox.paypal.com"
|
||||
PAYPAL_REPORTING_MAX_DAYS = 31
|
||||
PAYPAL_TRANSACTION_SEARCH_SCOPE = "https://uri.paypal.com/services/reporting/search/read"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PayPalConfig:
|
||||
client_id: str
|
||||
client_secret: str
|
||||
environment: Literal["live", "sandbox"] = "live"
|
||||
base_url: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PayPalFetchResult:
|
||||
start_date: date
|
||||
end_date: date
|
||||
transactions: list[dict[str, Any]]
|
||||
observations: list[dict[str, Any]]
|
||||
raw_response_count: int
|
||||
|
||||
|
||||
class PayPalAPIError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def inspect_paypal_credentials(config: PayPalConfig) -> dict[str, Any]:
|
||||
return _token_payload_to_auth_check(config, fetch_access_token_payload(config))
|
||||
|
||||
|
||||
def _token_payload_to_auth_check(config: PayPalConfig, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
scope = payload.get("scope")
|
||||
scopes = [item for item in re.split(r"[\s,]+", scope.strip()) if item] if isinstance(scope, str) else []
|
||||
return {
|
||||
"environment": config.environment,
|
||||
"base_url": _base_url(config),
|
||||
"app_id": _string_or_none(payload.get("app_id")),
|
||||
"token_type": _string_or_none(payload.get("token_type")),
|
||||
"expires_in": _int_or_none(payload.get("expires_in")),
|
||||
"scope_text": scope if isinstance(scope, str) else None,
|
||||
"scopes": scopes,
|
||||
"required_scope": PAYPAL_TRANSACTION_SEARCH_SCOPE,
|
||||
"has_transaction_search": PAYPAL_TRANSACTION_SEARCH_SCOPE in scopes,
|
||||
}
|
||||
|
||||
|
||||
def fetch_paypal_transactions(
|
||||
config: PayPalConfig,
|
||||
*,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
household_id: str | None = None,
|
||||
source_account_id: str | None = None,
|
||||
owner_user_id: str | None = None,
|
||||
transaction_currency: str | None = None,
|
||||
page_size: int = 100,
|
||||
max_pages: int = 10,
|
||||
) -> PayPalFetchResult:
|
||||
_validate_date_range(start_date, end_date)
|
||||
token = fetch_access_token(config)
|
||||
details = list_transaction_details(
|
||||
config,
|
||||
token=token,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
transaction_currency=transaction_currency,
|
||||
page_size=page_size,
|
||||
max_pages=max_pages,
|
||||
)
|
||||
observations = [
|
||||
paypal_transaction_to_observation(
|
||||
transaction,
|
||||
household_id=household_id,
|
||||
source_account_id=source_account_id,
|
||||
owner_user_id=owner_user_id,
|
||||
)
|
||||
for transaction in details
|
||||
if _transaction_amount(transaction) is not None
|
||||
]
|
||||
return PayPalFetchResult(
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
transactions=[paypal_transaction_to_preview(transaction) for transaction in details],
|
||||
observations=observations,
|
||||
raw_response_count=len(details),
|
||||
)
|
||||
|
||||
|
||||
def fetch_access_token(config: PayPalConfig) -> str:
|
||||
payload = fetch_access_token_payload(config)
|
||||
access_token = payload.get("access_token")
|
||||
if not isinstance(access_token, str) or not access_token:
|
||||
raise PayPalAPIError("PayPal token response did not include an access token.")
|
||||
scope = payload.get("scope")
|
||||
if isinstance(scope, str) and PAYPAL_TRANSACTION_SEARCH_SCOPE not in scope.split():
|
||||
raise PayPalAPIError(
|
||||
"PayPal credentials do not include Transaction Search permission. "
|
||||
f"Enable Transaction Search on the PayPal REST app and verify the OAuth token includes "
|
||||
f"{PAYPAL_TRANSACTION_SEARCH_SCOPE}."
|
||||
)
|
||||
return access_token
|
||||
|
||||
|
||||
def fetch_access_token_payload(config: PayPalConfig) -> dict[str, Any]:
|
||||
credentials = f"{config.client_id}:{config.client_secret}".encode()
|
||||
body = urllib.parse.urlencode({"grant_type": "client_credentials"}).encode("ascii")
|
||||
request = urllib.request.Request(
|
||||
f"{_base_url(config)}/v1/oauth2/token",
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={
|
||||
"Authorization": f"Basic {base64.b64encode(credentials).decode('ascii')}",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
return _request_json(request)
|
||||
|
||||
|
||||
def list_transaction_details(
|
||||
config: PayPalConfig,
|
||||
*,
|
||||
token: str,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
transaction_currency: str | None = None,
|
||||
page_size: int = 100,
|
||||
max_pages: int = 10,
|
||||
) -> list[dict[str, Any]]:
|
||||
page_size = max(1, min(page_size, 500))
|
||||
max_pages = max(1, max_pages)
|
||||
transactions: list[dict[str, Any]] = []
|
||||
page = 1
|
||||
total_pages: int | None = None
|
||||
|
||||
while page <= max_pages and (total_pages is None or page <= total_pages):
|
||||
params = {
|
||||
"start_date": _paypal_datetime(start_date, end_of_day=False),
|
||||
"end_date": _paypal_datetime(end_date, end_of_day=True),
|
||||
"fields": "all",
|
||||
"page_size": str(page_size),
|
||||
"page": str(page),
|
||||
}
|
||||
if transaction_currency:
|
||||
params["transaction_currency"] = transaction_currency.upper()
|
||||
|
||||
url = f"{_base_url(config)}/v1/reporting/transactions?{urllib.parse.urlencode(params)}"
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
method="GET",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
payload = _request_json(request)
|
||||
page_transactions = payload.get("transaction_details") or []
|
||||
if not isinstance(page_transactions, list):
|
||||
raise PayPalAPIError("PayPal transaction response has an unexpected shape.")
|
||||
transactions.extend(item for item in page_transactions if isinstance(item, dict))
|
||||
|
||||
total_pages = _int_or_none(payload.get("total_pages"))
|
||||
if total_pages is None and len(page_transactions) < page_size:
|
||||
break
|
||||
page += 1
|
||||
|
||||
return transactions
|
||||
|
||||
|
||||
def paypal_transaction_to_preview(transaction: dict[str, Any]) -> dict[str, Any]:
|
||||
info = _transaction_info(transaction)
|
||||
amount = _transaction_amount(transaction)
|
||||
currency = _currency_code(info)
|
||||
event_date = _transaction_date(info)
|
||||
return {
|
||||
"transaction_id": _transaction_id(info),
|
||||
"event_date": event_date.isoformat(),
|
||||
"updated_date": _date_string(info.get("transaction_updated_date")),
|
||||
"event_code": _string_or_none(info.get("transaction_event_code")),
|
||||
"paypal_status": _string_or_none(info.get("transaction_status")),
|
||||
"amount": str(abs(amount)) if amount is not None else None,
|
||||
"currency": currency,
|
||||
"counterparty": _counterparty(transaction),
|
||||
"description": _description(transaction),
|
||||
"event_type": _event_type(info, amount),
|
||||
"status": _event_status(info),
|
||||
}
|
||||
|
||||
|
||||
def paypal_transaction_to_observation(
|
||||
transaction: dict[str, Any],
|
||||
*,
|
||||
household_id: str | None,
|
||||
source_account_id: str | None,
|
||||
owner_user_id: str | None,
|
||||
) -> dict[str, Any]:
|
||||
info = _transaction_info(transaction)
|
||||
amount = _transaction_amount(transaction)
|
||||
if amount is None:
|
||||
raise ValueError("PayPal transaction has no transaction_amount.value")
|
||||
transaction_id = _transaction_id(info)
|
||||
currency = _currency_code(info)
|
||||
event_date = _transaction_date(info)
|
||||
return {
|
||||
"household_id": household_id,
|
||||
"source_type": "paypal_api",
|
||||
"source_account_id": source_account_id,
|
||||
"owner_user_id": owner_user_id,
|
||||
"source_reference": transaction_id,
|
||||
"provider_transaction_id": transaction_id,
|
||||
"raw_payload_json": transaction,
|
||||
"amount": str(abs(amount)),
|
||||
"currency": currency,
|
||||
"event_date": event_date.isoformat(),
|
||||
"booking_date": _date_string(info.get("transaction_updated_date")),
|
||||
"merchant": _counterparty(transaction),
|
||||
"counterparty": _counterparty(transaction),
|
||||
"description": _description(transaction),
|
||||
"event_type": _event_type(info, amount),
|
||||
"status": _event_status(info),
|
||||
"payer_user_id": owner_user_id,
|
||||
"payment_account_id": source_account_id,
|
||||
}
|
||||
|
||||
|
||||
def _request_json(request: urllib.request.Request) -> dict[str, Any]:
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
data = response.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
raise PayPalAPIError(_paypal_error_message(exc.code, detail)) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise PayPalAPIError(f"PayPal request failed: {exc.reason}") from exc
|
||||
|
||||
try:
|
||||
payload = json.loads(data.decode("utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise PayPalAPIError("PayPal returned invalid JSON.") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise PayPalAPIError("PayPal returned an unexpected JSON shape.")
|
||||
return payload
|
||||
|
||||
|
||||
def _paypal_error_message(status_code: int, detail: str) -> str:
|
||||
try:
|
||||
payload = json.loads(detail)
|
||||
except json.JSONDecodeError:
|
||||
return f"PayPal request failed with HTTP {status_code}: {detail[:500]}"
|
||||
message = payload.get("message") or payload.get("error_description") or payload.get("error")
|
||||
name = payload.get("name")
|
||||
if status_code == 403 and (name == "NOT_AUTHORIZED" or "insufficient permissions" in str(message).lower()):
|
||||
return (
|
||||
"PayPal Transaction Search is not authorized for these credentials. "
|
||||
"Enable Transaction Search on the matching live/sandbox REST app, make sure you are using credentials "
|
||||
"for the same environment selected in Mousehold, and verify the OAuth token includes "
|
||||
f"{PAYPAL_TRANSACTION_SEARCH_SCOPE}. Third-party account access requires PayPal partner authorization."
|
||||
)
|
||||
if name and message:
|
||||
return f"PayPal request failed with HTTP {status_code}: {name}: {message}"
|
||||
if message:
|
||||
return f"PayPal request failed with HTTP {status_code}: {message}"
|
||||
return f"PayPal request failed with HTTP {status_code}: {detail[:500]}"
|
||||
|
||||
|
||||
def _base_url(config: PayPalConfig) -> str:
|
||||
if config.base_url:
|
||||
return config.base_url.rstrip("/")
|
||||
return PAYPAL_SANDBOX_BASE_URL if config.environment == "sandbox" else PAYPAL_LIVE_BASE_URL
|
||||
|
||||
|
||||
def _validate_date_range(start_date: date, end_date: date) -> None:
|
||||
if end_date < start_date:
|
||||
raise PayPalAPIError("PayPal end_date must be on or after start_date.")
|
||||
if (end_date - start_date).days + 1 > PAYPAL_REPORTING_MAX_DAYS:
|
||||
raise PayPalAPIError("PayPal Transaction Search supports a maximum date range of 31 days.")
|
||||
|
||||
|
||||
def _paypal_datetime(value: date, *, end_of_day: bool) -> str:
|
||||
wall_time = time(23, 59, 59) if end_of_day else time(0, 0, 0)
|
||||
return datetime.combine(value, wall_time, tzinfo=UTC).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _transaction_info(transaction: dict[str, Any]) -> dict[str, Any]:
|
||||
info = transaction.get("transaction_info") or {}
|
||||
return info if isinstance(info, dict) else {}
|
||||
|
||||
|
||||
def _transaction_amount(transaction: dict[str, Any]) -> Decimal | None:
|
||||
info = _transaction_info(transaction)
|
||||
amount = info.get("transaction_amount") or {}
|
||||
if not isinstance(amount, dict):
|
||||
return None
|
||||
value = amount.get("value")
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return Decimal(str(value)).quantize(Decimal("0.01"))
|
||||
except InvalidOperation:
|
||||
return None
|
||||
|
||||
|
||||
def _currency_code(info: dict[str, Any]) -> str:
|
||||
amount = info.get("transaction_amount") or {}
|
||||
if isinstance(amount, dict):
|
||||
currency = amount.get("currency_code")
|
||||
if isinstance(currency, str) and currency:
|
||||
return currency.upper()
|
||||
return "EUR"
|
||||
|
||||
|
||||
def _transaction_id(info: dict[str, Any]) -> str | None:
|
||||
return _string_or_none(info.get("transaction_id")) or _string_or_none(info.get("paypal_reference_id"))
|
||||
|
||||
|
||||
def _transaction_date(info: dict[str, Any]) -> date:
|
||||
return _parse_paypal_date(info.get("transaction_initiation_date")) or date.today()
|
||||
|
||||
|
||||
def _date_string(value: object) -> str | None:
|
||||
parsed = _parse_paypal_date(value)
|
||||
return parsed.isoformat() if parsed else None
|
||||
|
||||
|
||||
def _parse_paypal_date(value: object) -> date | None:
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
normalized = value.replace("Z", "+00:00")
|
||||
if reworked := _parse_iso_date(normalized):
|
||||
return reworked
|
||||
for fmt in ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S.%f%z"):
|
||||
try:
|
||||
return datetime.strptime(value, fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
return date.fromisoformat(value[:10])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_iso_date(value: str) -> date | None:
|
||||
try:
|
||||
return datetime.fromisoformat(value).date()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _counterparty(transaction: dict[str, Any]) -> str | None:
|
||||
payer_info = transaction.get("payer_info") or {}
|
||||
if isinstance(payer_info, dict):
|
||||
payer_name = payer_info.get("payer_name") or {}
|
||||
if isinstance(payer_name, dict):
|
||||
full_name = _first_non_empty(
|
||||
payer_name.get("alternate_full_name"),
|
||||
" ".join(
|
||||
str(part)
|
||||
for part in (payer_name.get("given_name"), payer_name.get("surname"))
|
||||
if part
|
||||
),
|
||||
)
|
||||
if full_name:
|
||||
return full_name
|
||||
email = _string_or_none(payer_info.get("email_address"))
|
||||
if email:
|
||||
return email
|
||||
|
||||
shipping_info = transaction.get("shipping_info") or {}
|
||||
if isinstance(shipping_info, dict):
|
||||
name = _string_or_none(shipping_info.get("name"))
|
||||
if name:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def _description(transaction: dict[str, Any]) -> str | None:
|
||||
info = _transaction_info(transaction)
|
||||
cart_info = transaction.get("cart_info") or {}
|
||||
if isinstance(cart_info, dict):
|
||||
invoice_number = _string_or_none(cart_info.get("invoice_number"))
|
||||
if invoice_number:
|
||||
return f"PayPal invoice {invoice_number}"
|
||||
item_details = cart_info.get("item_details") or []
|
||||
if isinstance(item_details, list) and item_details:
|
||||
first_item = item_details[0]
|
||||
if isinstance(first_item, dict):
|
||||
item_name = _string_or_none(first_item.get("item_name"))
|
||||
if item_name:
|
||||
return item_name
|
||||
return _first_non_empty(
|
||||
info.get("transaction_subject"),
|
||||
info.get("transaction_note"),
|
||||
info.get("paypal_reference_id"),
|
||||
info.get("transaction_id"),
|
||||
)
|
||||
|
||||
|
||||
def _event_type(info: dict[str, Any], amount: Decimal | None) -> str:
|
||||
description = " ".join(
|
||||
str(value)
|
||||
for value in (
|
||||
info.get("transaction_subject"),
|
||||
info.get("transaction_note"),
|
||||
info.get("transaction_event_code"),
|
||||
)
|
||||
if value
|
||||
).lower()
|
||||
if "fee" in description or "gebühr" in description:
|
||||
return "fee"
|
||||
if "refund" in description or "erstattung" in description or "rückerstattung" in description:
|
||||
return "refund"
|
||||
if amount is None:
|
||||
return "expense"
|
||||
return "expense" if amount < 0 else "income"
|
||||
|
||||
|
||||
def _event_status(info: dict[str, Any]) -> str:
|
||||
status = _string_or_none(info.get("transaction_status"))
|
||||
if status == "P":
|
||||
return "announced"
|
||||
if status == "D":
|
||||
return "cancelled"
|
||||
return "confirmed"
|
||||
|
||||
|
||||
def _string_or_none(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _first_non_empty(*values: object) -> str | None:
|
||||
for value in values:
|
||||
text = _string_or_none(value)
|
||||
if text:
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
def _int_or_none(value: object) -> int | None:
|
||||
try:
|
||||
return int(str(value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
581
apps/local-agent/mousehold_agent/server.py
Normal file
581
apps/local-agent/mousehold_agent/server.py
Normal file
@@ -0,0 +1,581 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import imaplib
|
||||
import os
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
import uvicorn
|
||||
from fastapi import APIRouter, FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fints.client import FinTS3PinTanClient, NeedTANResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .fints_connector import (
|
||||
PRODUCT_ID_HELP,
|
||||
FinTSConfig,
|
||||
FinTSConfigurationError,
|
||||
account_to_dict,
|
||||
balance_to_dict,
|
||||
create_client,
|
||||
persist_client,
|
||||
select_account,
|
||||
tan_challenge_to_dict,
|
||||
transaction_to_observation,
|
||||
)
|
||||
from .imap_importer import DEFAULT_SENDER_FILTERS, IMAPConfig, list_imap_folders, scan_imap_mailbox
|
||||
from .paypal_connector import PayPalAPIError, PayPalConfig, fetch_paypal_transactions, inspect_paypal_credentials
|
||||
from .statement_importer import parse_klarna_statement, parse_paypal_statement
|
||||
|
||||
LOCAL_AGENT_ORIGINS = [
|
||||
"http://127.0.0.1:5173",
|
||||
"http://localhost:5173",
|
||||
]
|
||||
|
||||
|
||||
class FinTSRequest(BaseModel):
|
||||
blz: str
|
||||
server: str
|
||||
bank_user_id: str
|
||||
pin: str
|
||||
customer_id: str | None = None
|
||||
product_id: str | None = None
|
||||
product_version: str | None = None
|
||||
tan_mechanism: str | None = None
|
||||
tan_medium: str | None = None
|
||||
profile: str = "browser"
|
||||
state_dir: str = "~/.config/mousehold/fints"
|
||||
store_state: bool = True
|
||||
|
||||
|
||||
class FinTSAccountsRequest(BaseModel):
|
||||
credentials: FinTSRequest
|
||||
with_balances: bool = False
|
||||
|
||||
|
||||
class FinTSTransactionsRequest(BaseModel):
|
||||
credentials: FinTSRequest
|
||||
iban: str | None = None
|
||||
days: int = Field(default=30, ge=1, le=730)
|
||||
start_date: date | None = None
|
||||
end_date: date | None = None
|
||||
include_pending: bool = False
|
||||
household_id: str | None = None
|
||||
source_account_id: str | None = None
|
||||
owner_user_id: str | None = None
|
||||
|
||||
|
||||
class FinTSChoiceRequest(BaseModel):
|
||||
value: str
|
||||
|
||||
|
||||
class FinTSTanRequest(BaseModel):
|
||||
tan: str = ""
|
||||
|
||||
|
||||
class IMAPCredentials(BaseModel):
|
||||
host: str
|
||||
port: int = Field(default=993, ge=1, le=65535)
|
||||
username: str
|
||||
password: str
|
||||
use_ssl: bool = True
|
||||
|
||||
|
||||
class IMAPFoldersRequest(BaseModel):
|
||||
credentials: IMAPCredentials
|
||||
|
||||
|
||||
class IMAPScanRequest(BaseModel):
|
||||
credentials: IMAPCredentials
|
||||
folder: str = "INBOX"
|
||||
search_query: str = "ALL"
|
||||
sender_filters: list[str] = Field(default_factory=lambda: list(DEFAULT_SENDER_FILTERS))
|
||||
limit: int = Field(default=25, ge=1, le=500)
|
||||
household_id: str
|
||||
source_account_id: str | None = None
|
||||
owner_user_id: str | None = None
|
||||
|
||||
|
||||
class PayPalCredentials(BaseModel):
|
||||
client_id: str
|
||||
client_secret: str
|
||||
environment: Literal["live", "sandbox"] = "live"
|
||||
base_url: str | None = None
|
||||
|
||||
|
||||
class PayPalTransactionsRequest(BaseModel):
|
||||
credentials: PayPalCredentials
|
||||
days: int = Field(default=30, ge=1, le=31)
|
||||
start_date: date | None = None
|
||||
end_date: date | None = None
|
||||
transaction_currency: str | None = None
|
||||
page_size: int = Field(default=100, ge=1, le=500)
|
||||
max_pages: int = Field(default=10, ge=1, le=100)
|
||||
household_id: str
|
||||
source_account_id: str | None = None
|
||||
owner_user_id: str | None = None
|
||||
|
||||
|
||||
class PayPalAuthCheckRequest(BaseModel):
|
||||
credentials: PayPalCredentials
|
||||
|
||||
|
||||
class StatementParseRequest(BaseModel):
|
||||
csv_text: str
|
||||
filename: str | None = None
|
||||
household_id: str
|
||||
source_account_id: str | None = None
|
||||
owner_user_id: str | None = None
|
||||
|
||||
|
||||
JobStatus = Literal["running", "needs_tan", "needs_choice", "done", "error"]
|
||||
|
||||
|
||||
class FinTSBrowserJob:
|
||||
def __init__(self, kind: str) -> None:
|
||||
self.id = str(uuid.uuid4())
|
||||
self.kind = kind
|
||||
self.status: JobStatus = "running"
|
||||
self.error: str | None = None
|
||||
self.result: dict[str, Any] | None = None
|
||||
self.challenge: dict[str, Any] | None = None
|
||||
self.choice_kind: str | None = None
|
||||
self.choice_options: list[dict[str, Any]] = []
|
||||
self._lock = threading.Lock()
|
||||
self._event = threading.Event()
|
||||
self._input_value: str | None = None
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return {
|
||||
"id": self.id,
|
||||
"kind": self.kind,
|
||||
"status": self.status,
|
||||
"error": self.error,
|
||||
"result": self.result,
|
||||
"challenge": self.challenge,
|
||||
"choice_kind": self.choice_kind,
|
||||
"choice_options": self.choice_options,
|
||||
}
|
||||
|
||||
def set_done(self, result: dict[str, Any]) -> None:
|
||||
with self._lock:
|
||||
self.status = "done"
|
||||
self.result = result
|
||||
self.challenge = None
|
||||
self.choice_kind = None
|
||||
self.choice_options = []
|
||||
self._input_value = None
|
||||
self._event.clear()
|
||||
|
||||
def set_error(self, error: BaseException | str) -> None:
|
||||
with self._lock:
|
||||
self.status = "error"
|
||||
self.error = str(error)
|
||||
self.challenge = None
|
||||
self.choice_kind = None
|
||||
self.choice_options = []
|
||||
self._input_value = None
|
||||
self._event.clear()
|
||||
|
||||
def wait_for_tan(self, response: NeedTANResponse, timeout_seconds: int = 600) -> str:
|
||||
with self._lock:
|
||||
self.status = "needs_tan"
|
||||
self.challenge = tan_challenge_to_dict(response)
|
||||
self.choice_kind = None
|
||||
self.choice_options = []
|
||||
self._input_value = None
|
||||
self._event.clear()
|
||||
if not self._event.wait(timeout_seconds):
|
||||
raise TimeoutError("Timed out waiting for TAN input.")
|
||||
with self._lock:
|
||||
value = self._input_value or ""
|
||||
self.status = "running"
|
||||
self.challenge = None
|
||||
self._input_value = None
|
||||
self._event.clear()
|
||||
return value
|
||||
|
||||
def wait_for_choice(
|
||||
self,
|
||||
kind: str,
|
||||
options: list[dict[str, Any]],
|
||||
timeout_seconds: int = 600,
|
||||
) -> str:
|
||||
with self._lock:
|
||||
self.status = "needs_choice"
|
||||
self.choice_kind = kind
|
||||
self.choice_options = options
|
||||
self.challenge = None
|
||||
self._input_value = None
|
||||
self._event.clear()
|
||||
if not self._event.wait(timeout_seconds):
|
||||
raise TimeoutError(f"Timed out waiting for {kind} selection.")
|
||||
with self._lock:
|
||||
value = self._input_value
|
||||
self.status = "running"
|
||||
self.choice_kind = None
|
||||
self.choice_options = []
|
||||
self._input_value = None
|
||||
self._event.clear()
|
||||
if value is None:
|
||||
raise RuntimeError(f"No {kind} selection received.")
|
||||
return value
|
||||
|
||||
def provide_value(self, value: str) -> None:
|
||||
with self._lock:
|
||||
if self.status not in {"needs_tan", "needs_choice"}:
|
||||
raise RuntimeError("Job is not waiting for input.")
|
||||
self._input_value = value
|
||||
self._event.set()
|
||||
|
||||
|
||||
jobs: dict[str, FinTSBrowserJob] = {}
|
||||
|
||||
|
||||
def create_router() -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
@router.post("/fints/accounts/start")
|
||||
def start_accounts(payload: FinTSAccountsRequest) -> dict[str, Any]:
|
||||
job = _start_job("accounts", lambda current_job: _run_accounts(current_job, payload))
|
||||
return job.snapshot()
|
||||
|
||||
@router.post("/fints/transactions/start")
|
||||
def start_transactions(payload: FinTSTransactionsRequest) -> dict[str, Any]:
|
||||
job = _start_job("transactions", lambda current_job: _run_transactions(current_job, payload))
|
||||
return job.snapshot()
|
||||
|
||||
@router.get("/fints/jobs/{job_id}")
|
||||
def get_job(job_id: str) -> dict[str, Any]:
|
||||
return _get_job(job_id).snapshot()
|
||||
|
||||
@router.post("/fints/jobs/{job_id}/tan")
|
||||
def provide_tan(job_id: str, payload: FinTSTanRequest) -> dict[str, Any]:
|
||||
job = _get_job(job_id)
|
||||
if job.snapshot()["status"] != "needs_tan":
|
||||
raise HTTPException(status_code=409, detail="Job is not waiting for a TAN.")
|
||||
try:
|
||||
job.provide_value(payload.tan)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return job.snapshot()
|
||||
|
||||
@router.post("/fints/jobs/{job_id}/choice")
|
||||
def provide_choice(job_id: str, payload: FinTSChoiceRequest) -> dict[str, Any]:
|
||||
job = _get_job(job_id)
|
||||
if job.snapshot()["status"] != "needs_choice":
|
||||
raise HTTPException(status_code=409, detail="Job is not waiting for a choice.")
|
||||
try:
|
||||
job.provide_value(payload.value)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return job.snapshot()
|
||||
|
||||
@router.post("/imap/folders")
|
||||
def imap_folders(payload: IMAPFoldersRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return {"folders": list_imap_folders(_imap_config_from_payload(payload.credentials))}
|
||||
except (OSError, RuntimeError, imaplib.IMAP4.error) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@router.post("/imap/scan")
|
||||
def imap_scan(payload: IMAPScanRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return scan_imap_mailbox(
|
||||
_imap_config_from_payload(payload.credentials),
|
||||
household_id=payload.household_id,
|
||||
owner_user_id=payload.owner_user_id,
|
||||
source_account_id=payload.source_account_id,
|
||||
limit=payload.limit,
|
||||
folder=payload.folder,
|
||||
search_query=payload.search_query,
|
||||
sender_filters=payload.sender_filters,
|
||||
)
|
||||
except (OSError, RuntimeError, imaplib.IMAP4.error) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@router.post("/paypal/transactions")
|
||||
def paypal_transactions(payload: PayPalTransactionsRequest) -> dict[str, Any]:
|
||||
end_date = payload.end_date or date.today()
|
||||
start_date = payload.start_date or (end_date - timedelta(days=payload.days - 1))
|
||||
try:
|
||||
result = fetch_paypal_transactions(
|
||||
_paypal_config_from_payload(payload.credentials),
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
household_id=payload.household_id,
|
||||
owner_user_id=payload.owner_user_id,
|
||||
source_account_id=payload.source_account_id,
|
||||
transaction_currency=payload.transaction_currency,
|
||||
page_size=payload.page_size,
|
||||
max_pages=payload.max_pages,
|
||||
)
|
||||
except PayPalAPIError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {
|
||||
"start_date": result.start_date.isoformat(),
|
||||
"end_date": result.end_date.isoformat(),
|
||||
"transactions": result.transactions,
|
||||
"observations": result.observations,
|
||||
"count": len(result.observations),
|
||||
"raw_response_count": result.raw_response_count,
|
||||
}
|
||||
|
||||
@router.post("/paypal/auth-check")
|
||||
def paypal_auth_check(payload: PayPalAuthCheckRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return inspect_paypal_credentials(_paypal_config_from_payload(payload.credentials))
|
||||
except PayPalAPIError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@router.post("/paypal/statement/parse")
|
||||
def paypal_statement_parse(payload: StatementParseRequest) -> dict[str, Any]:
|
||||
return parse_paypal_statement(
|
||||
payload.csv_text,
|
||||
household_id=payload.household_id,
|
||||
owner_user_id=payload.owner_user_id,
|
||||
source_account_id=payload.source_account_id,
|
||||
filename=payload.filename,
|
||||
)
|
||||
|
||||
@router.post("/klarna/statement/parse")
|
||||
def klarna_statement_parse(payload: StatementParseRequest) -> dict[str, Any]:
|
||||
return parse_klarna_statement(
|
||||
payload.csv_text,
|
||||
household_id=payload.household_id,
|
||||
owner_user_id=payload.owner_user_id,
|
||||
source_account_id=payload.source_account_id,
|
||||
filename=payload.filename,
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(title="Mousehold Local Agent", version="0.1.0")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_cors_origins(),
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.include_router(create_router())
|
||||
return app
|
||||
|
||||
|
||||
def run(host: str = "127.0.0.1", port: int = 8765) -> None:
|
||||
uvicorn.run("mousehold_agent.server:app", host=host, port=port, reload=False)
|
||||
|
||||
|
||||
def _start_job(kind: str, target: Any) -> FinTSBrowserJob:
|
||||
job = FinTSBrowserJob(kind)
|
||||
jobs[job.id] = job
|
||||
|
||||
def wrapped() -> None:
|
||||
try:
|
||||
target(job)
|
||||
except BaseException as exc:
|
||||
job.set_error(exc)
|
||||
|
||||
thread = threading.Thread(target=wrapped, name=f"fints-{kind}-{job.id}", daemon=True)
|
||||
thread.start()
|
||||
return job
|
||||
|
||||
|
||||
def _get_job(job_id: str) -> FinTSBrowserJob:
|
||||
job = jobs.get(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="FinTS job not found")
|
||||
return job
|
||||
|
||||
|
||||
def _run_accounts(job: FinTSBrowserJob, payload: FinTSAccountsRequest) -> None:
|
||||
config = _config_from_payload(payload.credentials)
|
||||
|
||||
def operation(client: FinTS3PinTanClient) -> dict[str, Any]:
|
||||
accounts: list[dict[str, Any]] = []
|
||||
for index, account in enumerate(client.get_sepa_accounts()):
|
||||
row = account_to_dict(account)
|
||||
row["index"] = index
|
||||
if payload.with_balances:
|
||||
balance = _resolve_tan_response(client, client.get_balance(account), job)
|
||||
row["balance"] = balance_to_dict(balance)
|
||||
accounts.append(row)
|
||||
return {"accounts": accounts}
|
||||
|
||||
job.set_done(_with_browser_dialog(config, job, operation))
|
||||
|
||||
|
||||
def _run_transactions(job: FinTSBrowserJob, payload: FinTSTransactionsRequest) -> None:
|
||||
config = _config_from_payload(payload.credentials)
|
||||
end_date = payload.end_date or date.today()
|
||||
start_date = payload.start_date or (end_date - timedelta(days=payload.days))
|
||||
|
||||
def operation(client: FinTS3PinTanClient) -> dict[str, Any]:
|
||||
account = select_account(client.get_sepa_accounts(), payload.iban)
|
||||
transactions = _resolve_tan_response(
|
||||
client,
|
||||
client.get_transactions(
|
||||
account,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
include_pending=payload.include_pending,
|
||||
),
|
||||
job,
|
||||
)
|
||||
observations = [
|
||||
transaction_to_observation(
|
||||
transaction,
|
||||
account=account,
|
||||
household_id=payload.household_id,
|
||||
source_account_id=payload.source_account_id,
|
||||
owner_user_id=payload.owner_user_id,
|
||||
)
|
||||
for transaction in transactions
|
||||
]
|
||||
return {
|
||||
"account": account_to_dict(account),
|
||||
"observations": observations,
|
||||
"count": len(observations),
|
||||
}
|
||||
|
||||
job.set_done(_with_browser_dialog(config, job, operation))
|
||||
|
||||
|
||||
def _with_browser_dialog(config: FinTSConfig, job: FinTSBrowserJob, operation: Any) -> dict[str, Any]:
|
||||
client = create_client(config)
|
||||
_browser_bootstrap(client, config, job)
|
||||
try:
|
||||
with client:
|
||||
while isinstance(client.init_tan_response, NeedTANResponse):
|
||||
client.init_tan_response = _answer_tan(client, client.init_tan_response, job)
|
||||
return operation(client)
|
||||
finally:
|
||||
persist_client(client, config)
|
||||
|
||||
|
||||
def _browser_bootstrap(client: FinTS3PinTanClient, config: FinTSConfig, job: FinTSBrowserJob) -> None:
|
||||
if not client.get_current_tan_mechanism():
|
||||
client.fetch_tan_mechanisms()
|
||||
mechanisms = client.get_tan_mechanisms()
|
||||
if config.tan_mechanism:
|
||||
client.set_tan_mechanism(config.tan_mechanism)
|
||||
elif len(mechanisms) == 1:
|
||||
client.set_tan_mechanism(next(iter(mechanisms)))
|
||||
elif len(mechanisms) > 1:
|
||||
options = [
|
||||
{
|
||||
"value": security_function,
|
||||
"label": getattr(parameters, "name", security_function) or security_function,
|
||||
"description": f"Function {security_function}",
|
||||
}
|
||||
for security_function, parameters in mechanisms.items()
|
||||
]
|
||||
client.set_tan_mechanism(job.wait_for_choice("tan_mechanism", options))
|
||||
|
||||
if client.selected_tan_medium is None and client.is_tan_media_required():
|
||||
usage, media = client.get_tan_media()
|
||||
del usage
|
||||
if config.tan_medium:
|
||||
client.selected_tan_medium = config.tan_medium
|
||||
elif len(media) == 1:
|
||||
client.set_tan_medium(media[0])
|
||||
elif len(media) == 0:
|
||||
client.selected_tan_medium = ""
|
||||
else:
|
||||
options = [
|
||||
{
|
||||
"value": str(index),
|
||||
"label": getattr(item, "tan_medium_name", None) or f"Medium {index + 1}",
|
||||
"description": _tan_medium_description(item),
|
||||
}
|
||||
for index, item in enumerate(media)
|
||||
]
|
||||
client.set_tan_medium(media[int(job.wait_for_choice("tan_medium", options))])
|
||||
|
||||
|
||||
def _resolve_tan_response(client: FinTS3PinTanClient, response: Any, job: FinTSBrowserJob) -> Any:
|
||||
while isinstance(response, NeedTANResponse):
|
||||
response = _answer_tan(client, response, job)
|
||||
return response
|
||||
|
||||
|
||||
def _answer_tan(
|
||||
client: FinTS3PinTanClient,
|
||||
response: NeedTANResponse,
|
||||
job: FinTSBrowserJob,
|
||||
) -> Any:
|
||||
if response.decoupled:
|
||||
tan = job.wait_for_tan(response)
|
||||
del tan
|
||||
return client.send_tan(response, "")
|
||||
return client.send_tan(response, job.wait_for_tan(response))
|
||||
|
||||
|
||||
def _tan_medium_description(item: Any) -> str:
|
||||
parts = []
|
||||
mobile = getattr(item, "mobile_number_masked", None)
|
||||
if mobile:
|
||||
parts.append(str(mobile))
|
||||
last_use = getattr(item, "last_use", None)
|
||||
if last_use:
|
||||
parts.append(f"Last used {last_use}")
|
||||
return " · ".join(parts)
|
||||
|
||||
|
||||
def _config_from_payload(payload: FinTSRequest) -> FinTSConfig:
|
||||
if not payload.product_id:
|
||||
raise FinTSConfigurationError(PRODUCT_ID_HELP)
|
||||
return FinTSConfig(
|
||||
bank_identifier=payload.blz,
|
||||
user_id=payload.bank_user_id,
|
||||
pin=payload.pin,
|
||||
server=payload.server,
|
||||
customer_id=payload.customer_id or None,
|
||||
product_id=payload.product_id,
|
||||
product_version=payload.product_version or None,
|
||||
tan_mechanism=payload.tan_mechanism or None,
|
||||
tan_medium=payload.tan_medium or None,
|
||||
profile=payload.profile,
|
||||
state_dir=Path(payload.state_dir).expanduser(),
|
||||
store_state=payload.store_state,
|
||||
interactive_bootstrap=False,
|
||||
)
|
||||
|
||||
|
||||
def _imap_config_from_payload(payload: IMAPCredentials) -> IMAPConfig:
|
||||
return IMAPConfig(
|
||||
host=payload.host,
|
||||
port=payload.port,
|
||||
username=payload.username,
|
||||
password=payload.password,
|
||||
use_ssl=payload.use_ssl,
|
||||
)
|
||||
|
||||
|
||||
def _paypal_config_from_payload(payload: PayPalCredentials) -> PayPalConfig:
|
||||
return PayPalConfig(
|
||||
client_id=payload.client_id,
|
||||
client_secret=payload.client_secret,
|
||||
environment=payload.environment,
|
||||
base_url=payload.base_url or None,
|
||||
)
|
||||
|
||||
|
||||
def _cors_origins() -> list[str]:
|
||||
configured = os.environ.get("MOUSEHOLD_LOCAL_AGENT_CORS_ORIGINS")
|
||||
if not configured:
|
||||
return LOCAL_AGENT_ORIGINS
|
||||
return [origin.strip() for origin in configured.split(",") if origin.strip()]
|
||||
|
||||
|
||||
app = create_app()
|
||||
487
apps/local-agent/mousehold_agent/statement_importer.py
Normal file
487
apps/local-agent/mousehold_agent/statement_importer.py
Normal file
@@ -0,0 +1,487 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import io
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any, Literal
|
||||
|
||||
StatementKind = Literal["paypal", "klarna"]
|
||||
|
||||
PARSER_VERSION = "0.1.0"
|
||||
|
||||
PAYPAL_FIELD_ALIASES = {
|
||||
"date": ("date", "transaction date", "datum"),
|
||||
"name": ("name", "payer name", "counterparty", "merchant", "händler", "haendler"),
|
||||
"type": ("type", "transaction type", "typ"),
|
||||
"status": ("status", "transaction status"),
|
||||
"currency": ("currency", "currency code", "währung", "waehrung"),
|
||||
"gross": ("gross", "amount", "transaction amount", "betrag", "brutto"),
|
||||
"fee": ("fee", "fee amount", "gebühr", "gebuehr"),
|
||||
"net": ("net", "net amount", "netto"),
|
||||
"transaction_id": ("transaction id", "transaction_id", "transaktionscode", "transaktions-id"),
|
||||
"reference_id": ("reference txn id", "reference transaction id", "referenztransaktionscode"),
|
||||
"invoice": ("invoice number", "invoice id", "rechnungsnummer"),
|
||||
"item": ("item title", "item name", "description", "beschreibung", "betreff"),
|
||||
"from_email": ("from email address", "from email", "sender email"),
|
||||
"to_email": ("to email address", "to email", "recipient email"),
|
||||
}
|
||||
|
||||
KLARNA_FIELD_ALIASES = {
|
||||
"date": ("capture_date", "sale_date", "payout_date", "date", "datum", "transaction_date"),
|
||||
"type": ("type", "transaction type", "typ"),
|
||||
"detailed_type": ("detailed_type", "detail type", "details", "beschreibung"),
|
||||
"amount": ("amount", "betrag", "transaction_amount", "original_capture_amount"),
|
||||
"currency": (
|
||||
"posting_currency",
|
||||
"settlement_currency",
|
||||
"currency",
|
||||
"währung",
|
||||
"waehrung",
|
||||
"original_capture_currency",
|
||||
),
|
||||
"order_id": ("order_id", "order id", "bestellnummer", "bestellung", "short_order_id"),
|
||||
"capture_id": ("capture_id", "capture id"),
|
||||
"refund_id": ("refund_id", "refund id"),
|
||||
"payment_reference": ("payment_reference", "payment reference", "zahlungsreferenz", "reference", "referenz"),
|
||||
"merchant": (
|
||||
"merchant_reference1",
|
||||
"merchant_reference2",
|
||||
"terminal_name",
|
||||
"merchant",
|
||||
"händler",
|
||||
"haendler",
|
||||
"shop",
|
||||
),
|
||||
"description": ("description", "details", "detailed_type", "type", "beschreibung", "verwendungszweck"),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedStatement:
|
||||
source: StatementKind
|
||||
filename: str | None
|
||||
row_count: int
|
||||
importable_count: int
|
||||
skipped_count: int
|
||||
rows: list[dict[str, Any]]
|
||||
observations: list[dict[str, Any]]
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"source": self.source,
|
||||
"filename": self.filename,
|
||||
"row_count": self.row_count,
|
||||
"importable_count": self.importable_count,
|
||||
"skipped_count": self.skipped_count,
|
||||
"rows": self.rows,
|
||||
"observations": self.observations,
|
||||
}
|
||||
|
||||
|
||||
def parse_paypal_statement(
|
||||
csv_text: str,
|
||||
*,
|
||||
household_id: str,
|
||||
owner_user_id: str | None,
|
||||
source_account_id: str | None,
|
||||
filename: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
rows = _dict_rows(csv_text)
|
||||
previews: list[dict[str, Any]] = []
|
||||
observations: list[dict[str, Any]] = []
|
||||
for index, row in enumerate(rows, start=1):
|
||||
preview, observation = _parse_paypal_row(
|
||||
row,
|
||||
index=index,
|
||||
household_id=household_id,
|
||||
owner_user_id=owner_user_id,
|
||||
source_account_id=source_account_id,
|
||||
filename=filename,
|
||||
)
|
||||
previews.append(preview)
|
||||
if observation:
|
||||
observations.append(observation)
|
||||
return ParsedStatement(
|
||||
source="paypal",
|
||||
filename=filename,
|
||||
row_count=len(rows),
|
||||
importable_count=len(observations),
|
||||
skipped_count=len(previews) - len(observations),
|
||||
rows=previews,
|
||||
observations=observations,
|
||||
).as_dict()
|
||||
|
||||
|
||||
def parse_klarna_statement(
|
||||
csv_text: str,
|
||||
*,
|
||||
household_id: str,
|
||||
owner_user_id: str | None,
|
||||
source_account_id: str | None,
|
||||
filename: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
rows = _klarna_transaction_rows(csv_text)
|
||||
previews: list[dict[str, Any]] = []
|
||||
observations: list[dict[str, Any]] = []
|
||||
for index, row in enumerate(rows, start=1):
|
||||
preview, observation = _parse_klarna_row(
|
||||
row,
|
||||
index=index,
|
||||
household_id=household_id,
|
||||
owner_user_id=owner_user_id,
|
||||
source_account_id=source_account_id,
|
||||
filename=filename,
|
||||
)
|
||||
previews.append(preview)
|
||||
if observation:
|
||||
observations.append(observation)
|
||||
return ParsedStatement(
|
||||
source="klarna",
|
||||
filename=filename,
|
||||
row_count=len(rows),
|
||||
importable_count=len(observations),
|
||||
skipped_count=len(previews) - len(observations),
|
||||
rows=previews,
|
||||
observations=observations,
|
||||
).as_dict()
|
||||
|
||||
|
||||
def _parse_paypal_row(
|
||||
row: dict[str, str],
|
||||
*,
|
||||
index: int,
|
||||
household_id: str,
|
||||
owner_user_id: str | None,
|
||||
source_account_id: str | None,
|
||||
filename: str | None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
||||
amount = _parse_amount(_pick(row, PAYPAL_FIELD_ALIASES["net"]) or _pick(row, PAYPAL_FIELD_ALIASES["gross"]))
|
||||
event_date = _parse_date(_pick(row, PAYPAL_FIELD_ALIASES["date"]))
|
||||
currency = _pick(row, PAYPAL_FIELD_ALIASES["currency"]) or "EUR"
|
||||
raw_type = _pick(row, PAYPAL_FIELD_ALIASES["type"]) or ""
|
||||
status_text = _pick(row, PAYPAL_FIELD_ALIASES["status"]) or ""
|
||||
transaction_id = _pick(row, PAYPAL_FIELD_ALIASES["transaction_id"])
|
||||
reference_id = _pick(row, PAYPAL_FIELD_ALIASES["reference_id"])
|
||||
source_reference = transaction_id or reference_id or _row_reference("paypal", row, index)
|
||||
merchant = _pick(row, PAYPAL_FIELD_ALIASES["name"]) or _pick(row, PAYPAL_FIELD_ALIASES["from_email"])
|
||||
description = _first_non_empty(
|
||||
_pick(row, PAYPAL_FIELD_ALIASES["item"]),
|
||||
_pick(row, PAYPAL_FIELD_ALIASES["invoice"]),
|
||||
raw_type,
|
||||
merchant,
|
||||
)
|
||||
|
||||
preview = _preview(
|
||||
row=index,
|
||||
source_type="paypal_csv",
|
||||
source_reference=source_reference,
|
||||
event_date=event_date,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
merchant=merchant,
|
||||
description=description,
|
||||
raw_type=raw_type,
|
||||
event_type=_paypal_event_type(raw_type, description, amount),
|
||||
status=_paypal_status(status_text),
|
||||
)
|
||||
if amount is None:
|
||||
preview["skip_reason"] = "No amount found"
|
||||
return preview, None
|
||||
if event_date is None:
|
||||
preview["skip_reason"] = "No date found"
|
||||
return preview, None
|
||||
|
||||
observation = {
|
||||
"household_id": household_id,
|
||||
"source_type": "paypal_csv",
|
||||
"source_account_id": source_account_id,
|
||||
"owner_user_id": owner_user_id,
|
||||
"source_reference": source_reference,
|
||||
"provider_transaction_id": transaction_id,
|
||||
"raw_payload_json": {
|
||||
"filename": filename,
|
||||
"row": row,
|
||||
"parser_name": "mousehold-paypal-statement",
|
||||
"parser_version": PARSER_VERSION,
|
||||
},
|
||||
"amount": str(abs(amount)),
|
||||
"currency": currency.upper(),
|
||||
"event_date": event_date.isoformat(),
|
||||
"booking_date": event_date.isoformat(),
|
||||
"merchant": merchant,
|
||||
"counterparty": merchant,
|
||||
"description": description,
|
||||
"event_type": preview["event_type"],
|
||||
"status": preview["status"],
|
||||
"payer_user_id": owner_user_id,
|
||||
"payment_account_id": source_account_id,
|
||||
}
|
||||
return preview, observation
|
||||
|
||||
|
||||
def _parse_klarna_row(
|
||||
row: dict[str, str],
|
||||
*,
|
||||
index: int,
|
||||
household_id: str,
|
||||
owner_user_id: str | None,
|
||||
source_account_id: str | None,
|
||||
filename: str | None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
||||
amount = _parse_amount(_pick(row, KLARNA_FIELD_ALIASES["amount"]))
|
||||
event_date = _parse_date(_pick(row, KLARNA_FIELD_ALIASES["date"]))
|
||||
currency = _pick(row, KLARNA_FIELD_ALIASES["currency"]) or "EUR"
|
||||
row_type = _pick(row, KLARNA_FIELD_ALIASES["type"]) or ""
|
||||
detailed_type = _pick(row, KLARNA_FIELD_ALIASES["detailed_type"]) or ""
|
||||
raw_type = " / ".join(part for part in (row_type, detailed_type) if part)
|
||||
source_reference = _first_non_empty(
|
||||
_pick(row, KLARNA_FIELD_ALIASES["capture_id"]),
|
||||
_pick(row, KLARNA_FIELD_ALIASES["refund_id"]),
|
||||
_pick(row, KLARNA_FIELD_ALIASES["order_id"]),
|
||||
_pick(row, KLARNA_FIELD_ALIASES["payment_reference"]),
|
||||
_row_reference("klarna", row, index),
|
||||
)
|
||||
merchant = _pick(row, KLARNA_FIELD_ALIASES["merchant"]) or "Klarna"
|
||||
description = _first_non_empty(_pick(row, KLARNA_FIELD_ALIASES["description"]), raw_type, merchant)
|
||||
event_type = _klarna_event_type(row_type, detailed_type, description, amount)
|
||||
status = (
|
||||
"outstanding"
|
||||
if row_type.upper() == "CHARGE" and "PAYMENT_REMINDER" in detailed_type.upper()
|
||||
else "confirmed"
|
||||
)
|
||||
|
||||
preview = _preview(
|
||||
row=index,
|
||||
source_type="klarna_csv",
|
||||
source_reference=source_reference,
|
||||
event_date=event_date,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
merchant=merchant,
|
||||
description=description,
|
||||
raw_type=raw_type,
|
||||
event_type=event_type,
|
||||
status=status,
|
||||
)
|
||||
if amount is None:
|
||||
preview["skip_reason"] = "No amount found"
|
||||
return preview, None
|
||||
if event_date is None:
|
||||
preview["skip_reason"] = "No date found"
|
||||
return preview, None
|
||||
|
||||
observation = {
|
||||
"household_id": household_id,
|
||||
"source_type": "klarna_csv",
|
||||
"source_account_id": source_account_id,
|
||||
"owner_user_id": owner_user_id,
|
||||
"source_reference": source_reference,
|
||||
"provider_transaction_id": source_reference,
|
||||
"raw_payload_json": {
|
||||
"filename": filename,
|
||||
"row": row,
|
||||
"parser_name": "mousehold-klarna-statement",
|
||||
"parser_version": PARSER_VERSION,
|
||||
},
|
||||
"amount": str(abs(amount)),
|
||||
"currency": currency.upper(),
|
||||
"event_date": event_date.isoformat(),
|
||||
"booking_date": event_date.isoformat(),
|
||||
"merchant": merchant,
|
||||
"counterparty": "Klarna",
|
||||
"description": description,
|
||||
"event_type": event_type,
|
||||
"status": status,
|
||||
"payer_user_id": owner_user_id,
|
||||
"payment_account_id": source_account_id,
|
||||
}
|
||||
return preview, observation
|
||||
|
||||
|
||||
def _dict_rows(csv_text: str) -> list[dict[str, str]]:
|
||||
text = csv_text.lstrip("\ufeff")
|
||||
dialect = _sniff_dialect(text)
|
||||
reader = csv.DictReader(io.StringIO(text), dialect=dialect)
|
||||
return [
|
||||
{_normalize_header(key): (value or "").strip() for key, value in row.items() if key}
|
||||
for row in reader
|
||||
if any((value or "").strip() for value in row.values())
|
||||
]
|
||||
|
||||
|
||||
def _klarna_transaction_rows(csv_text: str) -> list[dict[str, str]]:
|
||||
text = csv_text.lstrip("\ufeff")
|
||||
dialect = _sniff_dialect(text)
|
||||
reader = csv.reader(io.StringIO(text), dialect=dialect)
|
||||
header: list[str] | None = None
|
||||
rows: list[dict[str, str]] = []
|
||||
for raw_row in reader:
|
||||
cells = [cell.strip() for cell in raw_row]
|
||||
normalized = [_normalize_header(cell) for cell in cells]
|
||||
if "type" in normalized and "amount" in normalized:
|
||||
header = normalized
|
||||
continue
|
||||
if header is None or not any(cells):
|
||||
continue
|
||||
row = {
|
||||
key: cells[position].strip()
|
||||
for position, key in enumerate(header)
|
||||
if key and position < len(cells)
|
||||
}
|
||||
if _pick(row, KLARNA_FIELD_ALIASES["type"]) or _pick(row, KLARNA_FIELD_ALIASES["amount"]):
|
||||
rows.append(row)
|
||||
if rows:
|
||||
return rows
|
||||
return _dict_rows(text)
|
||||
|
||||
|
||||
def _sniff_dialect(csv_text: str) -> csv.Dialect:
|
||||
sample = csv_text[:4096]
|
||||
try:
|
||||
return csv.Sniffer().sniff(sample, delimiters=",;\t")
|
||||
except csv.Error:
|
||||
class Fallback(csv.excel):
|
||||
delimiter = ";"
|
||||
|
||||
return Fallback
|
||||
|
||||
|
||||
def _pick(row: dict[str, str], aliases: tuple[str, ...]) -> str | None:
|
||||
for alias in aliases:
|
||||
value = row.get(_normalize_header(alias))
|
||||
if value:
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _parse_amount(value: str | None) -> Decimal | None:
|
||||
if not value:
|
||||
return None
|
||||
cleaned = value.strip().replace("\xa0", " ")
|
||||
cleaned = re.sub(r"[^0-9,.\-+() ]", "", cleaned)
|
||||
negative = cleaned.startswith("(") and cleaned.endswith(")")
|
||||
cleaned = cleaned.strip("() ").replace(" ", "")
|
||||
if not cleaned:
|
||||
return None
|
||||
if "," in cleaned and "." in cleaned:
|
||||
if cleaned.rfind(",") > cleaned.rfind("."):
|
||||
cleaned = cleaned.replace(".", "").replace(",", ".")
|
||||
else:
|
||||
cleaned = cleaned.replace(",", "")
|
||||
elif "," in cleaned:
|
||||
cleaned = cleaned.replace(".", "").replace(",", ".")
|
||||
try:
|
||||
amount = Decimal(cleaned).quantize(Decimal("0.01"))
|
||||
except InvalidOperation:
|
||||
return None
|
||||
return -amount if negative else amount
|
||||
|
||||
|
||||
def _parse_date(value: str | None) -> date | None:
|
||||
if not value:
|
||||
return None
|
||||
stripped = value.strip()
|
||||
if "T" in stripped:
|
||||
try:
|
||||
return datetime.fromisoformat(stripped.replace("Z", "+00:00")).date()
|
||||
except ValueError:
|
||||
pass
|
||||
for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y", "%m/%d/%Y", "%d-%m-%Y", "%m-%d-%Y"):
|
||||
try:
|
||||
return datetime.strptime(stripped[:10], fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _paypal_event_type(raw_type: str, description: str | None, amount: Decimal | None) -> str:
|
||||
combined = f"{raw_type} {description or ''}".lower()
|
||||
if "fee" in combined or "gebühr" in combined:
|
||||
return "fee"
|
||||
if "refund" in combined or "erstattung" in combined or "reversal" in combined:
|
||||
return "refund"
|
||||
if "transfer" in combined or "bank account" in combined:
|
||||
return "transfer"
|
||||
if amount is None:
|
||||
return "expense"
|
||||
return "expense" if amount < 0 else "income"
|
||||
|
||||
|
||||
def _paypal_status(value: str) -> str:
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"pending", "processing", "created", "payment pending"}:
|
||||
return "announced"
|
||||
if normalized in {"denied", "canceled", "cancelled", "voided", "expired", "failed"}:
|
||||
return "cancelled"
|
||||
return "confirmed"
|
||||
|
||||
|
||||
def _klarna_event_type(
|
||||
row_type: str,
|
||||
detailed_type: str,
|
||||
description: str | None,
|
||||
amount: Decimal | None,
|
||||
) -> str:
|
||||
combined = f"{row_type} {detailed_type} {description or ''}".lower()
|
||||
if "fee" in combined or "commission" in combined:
|
||||
return "fee"
|
||||
if "return" in combined or "refund" in combined or "credit" in combined or "release" in combined:
|
||||
return "refund"
|
||||
if "payment_reminder" in combined or "liability" in combined or "payment" in combined or "zahlung" in combined:
|
||||
return "liability_payment"
|
||||
if amount is None:
|
||||
return "expense"
|
||||
return "expense" if amount < 0 else "income"
|
||||
|
||||
|
||||
def _preview(
|
||||
*,
|
||||
row: int,
|
||||
source_type: str,
|
||||
source_reference: str | None,
|
||||
event_date: date | None,
|
||||
amount: Decimal | None,
|
||||
currency: str,
|
||||
merchant: str | None,
|
||||
description: str | None,
|
||||
raw_type: str,
|
||||
event_type: str,
|
||||
status: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"row": row,
|
||||
"source_type": source_type,
|
||||
"source_reference": source_reference,
|
||||
"event_date": event_date.isoformat() if event_date else None,
|
||||
"amount": str(abs(amount)) if amount is not None else None,
|
||||
"currency": currency.upper(),
|
||||
"merchant": merchant,
|
||||
"description": description,
|
||||
"raw_type": raw_type,
|
||||
"event_type": event_type,
|
||||
"status": status,
|
||||
"importable": amount is not None and event_date is not None,
|
||||
"skip_reason": None,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_header(value: str) -> str:
|
||||
lowered = value.strip().lower().replace("\ufeff", "")
|
||||
lowered = lowered.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
|
||||
return re.sub(r"[^a-z0-9]+", "_", lowered).strip("_")
|
||||
|
||||
|
||||
def _row_reference(source: str, row: dict[str, str], index: int) -> str:
|
||||
digest = hashlib.sha256(repr(sorted(row.items())).encode()).hexdigest()[:16]
|
||||
return f"{source}-statement-row-{index}-{digest}"
|
||||
|
||||
|
||||
def _first_non_empty(*values: str | None) -> str | None:
|
||||
for value in values:
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
1
apps/web/.env.development
Normal file
1
apps/web/.env.development
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_URL=http://127.0.0.1:8001
|
||||
5
apps/web/.env.example
Normal file
5
apps/web/.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
VITE_API_URL=http://127.0.0.1:8001
|
||||
|
||||
# Optional. Leave unset to use the embedded API route at ${VITE_API_URL}/agent.
|
||||
# Set this only if you run `mousehold-agent serve` as a separate process.
|
||||
VITE_LOCAL_AGENT_URL=http://127.0.0.1:8765
|
||||
12
apps/web/index.html
Normal file
12
apps/web/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Mousehold</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
25
apps/web/package.json
Normal file
25
apps/web/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@mousehold/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "tsc --noEmit",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vite": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0"
|
||||
}
|
||||
}
|
||||
16
apps/web/playwright.config.ts
Normal file
16
apps/web/playwright.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
timeout: 30_000,
|
||||
use: {
|
||||
baseURL: process.env.PLAYWRIGHT_WEB_URL ?? "http://127.0.0.1:5173",
|
||||
trace: "retain-on-failure"
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] }
|
||||
}
|
||||
]
|
||||
});
|
||||
4071
apps/web/src/App.tsx
Normal file
4071
apps/web/src/App.tsx
Normal file
File diff suppressed because it is too large
Load Diff
38
apps/web/src/api.ts
Normal file
38
apps/web/src/api.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? "http://127.0.0.1:8000";
|
||||
const LOCAL_AGENT_BASE = import.meta.env.VITE_LOCAL_AGENT_URL ?? `${API_BASE}/agent`;
|
||||
|
||||
export async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {})
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
throw new Error(detail || response.statusText);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export async function localAgentApi<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${LOCAL_AGENT_BASE}${path}`, {
|
||||
...init,
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {})
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
throw new Error(detail || response.statusText);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export function apiUrl(path: string): string {
|
||||
return `${API_BASE}${path}`;
|
||||
}
|
||||
10
apps/web/src/main.tsx
Normal file
10
apps/web/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
890
apps/web/src/styles.css
Normal file
890
apps/web/src/styles.css
Normal file
@@ -0,0 +1,890 @@
|
||||
:root {
|
||||
color: #1f2528;
|
||||
background: #f5f7f8;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
select,
|
||||
input,
|
||||
textarea {
|
||||
min-height: 38px;
|
||||
border: 1px solid #cfd7dc;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: #1f2528;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 72px;
|
||||
padding-top: 9px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
background: #1f6f68;
|
||||
border-color: #1f6f68;
|
||||
color: #ffffff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
background: #ffffff;
|
||||
color: #1f2528;
|
||||
border-color: #aab6bc;
|
||||
}
|
||||
|
||||
button.inline-button {
|
||||
width: auto;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
button.icon-button {
|
||||
width: 38px;
|
||||
min-width: 38px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: #9a321f;
|
||||
border-color: #9a321f;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
input:disabled,
|
||||
select:disabled,
|
||||
textarea:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 26px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 17px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 15px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 10px 8px;
|
||||
border-bottom: 1px solid #e0e5e8;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
th {
|
||||
color: #607079;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
td span {
|
||||
display: block;
|
||||
color: #68767d;
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 18px 24px;
|
||||
border-bottom: 1px solid #dce3e6;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.topbar span {
|
||||
color: #607079;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.topbar-actions select {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.page-rail {
|
||||
position: sticky;
|
||||
top: 75px;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
padding: 10px 24px;
|
||||
border-bottom: 1px solid #dce3e6;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.page-rail button {
|
||||
min-height: 36px;
|
||||
background: #ffffff;
|
||||
border-color: transparent;
|
||||
color: #3d484d;
|
||||
}
|
||||
|
||||
.page-rail button.active {
|
||||
background: #1f6f68;
|
||||
border-color: #1f6f68;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: 18px 24px 32px;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
padding: 18px 24px 32px;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.setup-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.wide-panel {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.panel,
|
||||
aside.panel > section {
|
||||
background: #ffffff;
|
||||
border: 1px solid #dce3e6;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
aside.panel {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 18px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.grid.two {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.grid.three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(130px, 1fr));
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.form-grid .wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
color: #3d484d;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.field > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select,
|
||||
.field textarea {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.topbar-field {
|
||||
grid-template-columns: auto minmax(220px, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-select-field {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.management-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.connector-item {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.connector-meta {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.management-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, 1fr) minmax(120px, 180px) repeat(3, 38px);
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
padding: 8px;
|
||||
border: 1px solid #e0e5e8;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.management-row.account-row {
|
||||
grid-template-columns:
|
||||
minmax(140px, 1fr) minmax(120px, 1fr) minmax(120px, 160px)
|
||||
minmax(120px, 150px) minmax(120px, 150px) auto 38px 38px;
|
||||
}
|
||||
|
||||
.management-row.connector-row {
|
||||
grid-template-columns:
|
||||
minmax(140px, 1fr) minmax(120px, 150px) minmax(120px, 150px)
|
||||
minmax(110px, 140px) minmax(140px, 1fr) minmax(120px, 1fr)
|
||||
minmax(180px, 1.2fr) auto 38px 38px;
|
||||
}
|
||||
|
||||
.management-row.recurring-row {
|
||||
grid-template-columns:
|
||||
minmax(120px, 1fr) minmax(120px, 1fr) minmax(100px, 120px)
|
||||
minmax(110px, 130px) minmax(130px, 150px) minmax(120px, 160px)
|
||||
minmax(120px, 160px) minmax(120px, 160px) minmax(120px, 140px)
|
||||
minmax(130px, 150px) minmax(110px, 130px) 38px 38px;
|
||||
}
|
||||
|
||||
.management-row.taxonomy-row {
|
||||
grid-template-columns: minmax(130px, 1fr) minmax(140px, 1fr) minmax(150px, 1fr) auto 38px 38px;
|
||||
}
|
||||
|
||||
.management-row.tag-row {
|
||||
grid-template-columns: minmax(140px, 1fr) 38px 38px;
|
||||
}
|
||||
|
||||
.compact-check {
|
||||
min-height: 38px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.form-grid > button,
|
||||
.management-row > button,
|
||||
.form-grid > .checkbox,
|
||||
.management-row > .checkbox {
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.fints-grid {
|
||||
grid-template-columns: repeat(5, minmax(120px, 1fr));
|
||||
}
|
||||
|
||||
.mail-grid {
|
||||
grid-template-columns: repeat(5, minmax(120px, 1fr));
|
||||
}
|
||||
|
||||
.statement-grid {
|
||||
grid-template-columns: repeat(5, minmax(120px, 1fr));
|
||||
}
|
||||
|
||||
.compact-form {
|
||||
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 38px;
|
||||
color: #3d484d;
|
||||
}
|
||||
|
||||
.checkbox input {
|
||||
width: auto;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.summary-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.summary-list span {
|
||||
border: 1px solid #dce3e6;
|
||||
border-radius: 999px;
|
||||
padding: 4px 9px;
|
||||
color: #54646c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.scope-list span {
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.metric-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.wide-metrics {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
margin-top: 14px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.metric {
|
||||
min-height: 74px;
|
||||
border: 1px solid #dce3e6;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.metric span {
|
||||
display: block;
|
||||
color: #607079;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.metric strong {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 58px;
|
||||
padding: 10px;
|
||||
border: 1px solid #dce3e6;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.row-card span {
|
||||
display: block;
|
||||
max-width: 42ch;
|
||||
overflow: hidden;
|
||||
color: #607079;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reconcile-list {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.review-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.review-card {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) minmax(300px, 0.75fr);
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
padding: 12px;
|
||||
border: 1px solid #dce3e6;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.review-main {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.review-select {
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.review-main span,
|
||||
.linked-event span,
|
||||
.linked-event small,
|
||||
.muted-note {
|
||||
display: block;
|
||||
color: #607079;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.linked-event {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #e0e5e8;
|
||||
}
|
||||
|
||||
.review-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.review-edit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(110px, 1fr));
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #e0e5e8;
|
||||
}
|
||||
|
||||
.review-edit-grid .wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.review-edit-grid > button {
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.review-link-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.review-filters {
|
||||
grid-template-columns: auto minmax(180px, 240px) auto;
|
||||
}
|
||||
|
||||
.review-batch-actions {
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.import-run-detail {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #dce3e6;
|
||||
}
|
||||
|
||||
.reconcile-card {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.reconcile-main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.reconcile-event {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.reconcile-event span,
|
||||
.reconcile-actions span,
|
||||
.reconcile-event small {
|
||||
display: block;
|
||||
max-width: none;
|
||||
overflow: visible;
|
||||
color: #68767d;
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.reconcile-event strong {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.reconcile-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(90px, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 26px;
|
||||
border-radius: 999px;
|
||||
padding: 0 9px;
|
||||
background: #edf1f2;
|
||||
color: #425158;
|
||||
font-size: 12px;
|
||||
text-transform: capitalize;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status.confirmed,
|
||||
.status.booked,
|
||||
.status.settled,
|
||||
.status.candidate_created {
|
||||
background: #e3f4ec;
|
||||
color: #176342;
|
||||
}
|
||||
|
||||
.status.outstanding,
|
||||
.status.announced {
|
||||
background: #fff2d8;
|
||||
color: #7a4d00;
|
||||
}
|
||||
|
||||
.status.duplicate_source,
|
||||
.status.error {
|
||||
background: #ffe7e3;
|
||||
color: #9a321f;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.selected-row td {
|
||||
background: #eef6f5;
|
||||
}
|
||||
|
||||
.compact-table {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
min-height: 34px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-title h2 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section-title select {
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.table-filters {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(160px, 260px) minmax(150px, 190px);
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.event-detail {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #dce3e6;
|
||||
}
|
||||
|
||||
.event-edit-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.event-detail .section-title span {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: #607079;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tag-picker {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
min-height: 38px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.inspector-row {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inspector-section {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inline-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.detail-grid section {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail-card span {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.fints-prompt {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(180px, 260px) auto;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
margin-top: 14px;
|
||||
padding: 12px;
|
||||
border: 1px solid #cfd7dc;
|
||||
border-radius: 8px;
|
||||
background: #f8fafb;
|
||||
}
|
||||
|
||||
.challenge strong,
|
||||
.challenge span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.challenge span {
|
||||
color: #3d484d;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.challenge img {
|
||||
display: block;
|
||||
max-width: 220px;
|
||||
max-height: 220px;
|
||||
margin-top: 10px;
|
||||
border: 1px solid #cfd7dc;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 16px 24px 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #f0b5aa;
|
||||
border-radius: 8px;
|
||||
background: #fff0ed;
|
||||
color: #8d2f1e;
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.layout,
|
||||
.grid.two,
|
||||
.grid.three,
|
||||
.setup-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.wide-panel {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.management-row,
|
||||
.management-row.account-row,
|
||||
.management-row.connector-row,
|
||||
.management-row.recurring-row,
|
||||
.management-row.taxonomy-row,
|
||||
.management-row.tag-row,
|
||||
.detail-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.review-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.review-edit-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.topbar {
|
||||
position: static;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar-actions select {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-rail {
|
||||
position: static;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.page,
|
||||
.layout {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.form-grid,
|
||||
.fints-grid,
|
||||
.mail-grid,
|
||||
.statement-grid,
|
||||
.metric-row,
|
||||
.wide-metrics,
|
||||
.management-row,
|
||||
.management-row.account-row,
|
||||
.management-row.connector-row,
|
||||
.management-row.recurring-row,
|
||||
.management-row.taxonomy-row,
|
||||
.management-row.tag-row,
|
||||
.table-filters,
|
||||
.reconcile-main,
|
||||
.reconcile-actions,
|
||||
.review-card,
|
||||
.review-actions,
|
||||
.review-edit-grid,
|
||||
.review-filters,
|
||||
.detail-grid,
|
||||
.compact-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.fints-prompt {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-grid .wide {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.review-edit-grid .wide {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
}
|
||||
405
apps/web/src/types.ts
Normal file
405
apps/web/src/types.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
export type Household = {
|
||||
id: string;
|
||||
name: string;
|
||||
default_currency: string;
|
||||
};
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
household_id: string;
|
||||
display_name: string;
|
||||
email: string | null;
|
||||
};
|
||||
|
||||
export type Account = {
|
||||
id: string;
|
||||
household_id: string;
|
||||
owner_user_id: string | null;
|
||||
name: string;
|
||||
account_type: string;
|
||||
institution_name: string | null;
|
||||
currency: string;
|
||||
visibility_mode: string;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
export type Observation = {
|
||||
id: string;
|
||||
household_id: string;
|
||||
source_type: string;
|
||||
raw_hash: string;
|
||||
status: string;
|
||||
source_reference: string | null;
|
||||
owner_user_id?: string | null;
|
||||
source_account_id?: string | null;
|
||||
observed_at?: string | null;
|
||||
received_at?: string;
|
||||
merchant?: string | null;
|
||||
parsed_json: Record<string, string | null> | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type SplitLine = {
|
||||
id: string;
|
||||
event_id: string;
|
||||
user_id: string;
|
||||
share_amount: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type Event = {
|
||||
id: string;
|
||||
household_id: string;
|
||||
source_account_id: string | null;
|
||||
payment_account_id: string | null;
|
||||
payer_user_id: string | null;
|
||||
event_type: string;
|
||||
status: string;
|
||||
event_date: string;
|
||||
booking_date: string | null;
|
||||
due_date: string | null;
|
||||
amount: string;
|
||||
currency: string;
|
||||
merchant: string | null;
|
||||
counterparty: string | null;
|
||||
description: string | null;
|
||||
category_id: string | null;
|
||||
project_id: string | null;
|
||||
split_lines: SplitLine[];
|
||||
tag_ids: string[];
|
||||
};
|
||||
|
||||
export type Category = {
|
||||
id: string;
|
||||
household_id: string;
|
||||
parent_id: string | null;
|
||||
name: string;
|
||||
path: string;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
export type Tag = {
|
||||
id: string;
|
||||
household_id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type EventRelation = {
|
||||
id: string;
|
||||
from_event_id: string;
|
||||
to_event_id: string;
|
||||
relation_type: string;
|
||||
confidence: string;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type EventObservationLink = {
|
||||
id: string;
|
||||
event_id: string;
|
||||
observation_id: string;
|
||||
relation: string;
|
||||
confidence: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type EventDetail = {
|
||||
event: Event;
|
||||
category: Category | null;
|
||||
tags: Tag[];
|
||||
observation_links: EventObservationLink[];
|
||||
observations: Observation[];
|
||||
relations: EventRelation[];
|
||||
related_events: Event[];
|
||||
};
|
||||
|
||||
export type AuthSession = {
|
||||
session: {
|
||||
id: string;
|
||||
user_id: string;
|
||||
household_id: string;
|
||||
created_at: string;
|
||||
last_seen_at: string;
|
||||
expires_at: string;
|
||||
};
|
||||
user: User;
|
||||
household: Household;
|
||||
};
|
||||
|
||||
export type ObservationReviewItem = {
|
||||
observation: Observation;
|
||||
events: Event[];
|
||||
action_required: boolean;
|
||||
suggested_action: string;
|
||||
};
|
||||
|
||||
export type SettlementSummary = {
|
||||
household_id: string;
|
||||
project_id: string | null;
|
||||
currency: string;
|
||||
included_event_ids: string[];
|
||||
excluded_event_ids: string[];
|
||||
balances: Array<{ user_id: string; paid: string; share: string; net: string }>;
|
||||
suggested_transfers: Array<{ from_user_id: string; to_user_id: string; amount: string; currency: string }>;
|
||||
explanation: string[];
|
||||
};
|
||||
|
||||
export type Project = {
|
||||
id: string;
|
||||
household_id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
currency: string;
|
||||
};
|
||||
|
||||
export type ProjectSummary = {
|
||||
project_id: string;
|
||||
currency: string;
|
||||
budgeted_total: string;
|
||||
committed_total: string;
|
||||
paid_total: string;
|
||||
reconciled_total: string;
|
||||
settled_total: string;
|
||||
due_later_total: string;
|
||||
remaining_uncommitted_budget: string;
|
||||
open_actions: string[];
|
||||
};
|
||||
|
||||
export type Connection = {
|
||||
id: string;
|
||||
household_id: string;
|
||||
owner_user_id: string;
|
||||
connection_type: string;
|
||||
display_name: string;
|
||||
status: string;
|
||||
last_sync_at: string | null;
|
||||
last_error: string | null;
|
||||
secret_storage_ref: string | null;
|
||||
runs_locally: boolean;
|
||||
connector_config_json: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ImportRun = {
|
||||
id: string;
|
||||
household_id: string;
|
||||
connection_id: string | null;
|
||||
actor_user_id: string | null;
|
||||
source_type: string | null;
|
||||
status: string;
|
||||
imported_count: number;
|
||||
duplicate_count: number;
|
||||
created_event_count: number;
|
||||
skipped_count: number;
|
||||
error: string | null;
|
||||
summary_json: Record<string, unknown> | null;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
};
|
||||
|
||||
export type ImportRunDetail = {
|
||||
run: ImportRun;
|
||||
observations: Observation[];
|
||||
events: Event[];
|
||||
};
|
||||
|
||||
export type ObservationBatchResponse = {
|
||||
updated_count: number;
|
||||
items: ObservationReviewItem[];
|
||||
};
|
||||
|
||||
export type FinTSAccount = {
|
||||
index: number;
|
||||
iban: string | null;
|
||||
bic: string | null;
|
||||
accountnumber: string | null;
|
||||
blz: string | null;
|
||||
balance?: {
|
||||
amount: string;
|
||||
currency: string | null;
|
||||
date: string;
|
||||
status: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type FinTSJob = {
|
||||
id: string;
|
||||
kind: string;
|
||||
status: "running" | "needs_tan" | "needs_choice" | "done" | "error";
|
||||
error: string | null;
|
||||
result: {
|
||||
accounts?: FinTSAccount[];
|
||||
observations?: Array<Record<string, unknown>>;
|
||||
count?: number;
|
||||
} | null;
|
||||
challenge: {
|
||||
challenge: string;
|
||||
challenge_html: string;
|
||||
challenge_hhduc: string | null;
|
||||
challenge_matrix: { mime_type: string; data_base64: string } | null;
|
||||
decoupled: boolean;
|
||||
} | null;
|
||||
choice_kind: string | null;
|
||||
choice_options: Array<{ value: string; label: string; description?: string }>;
|
||||
};
|
||||
|
||||
export type IMAPFolder = {
|
||||
name: string;
|
||||
delimiter: string | null;
|
||||
attributes: string[];
|
||||
};
|
||||
|
||||
export type IMAPPreviewMessage = {
|
||||
message_id: string | null;
|
||||
source_reference: string;
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
date: string;
|
||||
folder: string;
|
||||
source_type: string;
|
||||
event_type: string;
|
||||
status: string;
|
||||
amount: string | null;
|
||||
currency: string | null;
|
||||
merchant: string | null;
|
||||
due_date: string | null;
|
||||
references: Record<string, string>;
|
||||
importable: boolean;
|
||||
skip_reason: string | null;
|
||||
};
|
||||
|
||||
export type IMAPScanResult = {
|
||||
folder: string;
|
||||
scanned_count: number;
|
||||
filtered_count: number;
|
||||
matched_count: number;
|
||||
importable_count: number;
|
||||
messages: IMAPPreviewMessage[];
|
||||
observations: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export type StatementPreviewRow = {
|
||||
row: number;
|
||||
source_type: string;
|
||||
source_reference: string | null;
|
||||
event_date: string | null;
|
||||
amount: string | null;
|
||||
currency: string;
|
||||
merchant: string | null;
|
||||
description: string | null;
|
||||
raw_type: string;
|
||||
event_type: string;
|
||||
status: string;
|
||||
importable: boolean;
|
||||
skip_reason: string | null;
|
||||
};
|
||||
|
||||
export type StatementParseResult = {
|
||||
source: "paypal" | "klarna";
|
||||
filename: string | null;
|
||||
row_count: number;
|
||||
importable_count: number;
|
||||
skipped_count: number;
|
||||
rows: StatementPreviewRow[];
|
||||
observations: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export type ReconciliationEventSummary = {
|
||||
id: string;
|
||||
source_type: string | null;
|
||||
event_type: string;
|
||||
status: string;
|
||||
event_date: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
merchant: string | null;
|
||||
counterparty: string | null;
|
||||
description: string | null;
|
||||
source_reference: string | null;
|
||||
provider_transaction_id: string | null;
|
||||
observation_id: string | null;
|
||||
};
|
||||
|
||||
export type ReconciliationSuggestion = {
|
||||
id: string;
|
||||
kind: string;
|
||||
relation_type: string;
|
||||
primary_event: ReconciliationEventSummary;
|
||||
confirming_event: ReconciliationEventSummary;
|
||||
confidence: string;
|
||||
reasons: string[];
|
||||
action: string;
|
||||
};
|
||||
|
||||
export type ReconciliationAcceptResponse = {
|
||||
accepted: ReconciliationSuggestion;
|
||||
};
|
||||
|
||||
export type ReconciliationDecision = {
|
||||
id: string;
|
||||
household_id: string;
|
||||
suggestion_id: string;
|
||||
decision: string;
|
||||
kind: string;
|
||||
relation_type: string;
|
||||
primary_event_id: string;
|
||||
confirming_event_id: string;
|
||||
confidence: string;
|
||||
actor_user_id: string | null;
|
||||
reason: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ReconciliationIgnoreResponse = {
|
||||
ignored: ReconciliationSuggestion;
|
||||
decision: ReconciliationDecision;
|
||||
};
|
||||
|
||||
export type SyncObservationsResponse = {
|
||||
imported_observation_ids: string[];
|
||||
created_event_ids: string[];
|
||||
duplicate_observation_ids: string[];
|
||||
connection_id: string | null;
|
||||
imported_count: number;
|
||||
duplicate_count: number;
|
||||
created_event_count: number;
|
||||
run: ImportRun | null;
|
||||
};
|
||||
|
||||
export type BackupResult = {
|
||||
file_path: string;
|
||||
size_bytes: number;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type HouseholdExport = {
|
||||
household: Record<string, unknown>;
|
||||
exported_at: string;
|
||||
counts: Record<string, number>;
|
||||
data: Record<string, Array<Record<string, unknown>>>;
|
||||
};
|
||||
|
||||
export type RecurringCommitment = {
|
||||
id: string;
|
||||
household_id: string;
|
||||
provider: string;
|
||||
description: string | null;
|
||||
amount_expected: string;
|
||||
amount_min: string | null;
|
||||
amount_max: string | null;
|
||||
currency: string;
|
||||
interval: string;
|
||||
next_due_date: string;
|
||||
payer_account_id: string | null;
|
||||
category_id: string | null;
|
||||
project_id: string | null;
|
||||
notice_period: string | null;
|
||||
renewal_date: string | null;
|
||||
status: string;
|
||||
matching_rule_json: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
1
apps/web/src/vite-env.d.ts
vendored
Normal file
1
apps/web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
102
apps/web/tests/mvp.spec.ts
Normal file
102
apps/web/tests/mvp.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { expect, test, type APIRequestContext, type APIResponse } from "@playwright/test";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
function envFileValue(key: string): string | undefined {
|
||||
try {
|
||||
const lines = readFileSync(".env.development", "utf8").split(/\r?\n/);
|
||||
const prefix = `${key}=`;
|
||||
const line = lines.find((item) => item.startsWith(prefix));
|
||||
return line?.slice(prefix.length).trim().replace(/^["']|["']$/g, "");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const apiBase =
|
||||
process.env.PLAYWRIGHT_API_URL ??
|
||||
process.env.VITE_API_URL ??
|
||||
envFileValue("VITE_API_URL") ??
|
||||
"http://127.0.0.1:8000";
|
||||
|
||||
async function expectOk(response: APIResponse): Promise<void> {
|
||||
expect(response.ok(), await response.text()).toBeTruthy();
|
||||
}
|
||||
|
||||
async function postJson<T>(
|
||||
request: APIRequestContext,
|
||||
path: string,
|
||||
data: Record<string, unknown>
|
||||
): Promise<T> {
|
||||
const response = await request.post(`${apiBase}${path}`, { data });
|
||||
await expectOk(response);
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
test("local MVP ledger flow", async ({ page, request }) => {
|
||||
test.setTimeout(60_000);
|
||||
const suffix = Date.now().toString(36);
|
||||
const household = await postJson<{ id: string; name: string }>(request, "/households", {
|
||||
name: `Smoke household ${suffix}`,
|
||||
default_currency: "EUR"
|
||||
});
|
||||
const userA = await postJson<{ id: string; display_name: string }>(request, "/users", {
|
||||
household_id: household.id,
|
||||
display_name: `Alice ${suffix}`,
|
||||
email: `alice-${suffix}@example.test`
|
||||
});
|
||||
await postJson<{ id: string }>(request, "/users", {
|
||||
household_id: household.id,
|
||||
display_name: `Bob ${suffix}`,
|
||||
email: `bob-${suffix}@example.test`
|
||||
});
|
||||
const account = await postJson<{ id: string; name: string }>(request, "/accounts", {
|
||||
household_id: household.id,
|
||||
owner_user_id: userA.id,
|
||||
name: `Alice checking ${suffix}`,
|
||||
account_type: "checking",
|
||||
institution_name: "Smoke Bank",
|
||||
currency: "EUR",
|
||||
visibility_mode: "shared_full",
|
||||
is_active: true
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto("/");
|
||||
const householdSelect = page.getByLabel("Household");
|
||||
await expect(householdSelect).toContainText(household.name);
|
||||
await householdSelect.selectOption({ value: household.id });
|
||||
const sessionSelect = page.getByLabel("Signed in");
|
||||
await expect(sessionSelect).toBeEnabled();
|
||||
await expect(sessionSelect).toContainText(userA.display_name);
|
||||
await sessionSelect.selectOption({ value: userA.id });
|
||||
await expect(page.getByRole("button", { name: "Logout" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Entry" }).click();
|
||||
const manualEntry = page.locator("section.panel").filter({ has: page.getByRole("heading", { name: "Entry" }) });
|
||||
await manualEntry.getByLabel("Amount", { exact: true }).fill("42.50");
|
||||
await manualEntry.getByLabel("Merchant", { exact: true }).fill(`Smoke Shop ${suffix}`);
|
||||
await manualEntry.getByLabel("Description", { exact: true }).fill("Smoke groceries");
|
||||
await manualEntry.locator('select[name="payer_user_id"]').selectOption({ label: userA.display_name });
|
||||
await manualEntry.locator('select[name="source_account_id"]').selectOption({ label: account.name });
|
||||
await manualEntry.getByRole("button", { name: "Observation" }).click();
|
||||
|
||||
await page.getByRole("button", { name: "Inbox" }).click();
|
||||
const reviewCard = page.locator(".review-card").filter({ hasText: `Smoke Shop ${suffix}` }).first();
|
||||
await expect(reviewCard).toBeVisible();
|
||||
await reviewCard.getByRole("button", { name: "Confirm" }).click();
|
||||
|
||||
await page.getByRole("button", { name: "Ledger" }).click();
|
||||
const ledgerRow = page.locator("tbody tr").filter({ hasText: `Smoke Shop ${suffix}` }).first();
|
||||
await expect(ledgerRow).toBeVisible();
|
||||
await expect(ledgerRow.getByText("confirmed")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Setup" }).click();
|
||||
await expect(page.getByRole("button", { name: "Export JSON" })).toBeEnabled();
|
||||
await expect(page.getByRole("button", { name: "SQLite backup" })).toBeEnabled();
|
||||
} finally {
|
||||
const cleanup = await request.delete(`${apiBase}/households/${household.id}`);
|
||||
if (!cleanup.ok() && cleanup.status() !== 404) {
|
||||
console.warn(await cleanup.text());
|
||||
}
|
||||
}
|
||||
});
|
||||
21
apps/web/tsconfig.json
Normal file
21
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": []
|
||||
}
|
||||
9
apps/web/tsconfig.node.json
Normal file
9
apps/web/tsconfig.node.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
10
apps/web/vite.config.ts
Normal file
10
apps/web/vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: false
|
||||
}
|
||||
});
|
||||
1500
continuation.md
Normal file
1500
continuation.md
Normal file
File diff suppressed because it is too large
Load Diff
323
db/migrations/0001_initial.sql
Normal file
323
db/migrations/0001_initial.sql
Normal file
@@ -0,0 +1,323 @@
|
||||
-- M0: Local household ledger skeleton.
|
||||
-- SQLite-compatible baseline schema. The SQLAlchemy models are the runtime source for dev
|
||||
-- create_all(); this file documents the first relational shape for migration tooling.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS households (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
default_currency TEXT NOT NULL DEFAULT 'EUR',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
household_id TEXT NOT NULL REFERENCES households(id),
|
||||
display_name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS login_identities (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
provider TEXT NOT NULL,
|
||||
provider_subject TEXT NOT NULL,
|
||||
email TEXT,
|
||||
display_name TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(provider, provider_subject)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS local_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
household_id TEXT NOT NULL REFERENCES households(id),
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
revoked_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_local_session_token
|
||||
ON local_sessions(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS ix_local_session_user
|
||||
ON local_sessions(user_id, expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
household_id TEXT NOT NULL REFERENCES households(id),
|
||||
owner_user_id TEXT REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
account_type TEXT NOT NULL,
|
||||
institution_name TEXT,
|
||||
currency TEXT NOT NULL DEFAULT 'EUR',
|
||||
visibility_mode TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS raw_observations (
|
||||
id TEXT PRIMARY KEY,
|
||||
household_id TEXT NOT NULL REFERENCES households(id),
|
||||
source_type TEXT NOT NULL,
|
||||
source_account_id TEXT REFERENCES accounts(id),
|
||||
owner_user_id TEXT REFERENCES users(id),
|
||||
observed_at TEXT,
|
||||
received_at TEXT NOT NULL,
|
||||
raw_hash TEXT NOT NULL,
|
||||
source_reference TEXT,
|
||||
provider_transaction_id TEXT,
|
||||
message_id TEXT,
|
||||
raw_text TEXT,
|
||||
raw_file_path TEXT,
|
||||
raw_payload_json TEXT,
|
||||
parsed_json TEXT,
|
||||
parser_name TEXT,
|
||||
parser_version TEXT,
|
||||
confidence NUMERIC(14, 2) NOT NULL DEFAULT 1.00,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_observation_dedupe
|
||||
ON raw_observations(household_id, source_type, raw_hash);
|
||||
CREATE INDEX IF NOT EXISTS ix_observation_external_refs
|
||||
ON raw_observations(provider_transaction_id, message_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
household_id TEXT NOT NULL REFERENCES households(id),
|
||||
parent_id TEXT REFERENCES categories(id),
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
household_id TEXT NOT NULL REFERENCES households(id),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
start_date TEXT,
|
||||
end_date TEXT,
|
||||
default_split_rule_id TEXT,
|
||||
status TEXT NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'EUR',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS financial_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
household_id TEXT NOT NULL REFERENCES households(id),
|
||||
source_account_id TEXT REFERENCES accounts(id),
|
||||
payment_account_id TEXT REFERENCES accounts(id),
|
||||
payer_user_id TEXT REFERENCES users(id),
|
||||
event_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
event_date TEXT NOT NULL,
|
||||
booking_date TEXT,
|
||||
due_date TEXT,
|
||||
amount NUMERIC(14, 2) NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'EUR',
|
||||
merchant TEXT,
|
||||
counterparty TEXT,
|
||||
description TEXT,
|
||||
category_id TEXT REFERENCES categories(id),
|
||||
project_id TEXT REFERENCES projects(id),
|
||||
created_by_user_id TEXT REFERENCES users(id),
|
||||
fingerprint TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_events_household_status_date
|
||||
ON financial_events(household_id, status, event_date);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_observation_links (
|
||||
id TEXT PRIMARY KEY,
|
||||
event_id TEXT NOT NULL REFERENCES financial_events(id),
|
||||
observation_id TEXT NOT NULL REFERENCES raw_observations(id),
|
||||
relation TEXT NOT NULL,
|
||||
confidence NUMERIC(14, 2) NOT NULL DEFAULT 1.00,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(event_id, observation_id, relation)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_relations (
|
||||
id TEXT PRIMARY KEY,
|
||||
from_event_id TEXT NOT NULL REFERENCES financial_events(id),
|
||||
to_event_id TEXT NOT NULL REFERENCES financial_events(id),
|
||||
relation_type TEXT NOT NULL,
|
||||
confidence NUMERIC(14, 2) NOT NULL DEFAULT 1.00,
|
||||
created_by TEXT NOT NULL DEFAULT 'system',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS split_lines (
|
||||
id TEXT PRIMARY KEY,
|
||||
event_id TEXT NOT NULL REFERENCES financial_events(id),
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
share_amount NUMERIC(14, 2) NOT NULL,
|
||||
share_percent NUMERIC(14, 2),
|
||||
reason TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id TEXT PRIMARY KEY,
|
||||
household_id TEXT NOT NULL REFERENCES households(id),
|
||||
name TEXT NOT NULL,
|
||||
UNIQUE(household_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_tags (
|
||||
event_id TEXT NOT NULL REFERENCES financial_events(id),
|
||||
tag_id TEXT NOT NULL REFERENCES tags(id),
|
||||
PRIMARY KEY(event_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_participants (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
role TEXT NOT NULL,
|
||||
default_share_percent NUMERIC(14, 2),
|
||||
UNIQUE(project_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_budget_lines (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
category_id TEXT REFERENCES categories(id),
|
||||
name TEXT NOT NULL,
|
||||
budgeted_amount NUMERIC(14, 2) NOT NULL DEFAULT 0.00,
|
||||
committed_amount_cached NUMERIC(14, 2),
|
||||
paid_amount_cached NUMERIC(14, 2),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
notes TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_planned_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
budget_line_id TEXT REFERENCES project_budget_lines(id),
|
||||
description TEXT NOT NULL,
|
||||
estimated_amount NUMERIC(14, 2) NOT NULL DEFAULT 0.00,
|
||||
committed_amount NUMERIC(14, 2),
|
||||
expected_date TEXT,
|
||||
due_date TEXT,
|
||||
expected_payer_account_id TEXT REFERENCES accounts(id),
|
||||
split_rule_id TEXT,
|
||||
planning_status TEXT NOT NULL,
|
||||
payment_status TEXT NOT NULL,
|
||||
reconciliation_status TEXT NOT NULL,
|
||||
settlement_status TEXT NOT NULL,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payment_lines (
|
||||
id TEXT PRIMARY KEY,
|
||||
planned_item_id TEXT REFERENCES project_planned_items(id),
|
||||
event_id TEXT REFERENCES financial_events(id),
|
||||
amount NUMERIC(14, 2) NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'EUR',
|
||||
date TEXT NOT NULL,
|
||||
paid_by_user_id TEXT REFERENCES users(id),
|
||||
payment_account_id TEXT REFERENCES accounts(id),
|
||||
source_observation_id TEXT REFERENCES raw_observations(id),
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recurring_commitments (
|
||||
id TEXT PRIMARY KEY,
|
||||
household_id TEXT NOT NULL REFERENCES households(id),
|
||||
provider TEXT NOT NULL,
|
||||
description TEXT,
|
||||
amount_expected NUMERIC(14, 2) NOT NULL,
|
||||
amount_min NUMERIC(14, 2),
|
||||
amount_max NUMERIC(14, 2),
|
||||
currency TEXT NOT NULL DEFAULT 'EUR',
|
||||
interval TEXT NOT NULL,
|
||||
next_due_date TEXT NOT NULL,
|
||||
payer_account_id TEXT REFERENCES accounts(id),
|
||||
split_rule_id TEXT,
|
||||
category_id TEXT REFERENCES categories(id),
|
||||
project_id TEXT REFERENCES projects(id),
|
||||
notice_period TEXT,
|
||||
renewal_date TEXT,
|
||||
status TEXT NOT NULL,
|
||||
matching_rule_json TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS promised_payments (
|
||||
id TEXT PRIMARY KEY,
|
||||
household_id TEXT NOT NULL REFERENCES households(id),
|
||||
related_event_id TEXT REFERENCES financial_events(id),
|
||||
provider TEXT NOT NULL,
|
||||
amount NUMERIC(14, 2) NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'EUR',
|
||||
created_date TEXT NOT NULL,
|
||||
due_date TEXT NOT NULL,
|
||||
expected_funding_account_id TEXT REFERENCES accounts(id),
|
||||
responsible_user_id TEXT REFERENCES users(id),
|
||||
status TEXT NOT NULL,
|
||||
source_observation_id TEXT REFERENCES raw_observations(id),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS import_connections (
|
||||
id TEXT PRIMARY KEY,
|
||||
household_id TEXT NOT NULL REFERENCES households(id),
|
||||
owner_user_id TEXT NOT NULL REFERENCES users(id),
|
||||
connection_type TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
last_sync_at TEXT,
|
||||
last_error TEXT,
|
||||
secret_storage_ref TEXT,
|
||||
runs_locally INTEGER NOT NULL DEFAULT 1,
|
||||
connector_config_json TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS import_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
household_id TEXT NOT NULL REFERENCES households(id),
|
||||
connection_id TEXT REFERENCES import_connections(id),
|
||||
actor_user_id TEXT REFERENCES users(id),
|
||||
source_type TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'running',
|
||||
imported_count INTEGER NOT NULL DEFAULT 0,
|
||||
duplicate_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_event_count INTEGER NOT NULL DEFAULT 0,
|
||||
skipped_count INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT,
|
||||
summary_json TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_import_runs_household_started
|
||||
ON import_runs(household_id, started_at);
|
||||
CREATE INDEX IF NOT EXISTS ix_import_runs_connection_started
|
||||
ON import_runs(connection_id, started_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
household_id TEXT NOT NULL REFERENCES households(id),
|
||||
actor_user_id TEXT REFERENCES users(id),
|
||||
action TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT,
|
||||
details_json TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
91
docs/connectors.md
Normal file
91
docs/connectors.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Connector Boundary
|
||||
|
||||
Mousehold treats connectors as evidence producers. A connector submits `RawObservation`
|
||||
payloads; the backend turns them into reviewable `FinancialEvent` candidates and links
|
||||
the source evidence.
|
||||
|
||||
## Local-First Rule
|
||||
|
||||
Bank and mailbox credentials should stay in the local agent where possible.
|
||||
The API stores:
|
||||
|
||||
- connection type and display name
|
||||
- owner user and household
|
||||
- sync status and last error
|
||||
- a local secret reference, not the secret itself
|
||||
- normalized observations
|
||||
|
||||
## Current PoC Routes
|
||||
|
||||
- Manual entry: `POST /observations/manual`
|
||||
- Generic normalized import: `POST /observations/import`
|
||||
- Local-agent batch sync: `POST /observations/sync`
|
||||
- Connector metadata: `POST /connections`
|
||||
- Local-agent registration: `POST /local-agent/register`
|
||||
- Browser IMAP folder listing: `POST /agent/imap/folders`
|
||||
- Browser IMAP scan: `POST /agent/imap/scan`
|
||||
- PayPal CSV statement parsing: `POST /agent/paypal/statement/parse`
|
||||
- Klarna CSV statement parsing: `POST /agent/klarna/statement/parse`
|
||||
|
||||
## Data Source Mapping
|
||||
|
||||
- Bank CSV, FinTS, PSD2: confirmed cash movement observations. FinTS fetching is implemented in the local agent.
|
||||
- PayPal CSV: confirmed PayPal account observations.
|
||||
- PayPal email: announced or outstanding purchase/payment evidence.
|
||||
- Klarna email: usually outstanding obligation evidence.
|
||||
- Order confirmation email: announced purchase evidence.
|
||||
|
||||
Bank debits that fund PayPal, Klarna, or cards should be linked as settlements or
|
||||
liability payments instead of duplicated as new expenses.
|
||||
|
||||
## Reconciliation
|
||||
|
||||
The reconciliation service computes reviewable suggestions from imported events
|
||||
instead of linking automatically. Current suggestions cover:
|
||||
|
||||
- PayPal/Klarna/order email evidence matched to PayPal or Klarna statement rows
|
||||
- PayPal/Klarna statement rows matched to FinTS, bank CSV, or PSD2 cash movement
|
||||
- exact amount/currency matches with date windows, party overlap, provider names,
|
||||
and shared references
|
||||
|
||||
Accepting a suggestion creates an event relation, attaches the confirming
|
||||
observation to the primary event as evidence, and upgrades the primary event to
|
||||
`confirmed` when the confirming event is confirmed. The confirming event is kept
|
||||
for auditability; later UI cleanup can decide how aggressively to collapse or
|
||||
hide duplicate-looking rows.
|
||||
|
||||
## IMAP Flow
|
||||
|
||||
The web app has a `Mail` panel for IMAP proof-of-concept imports. It sends IMAP
|
||||
credentials to the configured agent route for that request only, selects the
|
||||
requested folder read-only, scans recent messages, and returns previews plus
|
||||
normalized observations. The backend stores observations only after the user
|
||||
clicks import.
|
||||
|
||||
The parser currently recognizes EUR amounts, simple due-date phrases, Klarna
|
||||
references, PayPal transaction IDs, order numbers, and invoice numbers. Email
|
||||
evidence is classified as `announced` unless it is a Klarna or due-date driven
|
||||
promised payment, which becomes `outstanding`. Confirmed cash movement should
|
||||
still come from FinTS, bank CSV, PSD2, or PayPal/Klarna statement CSV.
|
||||
|
||||
## PayPal Statement Flow
|
||||
|
||||
The active PayPal path is statement and email based.
|
||||
|
||||
The `Statements` panel supports PayPal Activity Download CSV exports. It
|
||||
recognizes fields such as `Date`, `Name`, `Type`, `Status`, `Currency`, `Gross`,
|
||||
`Fee`, `Net`, and `Transaction ID`, then imports confirmed `paypal_csv`
|
||||
observations.
|
||||
|
||||
PayPal emails remain early evidence for announced or outstanding obligations.
|
||||
Later PayPal statements and bank debits should confirm or settle those events
|
||||
instead of creating duplicate household expenses.
|
||||
|
||||
## Klarna Statement Flow
|
||||
|
||||
The `Statements` panel also supports Klarna CSV. It handles Klarna Merchant
|
||||
settlement reports with a summary section followed by a `type`/`amount`
|
||||
transaction table, and a simpler consumer-style CSV with date, description,
|
||||
amount, currency, reference, and type columns. Klarna CSV observations use
|
||||
`source_type=klarna_csv` and are treated as confirmed statement evidence, while
|
||||
Klarna emails remain the early outstanding/announced signal.
|
||||
133
docs/fints.md
Normal file
133
docs/fints.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# FinTS Connector
|
||||
|
||||
The FinTS connector can run embedded in the main API or in the separate local
|
||||
agent. Embedded mode is the default for local/self-hosted installs, so users do
|
||||
not need to start a second process.
|
||||
|
||||
The privacy tradeoff is explicit:
|
||||
|
||||
- Embedded mode: the API process receives PIN/TAN inputs while a FinTS job runs.
|
||||
- Separate local-agent mode: PIN/TAN inputs stay in the separate local process.
|
||||
|
||||
In both modes, PINs are not stored in the database. Normalized observations are
|
||||
stored only when you import them.
|
||||
|
||||
## Required Inputs
|
||||
|
||||
- `--blz` or `MOUSEHOLD_FINTS_BLZ`: bank identifier/BLZ.
|
||||
- `--server` or `MOUSEHOLD_FINTS_SERVER`: FinTS endpoint URL.
|
||||
- `--bank-user-id` or `MOUSEHOLD_FINTS_USER_ID`: your bank login ID.
|
||||
- PIN: prompted interactively by default, or provided through `MOUSEHOLD_FINTS_PIN`.
|
||||
- `--product-id` or `MOUSEHOLD_FINTS_PRODUCT_ID`: required by python-fints 4+.
|
||||
Register a product ID with Deutsche Kreditwirtschaft/ZKA before connecting.
|
||||
- Optional `--customer-id`, `--tan-medium`, and `--product-version`.
|
||||
|
||||
## comdirect
|
||||
|
||||
The web UI has a `comdirect` preset.
|
||||
|
||||
Current values:
|
||||
|
||||
```text
|
||||
BLZ: 20041111
|
||||
FinTS endpoint: https://fints.comdirect.de/fints
|
||||
Bank user ID: your comdirect Zugangsnummer
|
||||
Customer ID: leave empty
|
||||
```
|
||||
|
||||
If your account uses a different comdirect/Commerzbank GF comdirect BLZ, replace
|
||||
the preset BLZ before fetching accounts.
|
||||
|
||||
## Local State
|
||||
|
||||
The connector stores non-PIN client state in:
|
||||
|
||||
```text
|
||||
~/.config/mousehold/fints/<profile>.json
|
||||
```
|
||||
|
||||
This caches bank parameter data, selected TAN mechanism, and selected TAN
|
||||
medium so repeated syncs do not ask the same setup questions. The file is
|
||||
written with mode `0600`.
|
||||
|
||||
Use `--no-state` for a one-off session without reading or writing that cache.
|
||||
|
||||
## Commands
|
||||
|
||||
The browser UI uses the embedded API route by default:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8001/agent/fints/...
|
||||
```
|
||||
|
||||
To use a separate local-agent HTTP bridge instead:
|
||||
|
||||
```sh
|
||||
mousehold-agent serve --host 127.0.0.1 --port 8765
|
||||
```
|
||||
|
||||
Then set `VITE_LOCAL_AGENT_URL=http://127.0.0.1:8765` in
|
||||
`apps/web/.env.development`.
|
||||
|
||||
List accounts:
|
||||
|
||||
```sh
|
||||
mousehold-agent fints-accounts \
|
||||
--profile my-bank \
|
||||
--blz <bank-blz> \
|
||||
--server <fints-endpoint-url> \
|
||||
--bank-user-id <bank-login-id> \
|
||||
--with-balances
|
||||
```
|
||||
|
||||
Display transactions:
|
||||
|
||||
```sh
|
||||
mousehold-agent fints-transactions \
|
||||
--profile my-bank \
|
||||
--blz <bank-blz> \
|
||||
--server <fints-endpoint-url> \
|
||||
--bank-user-id <bank-login-id> \
|
||||
--iban <iban> \
|
||||
--start-date 2026-06-01 \
|
||||
--end-date 2026-06-27
|
||||
```
|
||||
|
||||
Sync transactions into Mousehold:
|
||||
|
||||
```sh
|
||||
mousehold-agent sync-fints \
|
||||
--api-url http://127.0.0.1:8001 \
|
||||
--connection-id <connection-id> \
|
||||
--household-id <household-id> \
|
||||
--owner-user-id <user-id> \
|
||||
--source-account-id <account-id> \
|
||||
--profile my-bank \
|
||||
--blz <bank-blz> \
|
||||
--server <fints-endpoint-url> \
|
||||
--bank-user-id <bank-login-id> \
|
||||
--iban <iban> \
|
||||
--days 30 \
|
||||
--display
|
||||
```
|
||||
|
||||
## TAN Handling
|
||||
|
||||
If the bank returns a TAN challenge, the CLI prints the challenge text and asks
|
||||
for the TAN. Decoupled app confirmation flows prompt you to confirm in the
|
||||
banking app and press Enter. Binary challenge images are written under:
|
||||
|
||||
```text
|
||||
~/.config/mousehold/fints/challenges/
|
||||
```
|
||||
|
||||
## Ledger Mapping
|
||||
|
||||
FinTS transactions are imported as `source_type = "fints"` and
|
||||
`status = "confirmed"`.
|
||||
|
||||
- Positive bank movements become `income`.
|
||||
- Ordinary negative bank movements become `expense`.
|
||||
- Negative movements mentioning PayPal, Klarna, or credit-card providers become
|
||||
`liability_payment`, so they can later be linked to prior obligations instead
|
||||
of becoming duplicate household expenses.
|
||||
1500
docs/handover.md
Normal file
1500
docs/handover.md
Normal file
File diff suppressed because it is too large
Load Diff
1835
package-lock.json
generated
Normal file
1835
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
package.json
Normal file
14
package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "mousehold",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/web"
|
||||
],
|
||||
"scripts": {
|
||||
"dev:web": "npm --workspace apps/web run dev",
|
||||
"build:web": "npm --workspace apps/web run build",
|
||||
"lint:web": "npm --workspace apps/web run lint",
|
||||
"test:e2e": "npm --workspace apps/web run test:e2e"
|
||||
}
|
||||
}
|
||||
41
packages/domain/mousehold_domain/__init__.py
Normal file
41
packages/domain/mousehold_domain/__init__.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from .enums import (
|
||||
AccountType,
|
||||
EventObservationRelation,
|
||||
FinancialEventStatus,
|
||||
FinancialEventType,
|
||||
ImportConnectionStatus,
|
||||
ImportConnectionType,
|
||||
ObservationStatus,
|
||||
PaymentLineStatus,
|
||||
PaymentStatus,
|
||||
PlanningStatus,
|
||||
ProjectStatus,
|
||||
ReconciliationStatus,
|
||||
RecurringCommitmentStatus,
|
||||
RecurringInterval,
|
||||
RelationType,
|
||||
SettlementStatus,
|
||||
SourceType,
|
||||
VisibilityMode,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AccountType",
|
||||
"EventObservationRelation",
|
||||
"FinancialEventStatus",
|
||||
"FinancialEventType",
|
||||
"ImportConnectionStatus",
|
||||
"ImportConnectionType",
|
||||
"ObservationStatus",
|
||||
"PaymentLineStatus",
|
||||
"PaymentStatus",
|
||||
"PlanningStatus",
|
||||
"ProjectStatus",
|
||||
"ReconciliationStatus",
|
||||
"RecurringCommitmentStatus",
|
||||
"RecurringInterval",
|
||||
"RelationType",
|
||||
"SettlementStatus",
|
||||
"SourceType",
|
||||
"VisibilityMode",
|
||||
]
|
||||
182
packages/domain/mousehold_domain/enums.py
Normal file
182
packages/domain/mousehold_domain/enums.py
Normal file
@@ -0,0 +1,182 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class AccountType(StrEnum):
|
||||
CHECKING = "checking"
|
||||
SHARED_CHECKING = "shared_checking"
|
||||
PAYPAL = "paypal"
|
||||
CREDIT_CARD = "credit_card"
|
||||
KLARNA = "klarna"
|
||||
CASH = "cash"
|
||||
LIABILITY = "liability"
|
||||
SAVINGS = "savings"
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
class VisibilityMode(StrEnum):
|
||||
PRIVATE = "private"
|
||||
SHARED_SUMMARY = "shared_summary"
|
||||
SHARED_FULL = "shared_full"
|
||||
SETTLEMENT_ONLY = "settlement_only"
|
||||
|
||||
|
||||
class SourceType(StrEnum):
|
||||
MANUAL = "manual"
|
||||
BANK_CSV = "bank_csv"
|
||||
FINTS = "fints"
|
||||
PSD2_AGGREGATOR = "psd2_aggregator"
|
||||
PAYPAL_API = "paypal_api"
|
||||
PAYPAL_CSV = "paypal_csv"
|
||||
PAYPAL_EMAIL = "paypal_email"
|
||||
KLARNA_CSV = "klarna_csv"
|
||||
KLARNA_EMAIL = "klarna_email"
|
||||
ORDER_CONFIRMATION_EMAIL = "order_confirmation_email"
|
||||
IMAP_EMAIL = "imap_email"
|
||||
PDF_INVOICE = "pdf_invoice"
|
||||
RECEIPT_PHOTO = "receipt_photo"
|
||||
LOCAL_AGENT = "local_agent"
|
||||
API = "api"
|
||||
|
||||
|
||||
class ObservationStatus(StrEnum):
|
||||
NEW = "new"
|
||||
PARSED = "parsed"
|
||||
CANDIDATE_CREATED = "candidate_created"
|
||||
IGNORED = "ignored"
|
||||
DUPLICATE_SOURCE = "duplicate_source"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class FinancialEventType(StrEnum):
|
||||
EXPENSE = "expense"
|
||||
INCOME = "income"
|
||||
TRANSFER = "transfer"
|
||||
LIABILITY_PAYMENT = "liability_payment"
|
||||
PROMISED_PAYMENT = "promised_payment"
|
||||
REFUND = "refund"
|
||||
REIMBURSEMENT = "reimbursement"
|
||||
FEE = "fee"
|
||||
INTEREST = "interest"
|
||||
ADJUSTMENT = "adjustment"
|
||||
|
||||
|
||||
class FinancialEventStatus(StrEnum):
|
||||
CANDIDATE = "candidate"
|
||||
ANNOUNCED = "announced"
|
||||
OUTSTANDING = "outstanding"
|
||||
CONFIRMED = "confirmed"
|
||||
BOOKED = "booked"
|
||||
SETTLED = "settled"
|
||||
CANCELLED = "cancelled"
|
||||
IGNORED = "ignored"
|
||||
|
||||
|
||||
class EventObservationRelation(StrEnum):
|
||||
EVIDENCE_FOR = "evidence_for"
|
||||
DUPLICATE_SOURCE = "duplicate_source"
|
||||
IMPORTED_FROM = "imported_from"
|
||||
PARSER_SOURCE = "parser_source"
|
||||
STATEMENT_SOURCE = "statement_source"
|
||||
|
||||
|
||||
class RelationType(StrEnum):
|
||||
DUPLICATE_OF = "duplicate_of"
|
||||
SAME_ECONOMIC_EVENT_AS = "same_economic_event_as"
|
||||
SETTLES = "settles"
|
||||
FUNDS = "funds"
|
||||
REIMBURSES = "reimburses"
|
||||
REFUNDS = "refunds"
|
||||
REVERSES = "reverses"
|
||||
REPLACES_PENDING_AUTHORIZATION = "replaces_pending_authorization"
|
||||
PART_OF_STATEMENT = "part_of_statement"
|
||||
|
||||
|
||||
class ProjectStatus(StrEnum):
|
||||
IDEA = "idea"
|
||||
PLANNING = "planning"
|
||||
ACTIVE = "active"
|
||||
COMPLETED = "completed"
|
||||
CANCELLED = "cancelled"
|
||||
ARCHIVED = "archived"
|
||||
|
||||
|
||||
class PlanningStatus(StrEnum):
|
||||
IDEA = "idea"
|
||||
ESTIMATED = "estimated"
|
||||
COMMITTED = "committed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class PaymentStatus(StrEnum):
|
||||
UNPAID = "unpaid"
|
||||
PARTIALLY_PAID = "partially_paid"
|
||||
PAID = "paid"
|
||||
REFUNDED = "refunded"
|
||||
PARTIALLY_REFUNDED = "partially_refunded"
|
||||
|
||||
|
||||
class ReconciliationStatus(StrEnum):
|
||||
NO_EVIDENCE = "no_evidence"
|
||||
EMAIL_SEEN = "email_seen"
|
||||
RECEIPT_SEEN = "receipt_seen"
|
||||
BANK_MATCHED = "bank_matched"
|
||||
STATEMENT_MATCHED = "statement_matched"
|
||||
MANUALLY_CONFIRMED = "manually_confirmed"
|
||||
|
||||
|
||||
class SettlementStatus(StrEnum):
|
||||
NOT_SHARED = "not_shared"
|
||||
UNSETTLED = "unsettled"
|
||||
PARTIALLY_SETTLED = "partially_settled"
|
||||
SETTLED = "settled"
|
||||
IGNORED = "ignored"
|
||||
|
||||
|
||||
class PaymentLineStatus(StrEnum):
|
||||
EXPECTED = "expected"
|
||||
SCHEDULED = "scheduled"
|
||||
PAID = "paid"
|
||||
RECONCILED = "reconciled"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class RecurringInterval(StrEnum):
|
||||
MONTHLY = "monthly"
|
||||
YEARLY = "yearly"
|
||||
QUARTERLY = "quarterly"
|
||||
WEEKLY = "weekly"
|
||||
CUSTOM = "custom"
|
||||
|
||||
|
||||
class RecurringCommitmentStatus(StrEnum):
|
||||
ACTIVE = "active"
|
||||
PAUSED = "paused"
|
||||
CANCELLED = "cancelled"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class PromisedPaymentStatus(StrEnum):
|
||||
EXPECTED = "expected"
|
||||
SCHEDULED = "scheduled"
|
||||
DEBITED = "debited"
|
||||
RECONCILED = "reconciled"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class ImportConnectionType(StrEnum):
|
||||
FINTS = "fints"
|
||||
PSD2_AGGREGATOR = "psd2_aggregator"
|
||||
IMAP = "imap"
|
||||
PAYPAL_API = "paypal_api"
|
||||
CSV_FOLDER = "csv_folder"
|
||||
LOCAL_FOLDER = "local_folder"
|
||||
LOCAL_AGENT = "local_agent"
|
||||
|
||||
|
||||
class ImportConnectionStatus(StrEnum):
|
||||
ACTIVE = "active"
|
||||
NEEDS_AUTH = "needs_auth"
|
||||
ERROR = "error"
|
||||
DISABLED = "disabled"
|
||||
8
packages/matching/mousehold_matching/__init__.py
Normal file
8
packages/matching/mousehold_matching/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from .fingerprints import compute_normalized_transaction_fingerprint, compute_raw_observation_hash
|
||||
from .scoring import score_event_match
|
||||
|
||||
__all__ = [
|
||||
"compute_normalized_transaction_fingerprint",
|
||||
"compute_raw_observation_hash",
|
||||
"score_event_match",
|
||||
]
|
||||
64
packages/matching/mousehold_matching/fingerprints.py
Normal file
64
packages/matching/mousehold_matching/fingerprints.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _stable_default(value: Any) -> str:
|
||||
if isinstance(value, datetime | date):
|
||||
return value.isoformat()
|
||||
if isinstance(value, Decimal):
|
||||
return str(value)
|
||||
return str(value)
|
||||
|
||||
|
||||
def _stable_json(value: Any) -> str:
|
||||
return json.dumps(
|
||||
value, default=_stable_default, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
)
|
||||
|
||||
|
||||
def compute_raw_observation_hash(
|
||||
*,
|
||||
source_type: str,
|
||||
source_reference: str | None = None,
|
||||
raw_text: str | None = None,
|
||||
raw_payload: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
payload = {
|
||||
"source_reference": source_reference,
|
||||
"source_type": source_type,
|
||||
"raw_payload": raw_payload or {},
|
||||
"raw_text": raw_text or "",
|
||||
}
|
||||
return hashlib.sha256(_stable_json(payload).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def normalize_counterparty(value: str | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
compact = re.sub(r"[^a-z0-9]+", " ", value.lower())
|
||||
return re.sub(r"\s+", " ", compact).strip()
|
||||
|
||||
|
||||
def compute_normalized_transaction_fingerprint(
|
||||
*,
|
||||
amount: Decimal | str | int | float,
|
||||
currency: str,
|
||||
event_date: date | str,
|
||||
merchant: str | None = None,
|
||||
counterparty: str | None = None,
|
||||
external_id: str | None = None,
|
||||
) -> str:
|
||||
normalized = {
|
||||
"amount": str(Decimal(str(amount)).quantize(Decimal("0.01"))),
|
||||
"currency": currency.upper(),
|
||||
"event_date": event_date.isoformat() if isinstance(event_date, date) else event_date,
|
||||
"party": normalize_counterparty(merchant or counterparty),
|
||||
"external_id": external_id or "",
|
||||
}
|
||||
return hashlib.sha256(_stable_json(normalized).encode("utf-8")).hexdigest()
|
||||
40
packages/matching/mousehold_matching/scoring.py
Normal file
40
packages/matching/mousehold_matching/scoring.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
from .fingerprints import normalize_counterparty
|
||||
|
||||
|
||||
def _date_distance(a: date, b: date) -> int:
|
||||
return abs((a - b).days)
|
||||
|
||||
|
||||
def score_event_match(
|
||||
*,
|
||||
amount_a: Decimal,
|
||||
amount_b: Decimal,
|
||||
currency_a: str,
|
||||
currency_b: str,
|
||||
date_a: date,
|
||||
date_b: date,
|
||||
party_a: str | None,
|
||||
party_b: str | None,
|
||||
) -> float:
|
||||
if currency_a.upper() != currency_b.upper():
|
||||
return 0.0
|
||||
|
||||
amount_score = (
|
||||
1.0
|
||||
if amount_a == amount_b
|
||||
else max(0.0, 1.0 - (abs(amount_a - amount_b) / max(abs(amount_a), Decimal("1"))))
|
||||
)
|
||||
days = _date_distance(date_a, date_b)
|
||||
date_score = 1.0 if days == 0 else max(0.0, 1.0 - (days / 14))
|
||||
party_score = SequenceMatcher(
|
||||
None,
|
||||
normalize_counterparty(party_a),
|
||||
normalize_counterparty(party_b),
|
||||
).ratio()
|
||||
return round((amount_score * 0.45) + (date_score * 0.30) + (party_score * 0.25), 4)
|
||||
46
pyproject.toml
Normal file
46
pyproject.toml
Normal file
@@ -0,0 +1,46 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=69", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "mousehold"
|
||||
version = "0.1.0"
|
||||
description = "Household finance ledger with evidence-driven imports, project budgets, and settlement."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { text = "AGPL-3.0-only" }
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"fints>=5.0.0",
|
||||
"pydantic>=2.8.0",
|
||||
"pydantic-settings>=2.4.0",
|
||||
"python-multipart>=0.0.9",
|
||||
"sqlalchemy>=2.0.32",
|
||||
"uvicorn[standard]>=0.30.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"httpx>=0.27.0",
|
||||
"pytest>=8.3.0",
|
||||
"ruff>=0.6.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
mousehold-api = "mousehold_api.main:run"
|
||||
mousehold-agent = "mousehold_agent.cli:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["apps/api", "apps/local-agent", "packages/domain", "packages/matching"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["apps/api", "apps/local-agent", "packages/domain", "packages/matching"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B"]
|
||||
ignore = ["B008"]
|
||||
5
requirements-dev.txt
Normal file
5
requirements-dev.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
-r requirements.txt
|
||||
|
||||
httpx>=0.27.0
|
||||
pytest>=8.3.0
|
||||
ruff>=0.6.0
|
||||
7
requirements.txt
Normal file
7
requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
fastapi>=0.115.0
|
||||
fints>=5.0.0
|
||||
pydantic>=2.8.0
|
||||
pydantic-settings>=2.4.0
|
||||
python-multipart>=0.0.9
|
||||
sqlalchemy>=2.0.32
|
||||
uvicorn[standard]>=0.30.0
|
||||
57
tests/conftest.py
Normal file
57
tests/conftest.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from mousehold_api import models
|
||||
from mousehold_api.db import Base
|
||||
from mousehold_domain import AccountType, VisibilityMode
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session() -> Session:
|
||||
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
||||
Base.metadata.create_all(engine)
|
||||
SessionLocal = sessionmaker(
|
||||
bind=engine, autoflush=False, autocommit=False, expire_on_commit=False
|
||||
)
|
||||
with SessionLocal() as test_session:
|
||||
yield test_session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def household_setup(session: Session) -> dict[str, str]:
|
||||
household = models.Household(name="Test household", default_currency="EUR")
|
||||
session.add(household)
|
||||
session.flush()
|
||||
user_a = models.User(household_id=household.id, display_name="A", email="a@example.test")
|
||||
user_b = models.User(household_id=household.id, display_name="B", email="b@example.test")
|
||||
session.add_all([user_a, user_b])
|
||||
session.flush()
|
||||
account_a = models.Account(
|
||||
household_id=household.id,
|
||||
owner_user_id=user_a.id,
|
||||
name="A checking",
|
||||
account_type=AccountType.CHECKING,
|
||||
institution_name="Demo Bank",
|
||||
currency="EUR",
|
||||
visibility_mode=VisibilityMode.SHARED_FULL,
|
||||
)
|
||||
shared_account = models.Account(
|
||||
household_id=household.id,
|
||||
owner_user_id=None,
|
||||
name="Shared wallet",
|
||||
account_type=AccountType.SHARED_CHECKING,
|
||||
institution_name="Demo Bank",
|
||||
currency="EUR",
|
||||
visibility_mode=VisibilityMode.SHARED_FULL,
|
||||
)
|
||||
session.add_all([account_a, shared_account])
|
||||
session.commit()
|
||||
return {
|
||||
"household_id": household.id,
|
||||
"user_a_id": user_a.id,
|
||||
"user_b_id": user_b.id,
|
||||
"account_a_id": account_a.id,
|
||||
"shared_account_id": shared_account.id,
|
||||
}
|
||||
78
tests/test_fints_connector.py
Normal file
78
tests/test_fints_connector.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from fints.models import SEPAAccount
|
||||
from mousehold_agent.fints_connector import transaction_to_observation
|
||||
|
||||
|
||||
class DummyTransaction:
|
||||
def __init__(self, data: dict[str, object]) -> None:
|
||||
self.data = data
|
||||
|
||||
|
||||
def test_fints_debit_transaction_becomes_expense_observation() -> None:
|
||||
observation = transaction_to_observation(
|
||||
DummyTransaction(
|
||||
{
|
||||
"date": date(2026, 6, 1),
|
||||
"amount": Decimal("-42.50"),
|
||||
"currency": "EUR",
|
||||
"applicant_name": "REWE",
|
||||
"purpose": "Kartenzahlung",
|
||||
"customer_reference": "abc-123",
|
||||
}
|
||||
),
|
||||
account=SEPAAccount("DE001234", "TESTBIC", "1234", None, "43060967"),
|
||||
household_id="household-1",
|
||||
source_account_id="account-1",
|
||||
owner_user_id="user-1",
|
||||
)
|
||||
|
||||
assert observation["source_type"] == "fints"
|
||||
assert observation["event_type"] == "expense"
|
||||
assert observation["amount"] == "42.50"
|
||||
assert observation["provider_transaction_id"] == "abc-123"
|
||||
assert observation["event_date"] == "2026-06-01"
|
||||
|
||||
|
||||
def test_fints_credit_transaction_becomes_income_observation() -> None:
|
||||
observation = transaction_to_observation(
|
||||
DummyTransaction(
|
||||
{
|
||||
"date": date(2026, 6, 2),
|
||||
"amount": Decimal("100.00"),
|
||||
"currency": "EUR",
|
||||
"applicant_name": "Employer",
|
||||
"purpose": "Salary",
|
||||
}
|
||||
),
|
||||
account=SEPAAccount("DE001234", "TESTBIC", "1234", None, "43060967"),
|
||||
household_id="household-1",
|
||||
source_account_id="account-1",
|
||||
owner_user_id="user-1",
|
||||
)
|
||||
|
||||
assert observation["event_type"] == "income"
|
||||
assert observation["amount"] == "100.00"
|
||||
|
||||
|
||||
def test_fints_paypal_bank_debit_becomes_liability_payment() -> None:
|
||||
observation = transaction_to_observation(
|
||||
DummyTransaction(
|
||||
{
|
||||
"date": date(2026, 6, 3),
|
||||
"amount": Decimal("-18.99"),
|
||||
"currency": "EUR",
|
||||
"applicant_name": "PayPal Europe",
|
||||
"purpose": "Funding debit",
|
||||
}
|
||||
),
|
||||
account=SEPAAccount("DE001234", "TESTBIC", "1234", None, "43060967"),
|
||||
household_id="household-1",
|
||||
source_account_id="account-1",
|
||||
owner_user_id="user-1",
|
||||
)
|
||||
|
||||
assert observation["event_type"] == "liability_payment"
|
||||
107
tests/test_imap_importer.py
Normal file
107
tests/test_imap_importer.py
Normal file
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from email.message import EmailMessage
|
||||
from email.utils import format_datetime
|
||||
|
||||
from mousehold_agent.imap_importer import parse_email_message
|
||||
|
||||
|
||||
def test_klarna_email_becomes_outstanding_promised_payment() -> None:
|
||||
message = EmailMessage()
|
||||
message["From"] = "Klarna <noreply@klarna.de>"
|
||||
message["To"] = "user@example.com"
|
||||
message["Subject"] = "Deine Bestellung bei Example Shop"
|
||||
message["Date"] = format_datetime(datetime(2026, 6, 20, 12, 0, tzinfo=UTC))
|
||||
message["Message-ID"] = "<klarna-1@example.com>"
|
||||
message.set_content(
|
||||
"Bestellnummer: AB-12345\n"
|
||||
"Gesamtbetrag: 84,00 €\n"
|
||||
"Bitte bezahlen bis 30.06.2026.\n"
|
||||
"Klarna Referenz: KR-98765"
|
||||
)
|
||||
|
||||
parsed = parse_email_message(
|
||||
message,
|
||||
household_id="household-1",
|
||||
owner_user_id="user-1",
|
||||
source_account_id="account-1",
|
||||
)
|
||||
|
||||
observation = parsed["observation"]
|
||||
assert observation is not None
|
||||
assert observation["source_type"] == "klarna_email"
|
||||
assert observation["event_type"] == "promised_payment"
|
||||
assert observation["status"] == "outstanding"
|
||||
assert observation["amount"] == "84.00"
|
||||
assert observation["due_date"] == "2026-06-30"
|
||||
assert observation["provider_transaction_id"] == "KR-98765"
|
||||
|
||||
|
||||
def test_paypal_email_becomes_announced_expense() -> None:
|
||||
message = EmailMessage()
|
||||
message["From"] = "service@paypal.de"
|
||||
message["To"] = "user@example.com"
|
||||
message["Subject"] = "You paid Example Merchant"
|
||||
message["Date"] = format_datetime(datetime(2026, 6, 21, 9, 30, tzinfo=UTC))
|
||||
message["Message-ID"] = "<paypal-1@example.com>"
|
||||
message.set_content("Payment to: Example Merchant\nTotal EUR 18.99\nTransaction ID: 7AB123456789")
|
||||
|
||||
parsed = parse_email_message(
|
||||
message,
|
||||
household_id="household-1",
|
||||
owner_user_id="user-1",
|
||||
source_account_id="account-1",
|
||||
)
|
||||
|
||||
observation = parsed["observation"]
|
||||
assert observation is not None
|
||||
assert observation["source_type"] == "paypal_email"
|
||||
assert observation["event_type"] == "expense"
|
||||
assert observation["status"] == "announced"
|
||||
assert observation["amount"] == "18.99"
|
||||
assert observation["provider_transaction_id"] == "7AB123456789"
|
||||
|
||||
|
||||
def test_html_order_email_uses_html_body_and_announced_status() -> None:
|
||||
message = EmailMessage()
|
||||
message["From"] = "Shop <orders@example.test>"
|
||||
message["Subject"] = "Your order from Example Store"
|
||||
message["Date"] = format_datetime(datetime(2026, 6, 22, 18, 15, tzinfo=UTC))
|
||||
message["Message-ID"] = "<order-1@example.com>"
|
||||
message.set_content(
|
||||
"<html><body><p>Order number: ORD-2026-1</p><p>Total: € 1.234,56</p></body></html>",
|
||||
subtype="html",
|
||||
)
|
||||
|
||||
parsed = parse_email_message(
|
||||
message,
|
||||
household_id="household-1",
|
||||
owner_user_id=None,
|
||||
source_account_id=None,
|
||||
)
|
||||
|
||||
observation = parsed["observation"]
|
||||
assert observation is not None
|
||||
assert observation["source_type"] == "order_confirmation_email"
|
||||
assert observation["status"] == "announced"
|
||||
assert observation["amount"] == "1234.56"
|
||||
assert observation["provider_transaction_id"] == "ORD-2026-1"
|
||||
|
||||
|
||||
def test_email_without_amount_is_not_importable() -> None:
|
||||
message = EmailMessage()
|
||||
message["From"] = "Klarna <noreply@klarna.de>"
|
||||
message["Subject"] = "Newsletter"
|
||||
message.set_content("No transaction amount in this message.")
|
||||
|
||||
parsed = parse_email_message(
|
||||
message,
|
||||
household_id="household-1",
|
||||
owner_user_id=None,
|
||||
source_account_id=None,
|
||||
)
|
||||
|
||||
assert parsed["observation"] is None
|
||||
assert parsed["message"]["importable"] is False
|
||||
assert parsed["message"]["skip_reason"] == "No EUR amount found"
|
||||
83
tests/test_ingestion.py
Normal file
83
tests/test_ingestion.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from mousehold_api.schemas import ManualObservationCreate, SplitLineCreate
|
||||
from mousehold_api.services.ingestion import create_observation_with_candidate
|
||||
from mousehold_domain import FinancialEventStatus, FinancialEventType, ObservationStatus, SourceType
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
def test_duplicate_observation_is_flagged_without_second_event(
|
||||
session: Session,
|
||||
household_setup: dict[str, str],
|
||||
) -> None:
|
||||
payload = ManualObservationCreate(
|
||||
household_id=household_setup["household_id"],
|
||||
owner_user_id=household_setup["user_a_id"],
|
||||
source_account_id=household_setup["account_a_id"],
|
||||
source_reference="same-manual-input",
|
||||
amount=Decimal("120.00"),
|
||||
currency="EUR",
|
||||
event_date=date(2026, 1, 1),
|
||||
merchant="Groceries",
|
||||
description="Weekly groceries",
|
||||
split_lines=[
|
||||
SplitLineCreate(user_id=household_setup["user_a_id"], share_amount=Decimal("60.00")),
|
||||
SplitLineCreate(user_id=household_setup["user_b_id"], share_amount=Decimal("60.00")),
|
||||
],
|
||||
)
|
||||
|
||||
first_observation, first_event = create_observation_with_candidate(session, payload)
|
||||
second_observation, second_event = create_observation_with_candidate(session, payload)
|
||||
|
||||
assert first_observation.status == ObservationStatus.CANDIDATE_CREATED
|
||||
assert first_event is not None
|
||||
assert second_observation.status == ObservationStatus.DUPLICATE_SOURCE
|
||||
assert second_event is None
|
||||
|
||||
|
||||
def test_email_with_due_date_becomes_outstanding_event(
|
||||
session: Session,
|
||||
household_setup: dict[str, str],
|
||||
) -> None:
|
||||
payload = ManualObservationCreate(
|
||||
household_id=household_setup["household_id"],
|
||||
owner_user_id=household_setup["user_a_id"],
|
||||
source_type=SourceType.KLARNA_EMAIL,
|
||||
amount=Decimal("84.00"),
|
||||
currency="EUR",
|
||||
event_date=date(2026, 1, 3),
|
||||
due_date=date(2026, 2, 2),
|
||||
merchant="Klarna Shop",
|
||||
description="Pay later purchase",
|
||||
)
|
||||
|
||||
_observation, event = create_observation_with_candidate(session, payload)
|
||||
|
||||
assert event is not None
|
||||
assert event.status == FinancialEventStatus.OUTSTANDING
|
||||
|
||||
|
||||
def test_bank_provider_debit_becomes_liability_payment(
|
||||
session: Session,
|
||||
household_setup: dict[str, str],
|
||||
) -> None:
|
||||
payload = ManualObservationCreate(
|
||||
household_id=household_setup["household_id"],
|
||||
owner_user_id=household_setup["user_a_id"],
|
||||
source_account_id=household_setup["account_a_id"],
|
||||
source_type=SourceType.BANK_CSV,
|
||||
amount=Decimal("250.00"),
|
||||
currency="EUR",
|
||||
event_date=date(2026, 1, 15),
|
||||
merchant="Visa Kartenabrechnung",
|
||||
description="Monthly credit card payment",
|
||||
)
|
||||
|
||||
_observation, event = create_observation_with_candidate(session, payload)
|
||||
|
||||
assert event is not None
|
||||
assert event.event_type == FinancialEventType.LIABILITY_PAYMENT
|
||||
assert event.status == FinancialEventStatus.CONFIRMED
|
||||
89
tests/test_observation_review.py
Normal file
89
tests/test_observation_review.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from mousehold_api import models
|
||||
from mousehold_api.routers.observations import (
|
||||
get_import_run_detail,
|
||||
update_observation,
|
||||
)
|
||||
from mousehold_api.schemas import (
|
||||
ManualObservationCreate,
|
||||
ObservationUpdateRequest,
|
||||
)
|
||||
from mousehold_api.services.ingestion import create_observation_with_candidate
|
||||
from mousehold_domain import SourceType
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
def test_observation_update_syncs_linked_event(
|
||||
session: Session,
|
||||
household_setup: dict[str, str],
|
||||
) -> None:
|
||||
payload = ManualObservationCreate(
|
||||
household_id=household_setup["household_id"],
|
||||
owner_user_id=household_setup["user_a_id"],
|
||||
source_account_id=household_setup["account_a_id"],
|
||||
amount=Decimal("42.50"),
|
||||
currency="EUR",
|
||||
event_date=date(2026, 1, 2),
|
||||
merchant="Old merchant",
|
||||
description="Old description",
|
||||
)
|
||||
observation, event = create_observation_with_candidate(session, payload)
|
||||
session.commit()
|
||||
|
||||
item = update_observation(
|
||||
observation.id,
|
||||
ObservationUpdateRequest(
|
||||
amount=Decimal("43.25"),
|
||||
event_date=date(2026, 1, 3),
|
||||
merchant="Updated merchant",
|
||||
description="Updated description",
|
||||
),
|
||||
session,
|
||||
None,
|
||||
)
|
||||
|
||||
assert item.observation.parsed_json["amount"] == "43.25"
|
||||
assert item.events[0].merchant == "Updated merchant"
|
||||
assert item.events[0].description == "Updated description"
|
||||
assert item.events[0].amount == Decimal("43.25")
|
||||
assert item.events[0].event_date == date(2026, 1, 3)
|
||||
assert event.id == item.events[0].id
|
||||
|
||||
|
||||
def test_import_run_detail_returns_observations_and_events(
|
||||
session: Session,
|
||||
household_setup: dict[str, str],
|
||||
) -> None:
|
||||
payload = ManualObservationCreate(
|
||||
household_id=household_setup["household_id"],
|
||||
owner_user_id=household_setup["user_a_id"],
|
||||
source_account_id=household_setup["account_a_id"],
|
||||
source_type=SourceType.BANK_CSV,
|
||||
amount=Decimal("20.00"),
|
||||
currency="EUR",
|
||||
event_date=date(2026, 1, 5),
|
||||
merchant="CSV merchant",
|
||||
)
|
||||
observation, event = create_observation_with_candidate(session, payload)
|
||||
run = models.ImportRun(
|
||||
household_id=household_setup["household_id"],
|
||||
imported_count=1,
|
||||
created_event_count=1,
|
||||
summary_json={
|
||||
"imported_observation_ids": [observation.id],
|
||||
"created_event_ids": [event.id],
|
||||
"duplicate_observation_ids": [],
|
||||
},
|
||||
)
|
||||
session.add(run)
|
||||
session.commit()
|
||||
|
||||
detail = get_import_run_detail(run.id, session, None)
|
||||
|
||||
assert detail.run.id == run.id
|
||||
assert [item.id for item in detail.observations] == [observation.id]
|
||||
assert [item.id for item in detail.events] == [event.id]
|
||||
123
tests/test_paypal_connector.py
Normal file
123
tests/test_paypal_connector.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from mousehold_agent.paypal_connector import (
|
||||
PayPalAPIError,
|
||||
PayPalConfig,
|
||||
_paypal_error_message,
|
||||
_token_payload_to_auth_check,
|
||||
paypal_transaction_to_observation,
|
||||
paypal_transaction_to_preview,
|
||||
)
|
||||
from mousehold_agent.paypal_connector import _validate_date_range as validate_date_range
|
||||
|
||||
|
||||
def test_paypal_negative_transaction_becomes_confirmed_expense() -> None:
|
||||
observation = paypal_transaction_to_observation(
|
||||
{
|
||||
"transaction_info": {
|
||||
"transaction_id": "PAYPAL-001",
|
||||
"transaction_initiation_date": "2026-06-20T12:30:00Z",
|
||||
"transaction_updated_date": "2026-06-20T12:31:00Z",
|
||||
"transaction_amount": {"currency_code": "EUR", "value": "-18.99"},
|
||||
"transaction_status": "S",
|
||||
"transaction_subject": "Payment to Example Merchant",
|
||||
},
|
||||
"payer_info": {"email_address": "merchant@example.test"},
|
||||
},
|
||||
household_id="household-1",
|
||||
source_account_id="account-1",
|
||||
owner_user_id="user-1",
|
||||
)
|
||||
|
||||
assert observation["source_type"] == "paypal_api"
|
||||
assert observation["provider_transaction_id"] == "PAYPAL-001"
|
||||
assert observation["event_type"] == "expense"
|
||||
assert observation["status"] == "confirmed"
|
||||
assert observation["amount"] == "18.99"
|
||||
assert observation["event_date"] == "2026-06-20"
|
||||
|
||||
|
||||
def test_paypal_positive_transaction_becomes_income() -> None:
|
||||
observation = paypal_transaction_to_observation(
|
||||
{
|
||||
"transaction_info": {
|
||||
"transaction_id": "PAYPAL-002",
|
||||
"transaction_initiation_date": "2026-06-21T09:10:00+0000",
|
||||
"transaction_amount": {"currency_code": "EUR", "value": "42.50"},
|
||||
"transaction_status": "S",
|
||||
},
|
||||
"payer_info": {
|
||||
"payer_name": {"given_name": "Alice", "surname": "Example"},
|
||||
},
|
||||
},
|
||||
household_id="household-1",
|
||||
source_account_id="account-1",
|
||||
owner_user_id="user-1",
|
||||
)
|
||||
|
||||
assert observation["event_type"] == "income"
|
||||
assert observation["amount"] == "42.50"
|
||||
assert observation["counterparty"] == "Alice Example"
|
||||
|
||||
|
||||
def test_paypal_pending_transaction_preview_is_announced() -> None:
|
||||
preview = paypal_transaction_to_preview(
|
||||
{
|
||||
"transaction_info": {
|
||||
"transaction_id": "PAYPAL-003",
|
||||
"transaction_initiation_date": "2026-06-22T09:10:00Z",
|
||||
"transaction_amount": {"currency_code": "EUR", "value": "-10.00"},
|
||||
"transaction_status": "P",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
assert preview["status"] == "announced"
|
||||
assert preview["event_type"] == "expense"
|
||||
|
||||
|
||||
def test_paypal_date_range_is_limited_to_31_days() -> None:
|
||||
with pytest.raises(PayPalAPIError):
|
||||
validate_date_range(date(2026, 6, 1), date(2026, 7, 2))
|
||||
|
||||
|
||||
def test_paypal_not_authorized_error_mentions_transaction_search_scope() -> None:
|
||||
message = _paypal_error_message(
|
||||
403,
|
||||
'{"name":"NOT_AUTHORIZED","message":"Authorization failed due to insufficient permissions."}',
|
||||
)
|
||||
|
||||
assert "Transaction Search" in message
|
||||
assert "https://uri.paypal.com/services/reporting/search/read" in message
|
||||
|
||||
|
||||
def test_paypal_auth_check_reports_missing_transaction_search_scope() -> None:
|
||||
check = _token_payload_to_auth_check(
|
||||
PayPalConfig(client_id="client", client_secret="secret", environment="sandbox"),
|
||||
{
|
||||
"scope": "openid https://uri.paypal.com/services/invoicing",
|
||||
"token_type": "Bearer",
|
||||
"app_id": "APP-123",
|
||||
"expires_in": 3600,
|
||||
},
|
||||
)
|
||||
|
||||
assert check["app_id"] == "APP-123"
|
||||
assert check["environment"] == "sandbox"
|
||||
assert check["has_transaction_search"] is False
|
||||
|
||||
|
||||
def test_paypal_auth_check_parses_comma_separated_scope_text() -> None:
|
||||
check = _token_payload_to_auth_check(
|
||||
PayPalConfig(client_id="client", client_secret="secret", environment="sandbox"),
|
||||
{
|
||||
"scope": "openid,https://uri.paypal.com/services/reporting/search/read",
|
||||
"token_type": "Bearer",
|
||||
},
|
||||
)
|
||||
|
||||
assert check["has_transaction_search"] is True
|
||||
assert "https://uri.paypal.com/services/reporting/search/read" in check["scopes"]
|
||||
70
tests/test_projects.py
Normal file
70
tests/test_projects.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from mousehold_api import models
|
||||
from mousehold_api.services.projects import calculate_project_summary
|
||||
from mousehold_domain import (
|
||||
PaymentLineStatus,
|
||||
PaymentStatus,
|
||||
PlanningStatus,
|
||||
ProjectStatus,
|
||||
ReconciliationStatus,
|
||||
SettlementStatus,
|
||||
)
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
def test_project_partial_payment_summary(
|
||||
session: Session,
|
||||
household_setup: dict[str, str],
|
||||
) -> None:
|
||||
project = models.Project(
|
||||
household_id=household_setup["household_id"],
|
||||
name="Stockholm 2026",
|
||||
status=ProjectStatus.ACTIVE,
|
||||
currency="EUR",
|
||||
)
|
||||
session.add(project)
|
||||
session.flush()
|
||||
budget_line = models.ProjectBudgetLine(
|
||||
project_id=project.id,
|
||||
name="Accommodation",
|
||||
budgeted_amount=Decimal("800.00"),
|
||||
)
|
||||
session.add(budget_line)
|
||||
session.flush()
|
||||
item = models.ProjectPlannedItem(
|
||||
project_id=project.id,
|
||||
budget_line_id=budget_line.id,
|
||||
description="Hotel",
|
||||
estimated_amount=Decimal("800.00"),
|
||||
committed_amount=Decimal("760.00"),
|
||||
due_date=date(2026, 8, 12),
|
||||
planning_status=PlanningStatus.COMMITTED,
|
||||
payment_status=PaymentStatus.PARTIALLY_PAID,
|
||||
reconciliation_status=ReconciliationStatus.EMAIL_SEEN,
|
||||
settlement_status=SettlementStatus.UNSETTLED,
|
||||
)
|
||||
session.add(item)
|
||||
session.flush()
|
||||
session.add(
|
||||
models.PaymentLine(
|
||||
planned_item_id=item.id,
|
||||
amount=Decimal("200.00"),
|
||||
currency="EUR",
|
||||
date=date(2026, 1, 5),
|
||||
paid_by_user_id=household_setup["user_a_id"],
|
||||
status=PaymentLineStatus.PAID,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
summary = calculate_project_summary(session, project.id)
|
||||
|
||||
assert summary.budgeted_total == Decimal("800.00")
|
||||
assert summary.committed_total == Decimal("760.00")
|
||||
assert summary.paid_total == Decimal("200.00")
|
||||
assert summary.due_later_total == Decimal("560.00")
|
||||
assert summary.remaining_uncommitted_budget == Decimal("40.00")
|
||||
401
tests/test_reconciliation.py
Normal file
401
tests/test_reconciliation.py
Normal file
@@ -0,0 +1,401 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from mousehold_api import models
|
||||
from mousehold_api.schemas import ManualObservationCreate
|
||||
from mousehold_api.services.ingestion import create_observation_with_candidate
|
||||
from mousehold_api.services.reconciliation import (
|
||||
accept_reconciliation_suggestion,
|
||||
ignore_reconciliation_suggestion,
|
||||
list_reconciliation_decisions,
|
||||
list_reconciliation_suggestions,
|
||||
)
|
||||
from mousehold_domain import (
|
||||
EventObservationRelation,
|
||||
FinancialEventStatus,
|
||||
FinancialEventType,
|
||||
RelationType,
|
||||
SourceType,
|
||||
)
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
def test_paypal_email_is_matched_to_paypal_statement(
|
||||
session: Session,
|
||||
household_setup: dict[str, str],
|
||||
) -> None:
|
||||
email_event = _create_observation_event(
|
||||
session,
|
||||
household_id=household_setup["household_id"],
|
||||
owner_user_id=household_setup["user_a_id"],
|
||||
source_type=SourceType.PAYPAL_EMAIL,
|
||||
source_reference="paypal-email-1",
|
||||
amount=Decimal("18.99"),
|
||||
event_date=date(2026, 1, 5),
|
||||
due_date=date(2026, 1, 8),
|
||||
merchant="Example Merchant",
|
||||
description="PayPal payment to Example Merchant",
|
||||
status=None,
|
||||
)
|
||||
statement_event = _create_observation_event(
|
||||
session,
|
||||
household_id=household_setup["household_id"],
|
||||
owner_user_id=household_setup["user_a_id"],
|
||||
source_type=SourceType.PAYPAL_CSV,
|
||||
source_reference="PAYPAL-TXN-1",
|
||||
provider_transaction_id="PAYPAL-TXN-1",
|
||||
amount=Decimal("18.99"),
|
||||
event_date=date(2026, 1, 6),
|
||||
merchant="Example Merchant",
|
||||
description="Payment Sent",
|
||||
status=FinancialEventStatus.CONFIRMED,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
suggestions = list_reconciliation_suggestions(
|
||||
session,
|
||||
household_id=household_setup["household_id"],
|
||||
)
|
||||
|
||||
assert len(suggestions) == 1
|
||||
assert suggestions[0].primary_event.id == email_event.id
|
||||
assert suggestions[0].confirming_event.id == statement_event.id
|
||||
assert suggestions[0].confidence >= Decimal("0.80")
|
||||
|
||||
|
||||
def test_accepting_suggestion_confirms_primary_and_links_evidence(
|
||||
session: Session,
|
||||
household_setup: dict[str, str],
|
||||
) -> None:
|
||||
email_event = _create_observation_event(
|
||||
session,
|
||||
household_id=household_setup["household_id"],
|
||||
owner_user_id=household_setup["user_a_id"],
|
||||
source_type=SourceType.KLARNA_EMAIL,
|
||||
source_reference="klarna-email-1",
|
||||
amount=Decimal("84.00"),
|
||||
event_date=date(2026, 2, 1),
|
||||
due_date=date(2026, 2, 14),
|
||||
merchant="Example Shop",
|
||||
description="Klarna payment reference KR-123",
|
||||
raw_payload_json={"references": {"klarna_reference": "KR-123"}},
|
||||
status=None,
|
||||
)
|
||||
_statement_event = _create_observation_event(
|
||||
session,
|
||||
household_id=household_setup["household_id"],
|
||||
owner_user_id=household_setup["user_a_id"],
|
||||
source_type=SourceType.KLARNA_CSV,
|
||||
source_reference="KR-123",
|
||||
provider_transaction_id="KR-123",
|
||||
amount=Decimal("84.00"),
|
||||
event_date=date(2026, 2, 8),
|
||||
merchant="Example Shop",
|
||||
description="Klarna payment",
|
||||
status=FinancialEventStatus.CONFIRMED,
|
||||
)
|
||||
session.commit()
|
||||
suggestion = list_reconciliation_suggestions(
|
||||
session,
|
||||
household_id=household_setup["household_id"],
|
||||
)[0]
|
||||
|
||||
accept_reconciliation_suggestion(
|
||||
session,
|
||||
household_id=household_setup["household_id"],
|
||||
suggestion_id=suggestion.id,
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(email_event)
|
||||
|
||||
assert email_event.status == FinancialEventStatus.CONFIRMED
|
||||
relation = session.scalars(select(models.EventRelation)).one()
|
||||
assert relation.from_event_id == suggestion.primary_event.id
|
||||
assert relation.to_event_id == suggestion.confirming_event.id
|
||||
evidence_link = session.scalars(
|
||||
select(models.EventObservationLink)
|
||||
.where(models.EventObservationLink.event_id == email_event.id)
|
||||
.where(models.EventObservationLink.relation == EventObservationRelation.EVIDENCE_FOR)
|
||||
).first()
|
||||
assert evidence_link is not None
|
||||
assert list_reconciliation_suggestions(session, household_id=household_setup["household_id"]) == []
|
||||
|
||||
|
||||
def test_different_amounts_do_not_match(
|
||||
session: Session,
|
||||
household_setup: dict[str, str],
|
||||
) -> None:
|
||||
_create_observation_event(
|
||||
session,
|
||||
household_id=household_setup["household_id"],
|
||||
owner_user_id=household_setup["user_a_id"],
|
||||
source_type=SourceType.PAYPAL_EMAIL,
|
||||
source_reference="paypal-email-2",
|
||||
amount=Decimal("18.99"),
|
||||
event_date=date(2026, 1, 5),
|
||||
merchant="Example Merchant",
|
||||
description="PayPal payment",
|
||||
status=None,
|
||||
)
|
||||
_create_observation_event(
|
||||
session,
|
||||
household_id=household_setup["household_id"],
|
||||
owner_user_id=household_setup["user_a_id"],
|
||||
source_type=SourceType.PAYPAL_CSV,
|
||||
source_reference="PAYPAL-TXN-2",
|
||||
amount=Decimal("19.99"),
|
||||
event_date=date(2026, 1, 6),
|
||||
merchant="Example Merchant",
|
||||
description="Payment Sent",
|
||||
status=FinancialEventStatus.CONFIRMED,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
assert list_reconciliation_suggestions(session, household_id=household_setup["household_id"]) == []
|
||||
|
||||
|
||||
def test_ignored_suggestion_is_persistently_hidden(
|
||||
session: Session,
|
||||
household_setup: dict[str, str],
|
||||
) -> None:
|
||||
_create_observation_event(
|
||||
session,
|
||||
household_id=household_setup["household_id"],
|
||||
owner_user_id=household_setup["user_a_id"],
|
||||
source_type=SourceType.PAYPAL_EMAIL,
|
||||
source_reference="paypal-email-ignore",
|
||||
amount=Decimal("12.00"),
|
||||
event_date=date(2026, 1, 5),
|
||||
due_date=date(2026, 1, 8),
|
||||
merchant="Example Merchant",
|
||||
description="PayPal payment to Example Merchant",
|
||||
status=None,
|
||||
)
|
||||
_create_observation_event(
|
||||
session,
|
||||
household_id=household_setup["household_id"],
|
||||
owner_user_id=household_setup["user_a_id"],
|
||||
source_type=SourceType.PAYPAL_CSV,
|
||||
source_reference="PAYPAL-TXN-IGNORE",
|
||||
provider_transaction_id="PAYPAL-TXN-IGNORE",
|
||||
amount=Decimal("12.00"),
|
||||
event_date=date(2026, 1, 6),
|
||||
merchant="Example Merchant",
|
||||
description="Payment Sent",
|
||||
status=FinancialEventStatus.CONFIRMED,
|
||||
)
|
||||
session.commit()
|
||||
suggestion = list_reconciliation_suggestions(
|
||||
session,
|
||||
household_id=household_setup["household_id"],
|
||||
)[0]
|
||||
|
||||
_ignored, decision = ignore_reconciliation_suggestion(
|
||||
session,
|
||||
household_id=household_setup["household_id"],
|
||||
suggestion_id=suggestion.id,
|
||||
actor_user_id=household_setup["user_a_id"],
|
||||
reason="not the same payment",
|
||||
)
|
||||
session.commit()
|
||||
|
||||
assert decision.decision == "ignored"
|
||||
assert list_reconciliation_suggestions(session, household_id=household_setup["household_id"]) == []
|
||||
decisions = list_reconciliation_decisions(session, household_id=household_setup["household_id"])
|
||||
assert decisions[0].suggestion_id == suggestion.id
|
||||
|
||||
|
||||
def test_paypal_obligation_statement_and_bank_debit_lifecycle(
|
||||
session: Session,
|
||||
household_setup: dict[str, str],
|
||||
) -> None:
|
||||
email_event = _create_observation_event(
|
||||
session,
|
||||
household_id=household_setup["household_id"],
|
||||
owner_user_id=household_setup["user_a_id"],
|
||||
source_type=SourceType.PAYPAL_EMAIL,
|
||||
source_reference="paypal-email-lifecycle",
|
||||
amount=Decimal("84.00"),
|
||||
event_date=date(2026, 1, 5),
|
||||
due_date=date(2026, 2, 4),
|
||||
merchant="Example Merchant",
|
||||
description="PayPal promised payment to Example Merchant",
|
||||
status=None,
|
||||
event_type=FinancialEventType.PROMISED_PAYMENT,
|
||||
)
|
||||
statement_event = _create_observation_event(
|
||||
session,
|
||||
household_id=household_setup["household_id"],
|
||||
owner_user_id=household_setup["user_a_id"],
|
||||
source_type=SourceType.PAYPAL_CSV,
|
||||
source_reference="PAYPAL-LIFECYCLE",
|
||||
provider_transaction_id="PAYPAL-LIFECYCLE",
|
||||
amount=Decimal("84.00"),
|
||||
event_date=date(2026, 1, 6),
|
||||
merchant="Example Merchant",
|
||||
description="PayPal payment to Example Merchant",
|
||||
status=FinancialEventStatus.CONFIRMED,
|
||||
)
|
||||
bank_event = _create_observation_event(
|
||||
session,
|
||||
household_id=household_setup["household_id"],
|
||||
owner_user_id=household_setup["user_a_id"],
|
||||
source_type=SourceType.FINTS,
|
||||
source_reference="BANK-PAYPAL-LIFECYCLE",
|
||||
provider_transaction_id="BANK-PAYPAL-LIFECYCLE",
|
||||
amount=Decimal("84.00"),
|
||||
event_date=date(2026, 2, 4),
|
||||
merchant="PayPal Europe",
|
||||
description="PayPal funding debit",
|
||||
status=FinancialEventStatus.CONFIRMED,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
first = list_reconciliation_suggestions(session, household_id=household_setup["household_id"])[0]
|
||||
assert first.primary_event.id == email_event.id
|
||||
assert first.confirming_event.id == statement_event.id
|
||||
accept_reconciliation_suggestion(
|
||||
session,
|
||||
household_id=household_setup["household_id"],
|
||||
suggestion_id=first.id,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
session.refresh(email_event)
|
||||
session.refresh(bank_event)
|
||||
assert email_event.status == FinancialEventStatus.CONFIRMED
|
||||
assert bank_event.event_type == FinancialEventType.LIABILITY_PAYMENT
|
||||
|
||||
second = list_reconciliation_suggestions(session, household_id=household_setup["household_id"])[0]
|
||||
assert second.primary_event.id == statement_event.id
|
||||
assert second.confirming_event.id == bank_event.id
|
||||
assert second.relation_type == RelationType.SETTLES
|
||||
accept_reconciliation_suggestion(
|
||||
session,
|
||||
household_id=household_setup["household_id"],
|
||||
suggestion_id=second.id,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
relations = list(session.scalars(select(models.EventRelation)).all())
|
||||
assert {relation.relation_type for relation in relations} == {
|
||||
RelationType.SAME_ECONOMIC_EVENT_AS,
|
||||
RelationType.SETTLES,
|
||||
}
|
||||
expense_events = list(
|
||||
session.scalars(
|
||||
select(models.FinancialEvent)
|
||||
.where(models.FinancialEvent.household_id == household_setup["household_id"])
|
||||
.where(models.FinancialEvent.event_type == FinancialEventType.EXPENSE)
|
||||
).all()
|
||||
)
|
||||
assert [event.id for event in expense_events] == [statement_event.id]
|
||||
|
||||
|
||||
def test_klarna_obligation_statement_and_bank_debit_lifecycle(
|
||||
session: Session,
|
||||
household_setup: dict[str, str],
|
||||
) -> None:
|
||||
email_event = _create_observation_event(
|
||||
session,
|
||||
household_id=household_setup["household_id"],
|
||||
owner_user_id=household_setup["user_a_id"],
|
||||
source_type=SourceType.KLARNA_EMAIL,
|
||||
source_reference="klarna-email-lifecycle",
|
||||
amount=Decimal("64.00"),
|
||||
event_date=date(2026, 3, 1),
|
||||
due_date=date(2026, 3, 31),
|
||||
merchant="Example Shop",
|
||||
description="Klarna payment reference KR-456",
|
||||
raw_payload_json={"references": {"klarna_reference": "KR-456"}},
|
||||
status=None,
|
||||
event_type=FinancialEventType.PROMISED_PAYMENT,
|
||||
)
|
||||
statement_event = _create_observation_event(
|
||||
session,
|
||||
household_id=household_setup["household_id"],
|
||||
owner_user_id=household_setup["user_a_id"],
|
||||
source_type=SourceType.KLARNA_CSV,
|
||||
source_reference="KR-456",
|
||||
provider_transaction_id="KR-456",
|
||||
amount=Decimal("64.00"),
|
||||
event_date=date(2026, 3, 5),
|
||||
merchant="Example Shop",
|
||||
description="Klarna payment KR-456",
|
||||
status=FinancialEventStatus.CONFIRMED,
|
||||
)
|
||||
bank_event = _create_observation_event(
|
||||
session,
|
||||
household_id=household_setup["household_id"],
|
||||
owner_user_id=household_setup["user_a_id"],
|
||||
source_type=SourceType.FINTS,
|
||||
source_reference="BANK-KLARNA-LIFECYCLE",
|
||||
provider_transaction_id="BANK-KLARNA-LIFECYCLE",
|
||||
amount=Decimal("64.00"),
|
||||
event_date=date(2026, 3, 31),
|
||||
merchant="Klarna Bank AB",
|
||||
description="Klarna funding debit",
|
||||
status=FinancialEventStatus.CONFIRMED,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
first = list_reconciliation_suggestions(session, household_id=household_setup["household_id"])[0]
|
||||
assert first.primary_event.id == email_event.id
|
||||
assert first.confirming_event.id == statement_event.id
|
||||
accept_reconciliation_suggestion(
|
||||
session,
|
||||
household_id=household_setup["household_id"],
|
||||
suggestion_id=first.id,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
session.refresh(bank_event)
|
||||
assert bank_event.event_type == FinancialEventType.LIABILITY_PAYMENT
|
||||
|
||||
second = list_reconciliation_suggestions(session, household_id=household_setup["household_id"])[0]
|
||||
assert second.primary_event.id == statement_event.id
|
||||
assert second.confirming_event.id == bank_event.id
|
||||
assert second.relation_type == RelationType.SETTLES
|
||||
|
||||
|
||||
def _create_observation_event(
|
||||
session: Session,
|
||||
*,
|
||||
household_id: str,
|
||||
owner_user_id: str,
|
||||
source_type: SourceType,
|
||||
source_reference: str,
|
||||
amount: Decimal,
|
||||
event_date: date,
|
||||
merchant: str,
|
||||
description: str,
|
||||
provider_transaction_id: str | None = None,
|
||||
due_date: date | None = None,
|
||||
raw_payload_json: dict | None = None,
|
||||
status: FinancialEventStatus | None,
|
||||
event_type: FinancialEventType = FinancialEventType.EXPENSE,
|
||||
) -> models.FinancialEvent:
|
||||
_observation, event = create_observation_with_candidate(
|
||||
session,
|
||||
ManualObservationCreate(
|
||||
household_id=household_id,
|
||||
source_type=source_type,
|
||||
owner_user_id=owner_user_id,
|
||||
source_reference=source_reference,
|
||||
provider_transaction_id=provider_transaction_id,
|
||||
raw_payload_json=raw_payload_json,
|
||||
amount=amount,
|
||||
currency="EUR",
|
||||
event_date=event_date,
|
||||
due_date=due_date,
|
||||
merchant=merchant,
|
||||
description=description,
|
||||
status=status,
|
||||
event_type=event_type,
|
||||
),
|
||||
)
|
||||
assert event is not None
|
||||
return event
|
||||
124
tests/test_settlement.py
Normal file
124
tests/test_settlement.py
Normal file
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from mousehold_api import models
|
||||
from mousehold_api.services.settlement import calculate_settlement
|
||||
from mousehold_domain import FinancialEventStatus, FinancialEventType
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
def test_equal_split_settlement(
|
||||
session: Session,
|
||||
household_setup: dict[str, str],
|
||||
) -> None:
|
||||
event = models.FinancialEvent(
|
||||
household_id=household_setup["household_id"],
|
||||
payment_account_id=household_setup["account_a_id"],
|
||||
payer_user_id=household_setup["user_a_id"],
|
||||
event_type=FinancialEventType.EXPENSE,
|
||||
status=FinancialEventStatus.CONFIRMED,
|
||||
event_date=date(2026, 1, 1),
|
||||
amount=Decimal("120.00"),
|
||||
currency="EUR",
|
||||
merchant="Groceries",
|
||||
)
|
||||
session.add(event)
|
||||
session.flush()
|
||||
session.add_all(
|
||||
[
|
||||
models.SplitLine(
|
||||
event_id=event.id,
|
||||
user_id=household_setup["user_a_id"],
|
||||
share_amount=Decimal("60.00"),
|
||||
),
|
||||
models.SplitLine(
|
||||
event_id=event.id,
|
||||
user_id=household_setup["user_b_id"],
|
||||
share_amount=Decimal("60.00"),
|
||||
),
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
|
||||
summary = calculate_settlement(session, household_id=household_setup["household_id"])
|
||||
|
||||
assert len(summary.suggested_transfers) == 1
|
||||
transfer = summary.suggested_transfers[0]
|
||||
assert transfer.from_user_id == household_setup["user_b_id"]
|
||||
assert transfer.to_user_id == household_setup["user_a_id"]
|
||||
assert transfer.amount == Decimal("60.00")
|
||||
|
||||
|
||||
def test_shared_neutral_account_is_excluded(
|
||||
session: Session,
|
||||
household_setup: dict[str, str],
|
||||
) -> None:
|
||||
event = models.FinancialEvent(
|
||||
household_id=household_setup["household_id"],
|
||||
payment_account_id=household_setup["shared_account_id"],
|
||||
payer_user_id=household_setup["user_a_id"],
|
||||
event_type=FinancialEventType.EXPENSE,
|
||||
status=FinancialEventStatus.CONFIRMED,
|
||||
event_date=date(2026, 1, 1),
|
||||
amount=Decimal("100.00"),
|
||||
currency="EUR",
|
||||
)
|
||||
session.add(event)
|
||||
session.flush()
|
||||
session.add_all(
|
||||
[
|
||||
models.SplitLine(
|
||||
event_id=event.id,
|
||||
user_id=household_setup["user_a_id"],
|
||||
share_amount=Decimal("50.00"),
|
||||
),
|
||||
models.SplitLine(
|
||||
event_id=event.id,
|
||||
user_id=household_setup["user_b_id"],
|
||||
share_amount=Decimal("50.00"),
|
||||
),
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
|
||||
summary = calculate_settlement(session, household_id=household_setup["household_id"])
|
||||
|
||||
assert summary.included_event_ids == []
|
||||
assert summary.excluded_event_ids == [event.id]
|
||||
assert summary.suggested_transfers == []
|
||||
|
||||
|
||||
def test_announced_and_outstanding_are_excluded_by_default(
|
||||
session: Session,
|
||||
household_setup: dict[str, str],
|
||||
) -> None:
|
||||
event = models.FinancialEvent(
|
||||
household_id=household_setup["household_id"],
|
||||
payment_account_id=household_setup["account_a_id"],
|
||||
payer_user_id=household_setup["user_a_id"],
|
||||
event_type=FinancialEventType.EXPENSE,
|
||||
status=FinancialEventStatus.OUTSTANDING,
|
||||
event_date=date(2026, 1, 1),
|
||||
amount=Decimal("80.00"),
|
||||
currency="EUR",
|
||||
)
|
||||
session.add(event)
|
||||
session.flush()
|
||||
session.add(
|
||||
models.SplitLine(
|
||||
event_id=event.id, user_id=household_setup["user_b_id"], share_amount=Decimal("80.00")
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
default_summary = calculate_settlement(session, household_id=household_setup["household_id"])
|
||||
forecast_summary = calculate_settlement(
|
||||
session,
|
||||
household_id=household_setup["household_id"],
|
||||
include_announced=True,
|
||||
)
|
||||
|
||||
assert default_summary.suggested_transfers == []
|
||||
assert forecast_summary.suggested_transfers[0].amount == Decimal("80.00")
|
||||
104
tests/test_statement_importer.py
Normal file
104
tests/test_statement_importer.py
Normal file
@@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from mousehold_agent.statement_importer import parse_klarna_statement, parse_paypal_statement
|
||||
|
||||
|
||||
def test_paypal_activity_download_report_imports_confirmed_expense() -> None:
|
||||
csv_text = (
|
||||
"Date,Time,TimeZone,Name,Type,Status,Currency,Gross,Fee,Net,Transaction ID,Item Title\n"
|
||||
"01/15/2024,10:30:00,PST,Example Merchant,Payment Sent,Completed,EUR,"
|
||||
"-20.15,0.00,-20.15,TXN123456789,Order 12345\n"
|
||||
)
|
||||
|
||||
result = parse_paypal_statement(
|
||||
csv_text,
|
||||
household_id="household-1",
|
||||
owner_user_id="user-1",
|
||||
source_account_id="paypal-account",
|
||||
filename="Download.CSV",
|
||||
)
|
||||
|
||||
assert result["importable_count"] == 1
|
||||
observation = result["observations"][0]
|
||||
assert observation["source_type"] == "paypal_csv"
|
||||
assert observation["event_type"] == "expense"
|
||||
assert observation["status"] == "confirmed"
|
||||
assert observation["amount"] == "20.15"
|
||||
assert observation["provider_transaction_id"] == "TXN123456789"
|
||||
|
||||
|
||||
def test_paypal_report_reference_sample_fields_import() -> None:
|
||||
csv_text = (
|
||||
"Transaction ID,Date,Type,Status,Amount,Currency,Fee,Net\n"
|
||||
"TXN123456789,2024-01-15,PAYMENT,SUCCESS,100.00,USD,3.20,96.80\n"
|
||||
)
|
||||
|
||||
result = parse_paypal_statement(
|
||||
csv_text,
|
||||
household_id="household-1",
|
||||
owner_user_id=None,
|
||||
source_account_id=None,
|
||||
)
|
||||
|
||||
assert result["importable_count"] == 1
|
||||
assert result["observations"][0]["event_type"] == "income"
|
||||
assert result["observations"][0]["amount"] == "96.80"
|
||||
|
||||
|
||||
def test_klarna_settlement_report_with_summary_imports_transaction_rows() -> None:
|
||||
csv_text = (
|
||||
"total_sale_amount;total_fee_amount;payment_reference;settlement_currency;payout_date;merchant_settlement_type\n"
|
||||
'"100.00";"-3.20";"100162627";"EUR";"2026-05-14T00:00:00.000Z";"NET"\n'
|
||||
"type;order_id;capture_id;amount;detailed_type;sale_date;capture_date;posting_currency;merchant_reference1\n"
|
||||
'"SALE";"00000000-0000-0000-0000-000000000001";"11111111-1111-1111-1111-000000000001";"100.00";"PURCHASE";"2026-05-12";"2026-05-14";"EUR";"SO-0001"\n'
|
||||
'"FEE";"00000000-0000-0000-0000-000000000001";"11111111-1111-1111-1111-000000000001";"-3.20";"PURCHASE_FEE_PERCENTAGE";"2026-05-12";"2026-05-14";"EUR";"SO-0001"\n'
|
||||
)
|
||||
|
||||
result = parse_klarna_statement(
|
||||
csv_text,
|
||||
household_id="household-1",
|
||||
owner_user_id="user-1",
|
||||
source_account_id="klarna-account",
|
||||
filename="sample_purchase_with_vat.csv",
|
||||
)
|
||||
|
||||
assert result["row_count"] == 2
|
||||
assert result["importable_count"] == 2
|
||||
assert result["observations"][0]["source_type"] == "klarna_csv"
|
||||
assert result["observations"][0]["event_type"] == "income"
|
||||
assert result["observations"][1]["event_type"] == "fee"
|
||||
|
||||
|
||||
def test_klarna_consumer_style_statement_imports_liability_payment() -> None:
|
||||
csv_text = (
|
||||
"Datum;Beschreibung;Betrag;Währung;Referenz;Typ\n"
|
||||
"01.06.2026;Klarna Zahlung Example Shop;-84,00;EUR;KR-123;Zahlung\n"
|
||||
)
|
||||
|
||||
result = parse_klarna_statement(
|
||||
csv_text,
|
||||
household_id="household-1",
|
||||
owner_user_id="user-1",
|
||||
source_account_id="klarna-account",
|
||||
)
|
||||
|
||||
assert result["importable_count"] == 1
|
||||
observation = result["observations"][0]
|
||||
assert observation["event_type"] == "liability_payment"
|
||||
assert observation["status"] == "confirmed"
|
||||
assert observation["provider_transaction_id"] == "KR-123"
|
||||
assert observation["amount"] == "84.00"
|
||||
|
||||
|
||||
def test_statement_rows_without_amount_are_skipped() -> None:
|
||||
result = parse_paypal_statement(
|
||||
"Date,Name,Type,Status,Currency,Gross,Transaction ID\n"
|
||||
"2024-01-15,Example Merchant,Note,Completed,EUR,,TXN123\n",
|
||||
household_id="household-1",
|
||||
owner_user_id=None,
|
||||
source_account_id=None,
|
||||
)
|
||||
|
||||
assert result["importable_count"] == 0
|
||||
assert result["skipped_count"] == 1
|
||||
assert result["rows"][0]["skip_reason"] == "No amount found"
|
||||
107
tests/test_taxonomy_events.py
Normal file
107
tests/test_taxonomy_events.py
Normal file
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from mousehold_api import models
|
||||
from mousehold_api.routers import events as events_router
|
||||
from mousehold_api.routers import taxonomy
|
||||
from mousehold_api.schemas import (
|
||||
CategoryCreate,
|
||||
EventTagsUpdate,
|
||||
EventUpdate,
|
||||
ManualObservationCreate,
|
||||
TagCreate,
|
||||
)
|
||||
from mousehold_api.services.ingestion import create_observation_with_candidate
|
||||
from mousehold_domain import FinancialEventStatus, FinancialEventType, RelationType, SourceType
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
def test_categories_tags_are_visible_on_event_details(
|
||||
session: Session,
|
||||
household_setup: dict[str, str],
|
||||
) -> None:
|
||||
category = taxonomy.create_category(
|
||||
CategoryCreate(household_id=household_setup["household_id"], name="Groceries"),
|
||||
session=session,
|
||||
)
|
||||
tag = taxonomy.create_tag(
|
||||
TagCreate(household_id=household_setup["household_id"], name="shared"),
|
||||
session=session,
|
||||
)
|
||||
_observation, event = create_observation_with_candidate(
|
||||
session,
|
||||
ManualObservationCreate(
|
||||
household_id=household_setup["household_id"],
|
||||
source_type=SourceType.MANUAL,
|
||||
owner_user_id=household_setup["user_a_id"],
|
||||
amount=Decimal("42.50"),
|
||||
currency="EUR",
|
||||
event_date=date(2026, 3, 1),
|
||||
merchant="REWE",
|
||||
description="Weekly groceries",
|
||||
status=FinancialEventStatus.CONFIRMED,
|
||||
),
|
||||
)
|
||||
assert event is not None
|
||||
session.commit()
|
||||
|
||||
events_router.update_event(event.id, EventUpdate(category_id=category.id), session=session)
|
||||
events_router.update_event_tags(event.id, EventTagsUpdate(tag_ids=[tag.id]), session=session)
|
||||
|
||||
listed = events_router.list_events(household_setup["household_id"], session=session)
|
||||
assert listed[0].category_id == category.id
|
||||
assert listed[0].tag_ids == [tag.id]
|
||||
|
||||
detail = events_router.get_event_details(event.id, session=session)
|
||||
assert detail.category is not None
|
||||
assert detail.category.id == category.id
|
||||
assert [item.id for item in detail.tags] == [tag.id]
|
||||
assert len(detail.observations) == 1
|
||||
|
||||
|
||||
def test_event_list_can_hide_linked_confirmation_rows(
|
||||
session: Session,
|
||||
household_setup: dict[str, str],
|
||||
) -> None:
|
||||
primary = models.FinancialEvent(
|
||||
household_id=household_setup["household_id"],
|
||||
event_type=FinancialEventType.EXPENSE,
|
||||
status=FinancialEventStatus.CONFIRMED,
|
||||
event_date=date(2026, 4, 3),
|
||||
amount=Decimal("18.99"),
|
||||
currency="EUR",
|
||||
merchant="Example Shop",
|
||||
)
|
||||
confirming = models.FinancialEvent(
|
||||
household_id=household_setup["household_id"],
|
||||
event_type=FinancialEventType.EXPENSE,
|
||||
status=FinancialEventStatus.CONFIRMED,
|
||||
event_date=date(2026, 4, 4),
|
||||
amount=Decimal("18.99"),
|
||||
currency="EUR",
|
||||
merchant="PayPal",
|
||||
)
|
||||
session.add_all([primary, confirming])
|
||||
session.flush()
|
||||
session.add(
|
||||
models.EventRelation(
|
||||
from_event_id=primary.id,
|
||||
to_event_id=confirming.id,
|
||||
relation_type=RelationType.SAME_ECONOMIC_EVENT_AS,
|
||||
confidence=Decimal("0.93"),
|
||||
created_by="test",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
all_events = events_router.list_events(household_setup["household_id"], session=session)
|
||||
visible_events = events_router.list_events(
|
||||
household_setup["household_id"],
|
||||
hide_related_confirmations=True,
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert {item.id for item in all_events} == {primary.id, confirming.id}
|
||||
assert {item.id for item in visible_events} == {primary.id}
|
||||
Reference in New Issue
Block a user