from __future__ import annotations from datetime import UTC, date, datetime import json import pytest from govoplan_erp.backend.manifest import get_manifest from govoplan_erp.backend.payables import ( BookingObservation, ErpPayableProfile, PayableExportInput, build_payable_export_plan, reconcile_booking, ) def _profile() -> ErpPayableProfile: return ErpPayableProfile( profile_id="reviewed-target-v1", provider_id="erp-target", company_code="1000", payable_schema="govoplan-neutral-payable", payable_schema_version="1", mapping_revision="mapping-3", connection_ref="core-credential:erp-target", booking_status_mapping=( ("RECEIVED", "received"), ("POSTED", "booked"), ("REJECTED", "rejected"), ("REVERSED", "reversed"), ), ) def _payable() -> PayableExportInput: return PayableExportInput( tenant_id="tenant-a", source_module="payments", payable_id="payable-7", payable_revision=3, invoice_reference="invoice-2026-7", creditor_reference="creditor-4", currency="eur", gross_amount_minor=123_45, due_date=date(2026, 9, 15), cost_center_reference="cost-12", budget_reference="budget-2026", invoice_document_sha256="a" * 64, evidence_references=("xrechnung:handoff-8",), ) def _observation(plan, *, status: str = "POSTED", plan_sha256: str | None = None): return BookingObservation( tenant_id=plan.tenant_id, payable_id=plan.payable_id, provider_id=plan.provider_id, external_booking_id="booking-99", raw_status=status, external_revision="external-4", observed_at=datetime(2026, 8, 23, 12, tzinfo=UTC), exported_plan_sha256=plan_sha256 or plan.plan_sha256, evidence_sha256="b" * 64, currency="EUR", gross_amount_minor=123_45, ) def test_plan_is_canonical_digest_bound_and_effect_free() -> None: first = build_payable_export_plan(_profile(), _payable()) second = build_payable_export_plan(_profile(), _payable()) assert first == second assert first.dispatch_allowed is False assert first.idempotency_key.startswith("erp-payable:") payload = json.loads(first.payload_json) assert payload["payable"]["gross_amount_minor"] == 12345 assert payload["mapping_revision"] == "mapping-3" def test_money_rejects_floating_point_and_invalid_currency() -> None: values = _payable().to_payload() with pytest.raises(ValueError, match="integer minor units"): PayableExportInput( **{ **values, "due_date": date(2026, 9, 15), "gross_amount_minor": 123.45, "evidence_references": (), } ) @pytest.mark.parametrize( ("raw_status", "outcome", "action"), [ ("RECEIVED", "pending", "wait"), ("POSTED", "booked", "record_booking"), ("REJECTED", "rejected", "record_rejection"), ("REVERSED", "reversed", "record_reversal"), ], ) def test_reconciliation_maps_reviewed_statuses(raw_status, outcome, action) -> None: profile = _profile() plan = build_payable_export_plan(profile, _payable()) decision = reconcile_booking(profile, plan, _observation(plan, status=raw_status)) assert decision.outcome == outcome assert decision.action == action assert len(decision.decision_sha256) == 64 def test_unknown_or_wrong_plan_observation_is_quarantined() -> None: profile = _profile() plan = build_payable_export_plan(profile, _payable()) unknown = reconcile_booking(profile, plan, _observation(plan, status="NEW-VENDOR-STATE")) wrong_plan = reconcile_booking( profile, plan, _observation(plan, plan_sha256="c" * 64), ) assert (unknown.outcome, unknown.action) == ("conflict", "quarantine") assert (wrong_plan.outcome, wrong_plan.action) == ("conflict", "quarantine") def test_manifest_documents_unconfigured_provider_and_german_workflow() -> None: manifest = get_manifest() assert manifest.version == "0.1.20" assert manifest.external_providers[0].id == "erp.payables_target" assert manifest.architecture is not None assert manifest.architecture.target_tested_providers == () assert manifest.documentation[0].translations["de"]["title"]