402 lines
14 KiB
Python
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
|