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