Files
mousehold/apps/api/mousehold_api/services/ingestion.py
2026-06-28 13:48:15 +02:00

193 lines
6.4 KiB
Python

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