first ruggedy draft
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user