Files
X-Financial/server/tests/test_financial_connector_services.py

789 lines
26 KiB
Python
Raw Normal View History

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,
)