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