first ruggedy draft

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

57
tests/conftest.py Normal file
View 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,
}

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

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

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

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

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

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