Files
mousehold/tests/test_reconciliation.py
2026-06-28 13:48:15 +02:00

402 lines
14 KiB
Python

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