first ruggedy draft
This commit is contained in:
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