from __future__ import annotations import uuid from datetime import UTC, date, datetime from decimal import Decimal import pytest from sqlalchemy import create_engine, func, select from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool import app.models # noqa: F401 - 注册完整 metadata from app.api.deps import CurrentUserContext from app.db.base_class import Base from app.models.expense_case import BusinessEvent from app.models.financial_connector import ( FinancialConnectorConfig, FinancialConnectorEvent, PaymentReconciliationCase, PaymentReconciliationEvent, ) from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.models.savings import SavingsEvidenceLink, SavingsRealization from app.schemas.financial_connector import ( FinancialEventEnvelope, PaymentReconciliationActionCreate, ) from app.schemas.savings import SavingsRealizationActionCreate from app.services.expense_cases import ExpenseCaseService from app.services.financial_connector_auth import ( FinancialConnectorAuthError, FinancialConnectorSecretResolver, sign_financial_event, ) from app.services.financial_connector_ingestion import ( FinancialConnectorConflictError, FinancialConnectorIngestionService, ) from app.services.financial_connector_projection import ( FinancialConnectorProjectionService, FinancialReconciliationPermissionError, ) from app.services.savings_discovery import SavingsDiscoveryService from app.services.savings_realization import SavingsRealizationService @pytest.fixture() def db() -> Session: engine = create_engine( "sqlite+pysqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Base.metadata.create_all(engine) factory = sessionmaker(bind=engine, expire_on_commit=False) with factory() as session: yield session Base.metadata.drop_all(engine) engine.dispose() def test_hmac_is_tenant_bound_time_bounded_and_secret_never_persisted(db: Session) -> None: _config(db, tenant_id="tenant-a", environment="mock") claim = _claim(db, tenant_id="tenant-a") envelope = _event(claim, tenant_id="tenant-a", event_id="settled-auth-001") timestamp = 1_800_000_000 signature = sign_financial_event( envelope, timestamp=timestamp, secret="server-only-secret", tenant_id="tenant-a", provider="mock-bank", key_version="v1", ) with pytest.raises(FinancialConnectorAuthError, match="租户"): _service(db, timestamp).ingest( envelope, tenant_header="tenant-b", provider_header="mock-bank", key_version_header="v1", timestamp_header=str(timestamp), signature_header=signature, ) with pytest.raises(FinancialConnectorAuthError) as expired: _service(db, timestamp + 301).ingest( envelope, tenant_header="tenant-a", provider_header="mock-bank", key_version_header="v1", timestamp_header=str(timestamp), signature_header=signature, ) assert expired.value.code == "timestamp_outside_window" with pytest.raises(FinancialConnectorAuthError) as invalid: _service(db, timestamp).ingest( envelope, tenant_header="tenant-a", provider_header="mock-bank", key_version_header="v1", timestamp_header=str(timestamp), signature_header="sha256=" + "0" * 64, ) assert invalid.value.code == "signature_invalid" config = db.scalar(select(FinancialConnectorConfig)) assert config is not None assert config.secret_ref == "connector/test-key" assert "server-only-secret" not in str(config.__dict__) with pytest.raises(ValueError, match="未允许字段"): FinancialEventEnvelope( **{ **envelope.model_dump(), "payload": {**envelope.payload, "bank_account_number": "sensitive"}, } ) def test_hmac_binds_provider_and_key_version_even_when_secret_is_shared( db: Session, ) -> None: _config(db, tenant_id="tenant-a", provider="mock-bank", key_version="v1") _config(db, tenant_id="tenant-a", provider="alias-bank", key_version="v2") claim = _claim(db, tenant_id="tenant-a") envelope = _event(claim, tenant_id="tenant-a", event_id="source-bound-001") timestamp = 1_800_000_000 signature = sign_financial_event( envelope, timestamp=timestamp, secret="server-only-secret", tenant_id="tenant-a", provider="mock-bank", key_version="v1", ) service = _service(db, timestamp) with pytest.raises(FinancialConnectorAuthError) as replayed_to_alias: service.ingest( envelope, tenant_header="tenant-a", provider_header="alias-bank", key_version_header="v2", timestamp_header=str(timestamp), signature_header=signature, ) assert replayed_to_alias.value.code == "signature_invalid" def test_settlement_replay_erp_reversal_and_savings_evidence_e2e(db: Session) -> None: _config(db, environment="production") claim = _claim(db) application = _linked_application(db, claim) _savings_opportunity(db, claim) db.commit() timestamp = 1_800_000_000 settlement = _event(claim, event_id="settled-e2e-001") first = _ingest(db, settlement, timestamp) db.commit() replay = _ingest(db, settlement, timestamp) db.commit() db.refresh(claim) assert first.replayed is False assert replay.replayed is True assert first.event_id == replay.event_id assert first.processing_status == "processed" assert first.verification_level == "production_verified" assert first.evidence_classification == "external_cash" assert first.projection_scope == "canonical" assert claim.status == "paid" db.refresh(application) assert application.approval_stage == "申请归档" assert db.scalar(select(func.count(FinancialConnectorEvent.id))) == 1 assert db.scalar(select(func.count(PaymentReconciliationEvent.id))) == 1 connector_fact = db.get(FinancialConnectorEvent, first.event_id) assert connector_fact is not None assert "claim_reference" not in connector_fact.normalized_payload_json assert claim.claim_no not in str(connector_fact.normalized_payload_json) realization = db.scalar( select(SavingsRealization).where(SavingsRealization.claim_id == claim.id) ) assert realization is not None evidence = db.scalar( select(SavingsEvidenceLink).where( SavingsEvidenceLink.realization_id == realization.id, SavingsEvidenceLink.resource_type == "financial_connector_event", ) ) assert evidence is not None assert evidence.resource_id == first.event_id assert evidence.source_system == "mock-bank" assert evidence.metadata_json["evidence_level"] == "external_cash" SavingsRealizationService(db).execute_action( realization.id, SavingsRealizationActionCreate( action="confirm", request_id="connector-confirm-001", expected_version=1, comment="独立财务已核验生产连接器回执与政策基线。", ), _user("independent-confirmer", roles=["finance"]), ) invalid_erp = _event( claim, event_id="erp-e2e-invalid-001", event_type="erp_posted", extra={ "amount": "65.00", "origin_external_event_id": settlement.external_event_id, "erp_document_number": "ERP-VOUCHER-INVALID", }, ) invalid_erp_result = _ingest(db, invalid_erp, timestamp + 1) db.commit() db.refresh(claim) assert invalid_erp_result.processing_status == "exception" assert invalid_erp_result.error_code == "erp_amount_mismatch" assert claim.status == "paid" assert "erp_posted" not in set(db.scalars(select(BusinessEvent.event_type)).all()) erp = _event( claim, event_id="erp-e2e-001", event_type="erp_posted", extra={ "origin_external_event_id": settlement.external_event_id, "erp_document_number": "ERP-VOUCHER-2026-000991", "accounting_period": "2026-07", }, ) erp = FinancialEventEnvelope.model_validate( { **erp.model_dump(), "payload": { key: value for key, value in erp.payload.items() if key != "external_payment_reference" }, } ) erp_result = _ingest(db, erp, timestamp + 2) db.commit() db.refresh(claim) case = db.get(PaymentReconciliationCase, first.reconciliation_case_id) assert erp_result.processing_status == "processed" assert claim.status == "paid" assert case is not None and case.erp_status == "posted" assert case.erp_document_tail == "6-000991" assert "ERP-VOUCHER" not in str(case.__dict__) reversal = _event( claim, event_id="refund-e2e-001", event_type="payment_refunded", extra={"origin_external_event_id": settlement.external_event_id}, ) reversed_result = _ingest(db, reversal, timestamp + 3) db.commit() db.refresh(claim) db.refresh(case) assert reversed_result.processing_status == "processed" assert claim.status == "pending_payment" assert case.status == "reopened" db.refresh(realization) reversal_realization = db.scalar( select(SavingsRealization).where( SavingsRealization.reversal_of_realization_id == realization.id ) ) assert realization.reversed_at is not None assert reversal_realization is not None assert reversal_realization.actual_net == -realization.actual_net assert any( item.get("role") == "external_payment_reversal" and item.get("verification_status") == "verified" for item in reversal_realization.evidence_json ) event_types = set(db.scalars(select(BusinessEvent.event_type)).all()) assert {"payment_completed", "erp_posted", "payment_reversed"} <= event_types second_settlement = _event(claim, event_id="settled-e2e-002") second_result = _ingest(db, second_settlement, timestamp + 4) db.commit() db.refresh(claim) db.refresh(case) assert second_result.processing_status == "processed" assert claim.status == "paid" assert case.status == "matched" assert case.erp_status == "pending_posting" assert db.scalar(select(func.count(FinancialConnectorEvent.id))) == 5 def test_mismatch_failure_and_conflicting_replay_have_no_payment_side_effect(db: Session) -> None: _config(db) claim = _claim(db) db.commit() timestamp = 1_800_000_000 mismatch = _event( claim, event_id="settled-mismatch-001", extra={"amount": "999.00"}, ) result = _ingest(db, mismatch, timestamp) db.commit() db.refresh(claim) assert result.processing_status == "exception" assert result.error_code == "amount_mismatch" assert claim.status == "pending_payment" assert db.scalar(select(func.count(BusinessEvent.id))) == 0 conflict = _event( claim, event_id="settled-mismatch-001", extra={"amount": "998.00"}, ) with pytest.raises(FinancialConnectorConflictError): _ingest(db, conflict, timestamp + 1) db.rollback() db.refresh(claim) assert claim.status == "pending_payment" assert db.scalar(select(func.count(FinancialConnectorEvent.id))) == 1 failed = _event( claim, event_id="payment-failed-001", event_type="payment_failed", extra={"failure_code": "provider_timeout"}, ) failed_result = _ingest(db, failed, timestamp + 2) db.commit() db.refresh(claim) assert failed_result.error_code == "external_payment_failed" assert claim.status == "pending_payment" @pytest.mark.parametrize("environment", ["test", "mock", "staging"]) def test_non_production_events_only_create_simulation_facts( db: Session, environment: str, ) -> None: _config(db, environment=environment) claim = _claim(db) application = _linked_application(db, claim) _savings_opportunity(db, claim) db.commit() initial_business_event_count = int( db.scalar(select(func.count(BusinessEvent.id))) or 0 ) timestamp = 1_800_000_000 settlement = _event(claim, event_id="pending-saving-settled-001") settlement_result = _ingest(db, settlement, timestamp) db.commit() db.refresh(claim) db.refresh(application) assert settlement_result.processing_status == "processed" assert settlement_result.projection_scope == "simulation_only" assert settlement_result.reconciliation_case_id is None assert settlement_result.claim_status == "pending_payment" assert claim.status == "pending_payment" assert application.approval_stage == "审批完成" failed = _event( claim, event_id="pending-saving-failed-001", event_type="payment_failed", extra={"failure_code": "simulated_provider_failure"}, ) failed_result = _ingest(db, failed, timestamp + 1) db.commit() assert failed_result.processing_status == "exception" assert failed_result.error_code == "external_payment_failed" assert failed_result.projection_scope == "simulation_only" erp = _event( claim, event_id="pending-saving-erp-001", event_type="erp_posted", extra={ "origin_external_event_id": settlement.external_event_id, "erp_document_number": "ERP-SIMULATED-001", }, ) erp_result = _ingest(db, erp, timestamp + 2) db.commit() assert erp_result.processing_status == "processed" assert erp_result.projection_scope == "simulation_only" erp_failed = _event( claim, event_id="pending-saving-erp-failed-001", event_type="erp_posting_failed", extra={ "origin_external_event_id": settlement.external_event_id, "failure_code": "simulated_erp_failure", }, ) erp_failed_result = _ingest(db, erp_failed, timestamp + 3) db.commit() assert erp_failed_result.processing_status == "exception" assert erp_failed_result.error_code == "erp_posting_failed" assert erp_failed_result.projection_scope == "simulation_only" refund = _event( claim, event_id="pending-saving-refund-001", event_type="payment_refunded", extra={"origin_external_event_id": settlement.external_event_id}, ) refund_result = _ingest(db, refund, timestamp + 4) db.commit() db.refresh(claim) assert refund_result.processing_status == "processed" assert refund_result.projection_scope == "simulation_only" reversal = _event( claim, event_id="pending-saving-reversal-001", event_type="payment_reversed", extra={"origin_external_event_id": settlement.external_event_id}, ) reversal_result = _ingest(db, reversal, timestamp + 5) db.commit() assert reversal_result.processing_status == "processed" assert reversal_result.projection_scope == "simulation_only" assert claim.status == "pending_payment" assert db.scalar(select(func.count(PaymentReconciliationCase.id))) == 0 assert db.scalar(select(func.count(PaymentReconciliationEvent.id))) == 0 assert db.scalar(select(func.count(BusinessEvent.id))) == initial_business_event_count assert db.scalar(select(func.count(SavingsRealization.id))) == 0 facts = list(db.scalars(select(FinancialConnectorEvent)).all()) assert len(facts) == 6 assert all(item.expense_case_id is None for item in facts) assert all("claim_reference" not in item.normalized_payload_json for item in facts) def test_cross_tenant_claim_is_hidden_and_finance_actions_are_separated(db: Session) -> None: _config(db, tenant_id="tenant-a") _config(db, tenant_id="tenant-b", secret_ref="connector/tenant-b") claim = _claim(db, tenant_id="tenant-a", employee_name="申请人甲") db.commit() timestamp = 1_800_000_000 cross = _event(claim, tenant_id="tenant-b", event_id="cross-tenant-001") signature = sign_financial_event( cross, timestamp=timestamp, secret="tenant-b-secret-strong", tenant_id="tenant-b", provider="mock-bank", key_version="v1", ) result = FinancialConnectorIngestionService( db, secrets=FinancialConnectorSecretResolver( { "connector/test-key": "server-only-secret", "connector/tenant-b": "tenant-b-secret-strong", } ), now_epoch=timestamp, ).ingest( cross, tenant_header="tenant-b", provider_header="mock-bank", key_version_header="v1", timestamp_header=str(timestamp), signature_header=signature, ) db.commit() db.refresh(claim) assert result.error_code == "claim_not_found" assert result.reconciliation_case_id is None assert claim.status == "pending_payment" finance = _user("finance-a", tenant_id="tenant-a", roles=["finance"]) outsider = _user("employee-a", tenant_id="tenant-a") with pytest.raises(FinancialReconciliationPermissionError): FinancialConnectorProjectionService(db).list_cases( outsider, status_filter=None, page=1, page_size=20, ) tenant_a_cases = FinancialConnectorProjectionService(db).list_cases( finance, status_filter=None, page=1, page_size=20, ) assert tenant_a_cases.total == 0 def test_production_reversal_cannot_reference_simulation_origin(db: Session) -> None: _config(db, environment="mock", key_version="v1") _config(db, environment="production", key_version="v2") claim = _claim(db) db.commit() timestamp = 1_800_000_000 simulated = _event(claim, event_id="simulated-origin-001") simulated_result = _ingest(db, simulated, timestamp) db.commit() assert simulated_result.projection_scope == "simulation_only" reversal = _event( claim, event_id="production-reversal-001", event_type="payment_reversed", extra={"origin_external_event_id": simulated.external_event_id}, ) signature = sign_financial_event( reversal, timestamp=timestamp + 1, secret="server-only-secret", tenant_id="default", provider="mock-bank", key_version="v2", ) result = FinancialConnectorIngestionService( db, secrets=FinancialConnectorSecretResolver( {"connector/test-key": "server-only-secret"} ), now_epoch=timestamp + 1, ).ingest( reversal, tenant_header="default", provider_header="mock-bank", key_version_header="v2", timestamp_header=str(timestamp + 1), signature_header=signature, ) db.commit() db.refresh(claim) assert result.projection_scope == "canonical" assert result.error_code == "origin_settlement_not_found" assert claim.status == "pending_payment" assert result.reconciliation_case_id is None def test_applicant_cannot_confirm_own_exception(db: Session) -> None: _config(db, environment="production") employee_id = str(uuid.uuid4()) claim = _claim(db, employee_id=employee_id, employee_name="申请人甲") db.commit() mismatch = _event(claim, event_id="own-exception-001", extra={"amount": "2.00"}) result = _ingest(db, mismatch, 1_800_000_000) db.commit() case = db.get(PaymentReconciliationCase, result.reconciliation_case_id) assert case is not None applicant_finance = _user( "applicant-finance", roles=["finance"], employee_id=employee_id, name="申请人甲", ) with pytest.raises(FinancialReconciliationPermissionError): FinancialConnectorProjectionService(db).resolve( case.id, PaymentReconciliationActionCreate( expected_version=case.version, reason="本人尝试确认自己的异常", ), applicant_finance, action="confirmed", ) db.rollback() db.refresh(case) independent_finance = _user("independent-finance", roles=["finance"]) resolved = FinancialConnectorProjectionService(db).resolve( case.id, PaymentReconciliationActionCreate( expected_version=case.version, reason="独立财务确认该差异仅作为处置结论", ), independent_finance, action="confirmed", ) db.commit() db.refresh(claim) assert resolved is not None and resolved.status == "confirmed" assert claim.status == "pending_payment" def _service(db: Session, timestamp: int) -> FinancialConnectorIngestionService: return FinancialConnectorIngestionService( db, secrets=FinancialConnectorSecretResolver( {"connector/test-key": "server-only-secret"} ), now_epoch=timestamp, ) def _ingest( db: Session, envelope: FinancialEventEnvelope, timestamp: int, ): signature = sign_financial_event( envelope, timestamp=timestamp, secret="server-only-secret", tenant_id=envelope.tenant_id, provider="mock-bank", key_version="v1", ) return _service(db, timestamp).ingest( envelope, tenant_header=envelope.tenant_id, provider_header="mock-bank", key_version_header="v1", timestamp_header=str(timestamp), signature_header=signature, ) def _config( db: Session, *, tenant_id: str = "default", environment: str = "mock", secret_ref: str = "connector/test-key", provider: str = "mock-bank", key_version: str = "v1", ) -> FinancialConnectorConfig: row = FinancialConnectorConfig( id=str(uuid.uuid4()), tenant_id=tenant_id, provider=provider, environment=environment, key_version=key_version, secret_ref=secret_ref, allowed_event_types_json=[ "payment_settled", "payment_failed", "erp_posted", "erp_posting_failed", "payment_refunded", "payment_reversed", ], clock_skew_seconds=300, status="active", created_by="platform-admin", ) db.add(row) db.flush() return row def _claim( db: Session, *, tenant_id: str = "default", employee_id: str | None = None, employee_name: str = "测试员工", ) -> ExpenseClaim: claim = ExpenseClaim( id=str(uuid.uuid4()), claim_no=f"BX-CONNECTOR-{uuid.uuid4().hex[:10].upper()}", employee_id=employee_id, employee_name=employee_name, department_name="财务测试部", expense_type="travel", reason="连接器端到端测试", location="上海", amount=Decimal("66.00"), currency="CNY", invoice_count=1, occurred_at=datetime.now(UTC), submitted_at=datetime.now(UTC), status="pending_payment", approval_stage="待付款", risk_flags_json=[], ) db.add(claim) db.flush() if tenant_id != "default": ExpenseCaseService(db).ensure_case_for_claim(claim, tenant_id=tenant_id) return claim def _event( claim: ExpenseClaim, *, tenant_id: str = "default", event_id: str, event_type: str = "payment_settled", extra: dict[str, str] | None = None, ) -> FinancialEventEnvelope: payload = { "claim_id": claim.id, "claim_reference": claim.claim_no, "amount": str(claim.amount), "currency": claim.currency, "external_payment_reference": f"PAY-{claim.claim_no}-0001", **dict(extra or {}), } return FinancialEventEnvelope( tenant_id=tenant_id, external_event_id=event_id, event_type=event_type, occurred_at=datetime.now(UTC), correlation_id=f"corr-{event_id}"[:64], payload=payload, ) def _savings_opportunity(db: Session, claim: ExpenseClaim) -> None: item = ExpenseClaimItem( id=str(uuid.uuid4()), claim=claim, item_date=date.today(), item_type="hotel", item_reason="住宿", item_location="上海", item_note="", item_amount=Decimal("100.00"), ) db.add(item) db.flush() SavingsDiscoveryService(db).discover_standard_adjustments( claim=claim, items_by_id={item.id: item}, adjustment_flags=[ { "item_id": item.id, "message": "政策调整", "original_amount": "100.00", "reimbursable_amount": "66.00", "employee_absorbed_amount": "34.00", "policy_rule_version": "connector-policy-v1", "policy_rule_version_source": "published", "policy_grade": "P6", "policy_matched_city": "上海", "calculation_fingerprint": "sha256:" + "d" * 64, } ], current_user=_user("finance-payer", roles=["finance"]), request_id="connector-discovery-001", ) def _linked_application(db: Session, reimbursement: ExpenseClaim) -> ExpenseClaim: application = ExpenseClaim( id=str(uuid.uuid4()), claim_no=f"AP-CONNECTOR-{uuid.uuid4().hex[:10].upper()}", employee_name=reimbursement.employee_name, department_name=reimbursement.department_name, expense_type="travel_application", reason="连接器端到端申请", location=reimbursement.location, amount=reimbursement.amount, currency=reimbursement.currency, invoice_count=0, occurred_at=datetime.now(UTC), submitted_at=datetime.now(UTC), status="approved", approval_stage="审批完成", risk_flags_json=[], ) db.add(application) db.flush() reimbursement.risk_flags_json = [ { "source": "application_handoff", "event_type": "expense_application_to_reimbursement_draft", "application_claim_id": application.id, "application_claim_no": application.claim_no, } ] return application def _user( username: str, *, tenant_id: str = "default", roles: list[str] | None = None, employee_id: str = "", name: str | None = None, ) -> CurrentUserContext: return CurrentUserContext( username=username, name=name or username, role_codes=list(roles or []), is_admin=False, tenant_id=tenant_id, employee_id=employee_id, )