376 lines
16 KiB
Python
376 lines
16 KiB
Python
|
|
"""add tenant-safe financial connector and reconciliation ledger
|
||
|
|
|
||
|
|
Revision ID: 20260716_0017
|
||
|
|
Revises: 20260716_0016
|
||
|
|
Create Date: 2026-07-16 20:00:00
|
||
|
|
"""
|
||
|
|
|
||
|
|
from collections.abc import Sequence
|
||
|
|
|
||
|
|
import sqlalchemy as sa
|
||
|
|
|
||
|
|
from alembic import op
|
||
|
|
|
||
|
|
revision: str = "20260716_0017"
|
||
|
|
down_revision: str | None = "20260716_0016"
|
||
|
|
branch_labels: str | Sequence[str] | None = None
|
||
|
|
depends_on: str | Sequence[str] | None = None
|
||
|
|
|
||
|
|
|
||
|
|
def _require_postgresql() -> None:
|
||
|
|
dialect_name = op.get_bind().dialect.name
|
||
|
|
if dialect_name != "postgresql":
|
||
|
|
raise RuntimeError(
|
||
|
|
"20260716_0017 only supports PostgreSQL; "
|
||
|
|
f"refusing to mutate {dialect_name} without transactional constraint DDL"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _require_empty_connector_domain_for_downgrade() -> None:
|
||
|
|
bind = op.get_bind()
|
||
|
|
counts = {
|
||
|
|
table_name: int(bind.scalar(sa.text(f"SELECT COUNT(*) FROM {table_name}")) or 0)
|
||
|
|
for table_name in (
|
||
|
|
"financial_connector_configs",
|
||
|
|
"financial_connector_events",
|
||
|
|
"payment_reconciliation_cases",
|
||
|
|
"payment_reconciliation_events",
|
||
|
|
)
|
||
|
|
}
|
||
|
|
if any(counts.values()):
|
||
|
|
summary = ", ".join(f"{name}={count}" for name, count in counts.items())
|
||
|
|
raise RuntimeError(
|
||
|
|
"cannot downgrade financial connector: configurations or immutable facts exist "
|
||
|
|
f"({summary})"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def upgrade() -> None:
|
||
|
|
_require_postgresql()
|
||
|
|
op.create_table(
|
||
|
|
"financial_connector_configs",
|
||
|
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||
|
|
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||
|
|
sa.Column("provider", sa.String(length=80), nullable=False),
|
||
|
|
sa.Column("environment", sa.String(length=16), nullable=False),
|
||
|
|
sa.Column("key_version", sa.String(length=40), nullable=False),
|
||
|
|
sa.Column("secret_ref", sa.String(length=180), nullable=False),
|
||
|
|
sa.Column("allowed_event_types_json", sa.JSON(), nullable=False),
|
||
|
|
sa.Column("clock_skew_seconds", sa.Integer(), nullable=False),
|
||
|
|
sa.Column("status", sa.String(length=16), nullable=False),
|
||
|
|
sa.Column("last_success_at", sa.DateTime(timezone=True), nullable=True),
|
||
|
|
sa.Column("last_error_at", sa.DateTime(timezone=True), nullable=True),
|
||
|
|
sa.Column("last_error_code", sa.String(length=80), nullable=True),
|
||
|
|
sa.Column("created_by", sa.String(length=120), nullable=False),
|
||
|
|
sa.Column(
|
||
|
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||
|
|
),
|
||
|
|
sa.Column(
|
||
|
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||
|
|
),
|
||
|
|
sa.CheckConstraint(
|
||
|
|
"environment IN ('test', 'mock', 'staging', 'production')",
|
||
|
|
name="ck_financial_connector_configs_environment",
|
||
|
|
),
|
||
|
|
sa.CheckConstraint(
|
||
|
|
"status IN ('active', 'disabled', 'rotating')",
|
||
|
|
name="ck_financial_connector_configs_status",
|
||
|
|
),
|
||
|
|
sa.CheckConstraint(
|
||
|
|
"clock_skew_seconds BETWEEN 30 AND 900",
|
||
|
|
name="ck_financial_connector_configs_clock_skew",
|
||
|
|
),
|
||
|
|
sa.CheckConstraint(
|
||
|
|
"length(trim(provider)) > 0 AND length(trim(key_version)) > 0 "
|
||
|
|
"AND length(trim(secret_ref)) > 0",
|
||
|
|
name="ck_financial_connector_configs_keys",
|
||
|
|
),
|
||
|
|
sa.PrimaryKeyConstraint("id"),
|
||
|
|
sa.UniqueConstraint("tenant_id", "id", name="uq_financial_connector_configs_tenant_id"),
|
||
|
|
sa.UniqueConstraint(
|
||
|
|
"tenant_id",
|
||
|
|
"provider",
|
||
|
|
"key_version",
|
||
|
|
name="uq_financial_connector_configs_tenant_provider_key",
|
||
|
|
),
|
||
|
|
)
|
||
|
|
op.create_index(
|
||
|
|
"ix_financial_connector_configs_tenant_status",
|
||
|
|
"financial_connector_configs",
|
||
|
|
["tenant_id", "status", "provider"],
|
||
|
|
)
|
||
|
|
|
||
|
|
op.create_table(
|
||
|
|
"financial_connector_events",
|
||
|
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||
|
|
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||
|
|
sa.Column("config_id", sa.String(length=36), nullable=False),
|
||
|
|
sa.Column("provider", sa.String(length=80), nullable=False),
|
||
|
|
sa.Column("environment", sa.String(length=16), nullable=False),
|
||
|
|
sa.Column("direction", sa.String(length=12), nullable=False),
|
||
|
|
sa.Column("external_event_id", sa.String(length=160), nullable=False),
|
||
|
|
sa.Column("event_type", sa.String(length=40), nullable=False),
|
||
|
|
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
|
||
|
|
sa.Column(
|
||
|
|
"received_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||
|
|
),
|
||
|
|
sa.Column("key_version", sa.String(length=40), nullable=False),
|
||
|
|
sa.Column("verification_level", sa.String(length=32), nullable=False),
|
||
|
|
sa.Column("request_fingerprint", sa.String(length=80), nullable=False),
|
||
|
|
sa.Column("content_hash", sa.String(length=80), nullable=False),
|
||
|
|
sa.Column("processing_status", sa.String(length=20), nullable=False),
|
||
|
|
sa.Column("error_code", sa.String(length=80), nullable=True),
|
||
|
|
sa.Column("claim_id", sa.String(length=36), nullable=True),
|
||
|
|
sa.Column("expense_case_id", sa.String(length=36), nullable=True),
|
||
|
|
sa.Column("origin_event_id", sa.String(length=36), nullable=True),
|
||
|
|
sa.Column("correlation_id", sa.String(length=64), nullable=False),
|
||
|
|
sa.Column("external_reference_tail", sa.String(length=8), nullable=True),
|
||
|
|
sa.Column("normalized_payload_json", sa.JSON(), nullable=False),
|
||
|
|
sa.Column("response_json", sa.JSON(), nullable=False),
|
||
|
|
sa.CheckConstraint("direction = 'inbound'", name="ck_financial_connector_events_direction"),
|
||
|
|
sa.CheckConstraint(
|
||
|
|
"event_type IN ('payment_settled', 'payment_failed', 'erp_posted', "
|
||
|
|
"'erp_posting_failed', 'payment_refunded', 'payment_reversed')",
|
||
|
|
name="ck_financial_connector_events_type",
|
||
|
|
),
|
||
|
|
sa.CheckConstraint(
|
||
|
|
"environment IN ('test', 'mock', 'staging', 'production')",
|
||
|
|
name="ck_financial_connector_events_environment",
|
||
|
|
),
|
||
|
|
sa.CheckConstraint(
|
||
|
|
"verification_level IN ('simulated', 'staging_verified', 'production_verified')",
|
||
|
|
name="ck_financial_connector_events_verification",
|
||
|
|
),
|
||
|
|
sa.CheckConstraint(
|
||
|
|
"processing_status IN ('processed', 'exception', 'pending')",
|
||
|
|
name="ck_financial_connector_events_processing_status",
|
||
|
|
),
|
||
|
|
sa.CheckConstraint(
|
||
|
|
"length(trim(external_event_id)) > 0 "
|
||
|
|
"AND length(trim(request_fingerprint)) >= 16 "
|
||
|
|
"AND length(trim(content_hash)) >= 16",
|
||
|
|
name="ck_financial_connector_events_fingerprints",
|
||
|
|
),
|
||
|
|
sa.CheckConstraint(
|
||
|
|
"(event_type IN ('payment_refunded', 'payment_reversed', "
|
||
|
|
"'erp_posted', 'erp_posting_failed') "
|
||
|
|
"AND (origin_event_id IS NOT NULL OR processing_status = 'exception')) "
|
||
|
|
"OR (event_type IN ('payment_settled', 'payment_failed') "
|
||
|
|
"AND origin_event_id IS NULL)",
|
||
|
|
name="ck_financial_connector_events_origin",
|
||
|
|
),
|
||
|
|
sa.ForeignKeyConstraint(
|
||
|
|
["tenant_id", "config_id"],
|
||
|
|
["financial_connector_configs.tenant_id", "financial_connector_configs.id"],
|
||
|
|
name="fk_financial_connector_events_tenant_config",
|
||
|
|
ondelete="RESTRICT",
|
||
|
|
),
|
||
|
|
sa.ForeignKeyConstraint(
|
||
|
|
["tenant_id", "expense_case_id"],
|
||
|
|
["expense_cases.tenant_id", "expense_cases.id"],
|
||
|
|
name="fk_financial_connector_events_tenant_expense_case",
|
||
|
|
ondelete="RESTRICT",
|
||
|
|
),
|
||
|
|
sa.ForeignKeyConstraint(
|
||
|
|
["tenant_id", "origin_event_id"],
|
||
|
|
["financial_connector_events.tenant_id", "financial_connector_events.id"],
|
||
|
|
name="fk_financial_connector_events_tenant_origin",
|
||
|
|
ondelete="RESTRICT",
|
||
|
|
),
|
||
|
|
sa.PrimaryKeyConstraint("id"),
|
||
|
|
sa.UniqueConstraint("tenant_id", "id", name="uq_financial_connector_events_tenant_id"),
|
||
|
|
sa.UniqueConstraint(
|
||
|
|
"tenant_id",
|
||
|
|
"provider",
|
||
|
|
"external_event_id",
|
||
|
|
name="uq_financial_connector_events_external_id",
|
||
|
|
),
|
||
|
|
)
|
||
|
|
op.create_index(
|
||
|
|
"ix_financial_connector_events_tenant_received",
|
||
|
|
"financial_connector_events",
|
||
|
|
["tenant_id", "received_at"],
|
||
|
|
)
|
||
|
|
op.create_index(
|
||
|
|
"ix_financial_connector_events_tenant_claim",
|
||
|
|
"financial_connector_events",
|
||
|
|
["tenant_id", "claim_id", "occurred_at"],
|
||
|
|
)
|
||
|
|
|
||
|
|
op.create_table(
|
||
|
|
"payment_reconciliation_cases",
|
||
|
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||
|
|
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||
|
|
sa.Column("provider", sa.String(length=80), nullable=False),
|
||
|
|
sa.Column("claim_id", sa.String(length=36), nullable=False),
|
||
|
|
sa.Column("expense_case_id", sa.String(length=36), nullable=True),
|
||
|
|
sa.Column("expected_amount", sa.Numeric(20, 4), nullable=False),
|
||
|
|
sa.Column("actual_amount", sa.Numeric(20, 4), nullable=False),
|
||
|
|
sa.Column("amount_difference", sa.Numeric(20, 4), nullable=False),
|
||
|
|
sa.Column("expected_currency", sa.String(length=3), nullable=False),
|
||
|
|
sa.Column("actual_currency", sa.String(length=3), nullable=False),
|
||
|
|
sa.Column("expected_reference", sa.String(length=160), nullable=False),
|
||
|
|
sa.Column("external_reference_tail", sa.String(length=8), nullable=True),
|
||
|
|
sa.Column("status", sa.String(length=20), nullable=False),
|
||
|
|
sa.Column("exception_code", sa.String(length=80), nullable=True),
|
||
|
|
sa.Column("erp_status", sa.String(length=20), nullable=False),
|
||
|
|
sa.Column("erp_document_tail", sa.String(length=8), nullable=True),
|
||
|
|
sa.Column("erp_document_hash", sa.String(length=80), nullable=True),
|
||
|
|
sa.Column("assigned_to", sa.String(length=120), nullable=True),
|
||
|
|
sa.Column("last_connector_event_id", sa.String(length=36), nullable=False),
|
||
|
|
sa.Column("version", sa.Integer(), nullable=False),
|
||
|
|
sa.Column(
|
||
|
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||
|
|
),
|
||
|
|
sa.Column(
|
||
|
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||
|
|
),
|
||
|
|
sa.CheckConstraint(
|
||
|
|
"status IN ('pending', 'matched', 'exception', 'confirmed', "
|
||
|
|
"'rejected', 'reopened', 'closed')",
|
||
|
|
name="ck_payment_reconciliation_cases_status",
|
||
|
|
),
|
||
|
|
sa.CheckConstraint(
|
||
|
|
"erp_status IN ('pending_posting', 'posted', 'posting_failed')",
|
||
|
|
name="ck_payment_reconciliation_cases_erp_status",
|
||
|
|
),
|
||
|
|
sa.CheckConstraint(
|
||
|
|
"expected_amount >= 0 AND actual_amount >= 0",
|
||
|
|
name="ck_payment_reconciliation_cases_amounts",
|
||
|
|
),
|
||
|
|
sa.CheckConstraint(
|
||
|
|
"length(trim(expected_currency)) = 3 AND length(trim(actual_currency)) = 3",
|
||
|
|
name="ck_payment_reconciliation_cases_currencies",
|
||
|
|
),
|
||
|
|
sa.ForeignKeyConstraint(
|
||
|
|
["tenant_id", "expense_case_id"],
|
||
|
|
["expense_cases.tenant_id", "expense_cases.id"],
|
||
|
|
name="fk_payment_reconciliation_cases_tenant_expense_case",
|
||
|
|
ondelete="RESTRICT",
|
||
|
|
),
|
||
|
|
sa.ForeignKeyConstraint(
|
||
|
|
["tenant_id", "last_connector_event_id"],
|
||
|
|
["financial_connector_events.tenant_id", "financial_connector_events.id"],
|
||
|
|
name="fk_payment_reconciliation_cases_tenant_last_event",
|
||
|
|
ondelete="RESTRICT",
|
||
|
|
),
|
||
|
|
sa.PrimaryKeyConstraint("id"),
|
||
|
|
sa.UniqueConstraint("tenant_id", "id", name="uq_payment_reconciliation_cases_tenant_id"),
|
||
|
|
sa.UniqueConstraint(
|
||
|
|
"tenant_id",
|
||
|
|
"provider",
|
||
|
|
"claim_id",
|
||
|
|
name="uq_payment_reconciliation_cases_tenant_provider_claim",
|
||
|
|
),
|
||
|
|
)
|
||
|
|
op.create_index(
|
||
|
|
"ix_payment_reconciliation_cases_tenant_status",
|
||
|
|
"payment_reconciliation_cases",
|
||
|
|
["tenant_id", "status", "updated_at"],
|
||
|
|
)
|
||
|
|
|
||
|
|
op.create_table(
|
||
|
|
"payment_reconciliation_events",
|
||
|
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||
|
|
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||
|
|
sa.Column("reconciliation_case_id", sa.String(length=36), nullable=False),
|
||
|
|
sa.Column("connector_event_id", sa.String(length=36), nullable=False),
|
||
|
|
sa.Column("action", sa.String(length=32), nullable=False),
|
||
|
|
sa.Column("actor_type", sa.String(length=20), nullable=False),
|
||
|
|
sa.Column("actor_id", sa.String(length=120), nullable=False),
|
||
|
|
sa.Column("request_fingerprint", sa.String(length=80), nullable=False),
|
||
|
|
sa.Column("before_json", sa.JSON(), nullable=False),
|
||
|
|
sa.Column("after_json", sa.JSON(), nullable=False),
|
||
|
|
sa.Column("response_json", sa.JSON(), nullable=False),
|
||
|
|
sa.Column("reason", sa.Text(), nullable=True),
|
||
|
|
sa.Column("correlation_id", sa.String(length=64), nullable=False),
|
||
|
|
sa.Column(
|
||
|
|
"occurred_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||
|
|
),
|
||
|
|
sa.CheckConstraint(
|
||
|
|
"action IN ('auto_matched', 'exception_created', 'erp_posted', "
|
||
|
|
"'erp_posting_failed', 'reopened', 'confirmed', 'rejected', 'closed')",
|
||
|
|
name="ck_payment_reconciliation_events_action",
|
||
|
|
),
|
||
|
|
sa.CheckConstraint(
|
||
|
|
"length(trim(request_fingerprint)) >= 16",
|
||
|
|
name="ck_payment_reconciliation_events_fingerprint",
|
||
|
|
),
|
||
|
|
sa.ForeignKeyConstraint(
|
||
|
|
["tenant_id", "connector_event_id"],
|
||
|
|
["financial_connector_events.tenant_id", "financial_connector_events.id"],
|
||
|
|
name="fk_payment_reconciliation_events_tenant_connector_event",
|
||
|
|
ondelete="RESTRICT",
|
||
|
|
),
|
||
|
|
sa.ForeignKeyConstraint(
|
||
|
|
["tenant_id", "reconciliation_case_id"],
|
||
|
|
["payment_reconciliation_cases.tenant_id", "payment_reconciliation_cases.id"],
|
||
|
|
name="fk_payment_reconciliation_events_tenant_case",
|
||
|
|
ondelete="RESTRICT",
|
||
|
|
),
|
||
|
|
sa.PrimaryKeyConstraint("id"),
|
||
|
|
sa.UniqueConstraint("tenant_id", "id", name="uq_payment_reconciliation_events_tenant_id"),
|
||
|
|
sa.UniqueConstraint(
|
||
|
|
"tenant_id",
|
||
|
|
"connector_event_id",
|
||
|
|
"action",
|
||
|
|
name="uq_payment_reconciliation_events_connector_action",
|
||
|
|
),
|
||
|
|
)
|
||
|
|
op.create_index(
|
||
|
|
"ix_payment_reconciliation_events_tenant_case_time",
|
||
|
|
"payment_reconciliation_events",
|
||
|
|
["tenant_id", "reconciliation_case_id", "occurred_at"],
|
||
|
|
)
|
||
|
|
|
||
|
|
op.execute(
|
||
|
|
"""
|
||
|
|
CREATE FUNCTION reject_financial_connector_append_only_mutation()
|
||
|
|
RETURNS trigger AS $$
|
||
|
|
BEGIN
|
||
|
|
RAISE EXCEPTION 'financial connector facts are append-only';
|
||
|
|
END;
|
||
|
|
$$ LANGUAGE plpgsql;
|
||
|
|
"""
|
||
|
|
)
|
||
|
|
for table_name in ("financial_connector_events", "payment_reconciliation_events"):
|
||
|
|
op.execute(
|
||
|
|
f"""
|
||
|
|
CREATE TRIGGER trg_{table_name}_append_only
|
||
|
|
BEFORE UPDATE OR DELETE ON {table_name}
|
||
|
|
FOR EACH ROW EXECUTE FUNCTION reject_financial_connector_append_only_mutation();
|
||
|
|
"""
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def downgrade() -> None:
|
||
|
|
_require_postgresql()
|
||
|
|
_require_empty_connector_domain_for_downgrade()
|
||
|
|
for table_name in ("payment_reconciliation_events", "financial_connector_events"):
|
||
|
|
op.execute(f"DROP TRIGGER IF EXISTS trg_{table_name}_append_only ON {table_name}")
|
||
|
|
op.execute("DROP FUNCTION IF EXISTS reject_financial_connector_append_only_mutation()")
|
||
|
|
op.drop_index(
|
||
|
|
"ix_payment_reconciliation_events_tenant_case_time",
|
||
|
|
table_name="payment_reconciliation_events",
|
||
|
|
)
|
||
|
|
op.drop_table("payment_reconciliation_events")
|
||
|
|
op.drop_index(
|
||
|
|
"ix_payment_reconciliation_cases_tenant_status",
|
||
|
|
table_name="payment_reconciliation_cases",
|
||
|
|
)
|
||
|
|
op.drop_table("payment_reconciliation_cases")
|
||
|
|
op.drop_index(
|
||
|
|
"ix_financial_connector_events_tenant_claim",
|
||
|
|
table_name="financial_connector_events",
|
||
|
|
)
|
||
|
|
op.drop_index(
|
||
|
|
"ix_financial_connector_events_tenant_received",
|
||
|
|
table_name="financial_connector_events",
|
||
|
|
)
|
||
|
|
op.drop_table("financial_connector_events")
|
||
|
|
op.drop_index(
|
||
|
|
"ix_financial_connector_configs_tenant_status",
|
||
|
|
table_name="financial_connector_configs",
|
||
|
|
)
|
||
|
|
op.drop_table("financial_connector_configs")
|