feat(erp): plan payable exports and reconcile bookings
Module Package Release / publish-packages (push) Successful in 13s
Module Package Release / publish-packages (push) Successful in 13s
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Governed ERP integration contracts."""
|
||||
@@ -0,0 +1,302 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
DocumentationCondition,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
ModuleManifest,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import (
|
||||
ExternalProviderDeclaration,
|
||||
ExternalProviderRuntimeState,
|
||||
ExternalProviderStateContext,
|
||||
ExternalProviderStateProviderRegistration,
|
||||
ProviderBehaviorDeclaration,
|
||||
ProviderObjectDeclaration,
|
||||
declared_module_architecture,
|
||||
)
|
||||
|
||||
|
||||
MODULE_ID = "erp"
|
||||
MODULE_VERSION = "0.1.19"
|
||||
READ_SCOPE = "erp:payables:read"
|
||||
PLAN_SCOPE = "erp:payables:plan"
|
||||
RECONCILE_SCOPE = "erp:bookings:reconcile"
|
||||
ADMIN_SCOPE = "erp:integration:admin"
|
||||
ERP_PROVIDER_ID = "erp.payables_target"
|
||||
|
||||
|
||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||
module_id, resource, action = scope.split(":", 2)
|
||||
return PermissionDefinition(
|
||||
scope=scope,
|
||||
label=label,
|
||||
description=description,
|
||||
category="ERP",
|
||||
level="tenant",
|
||||
module_id=module_id,
|
||||
resource=resource,
|
||||
action=action,
|
||||
)
|
||||
|
||||
|
||||
ERP_PROVIDER = ExternalProviderDeclaration(
|
||||
id=ERP_PROVIDER_ID,
|
||||
module_id=MODULE_ID,
|
||||
label="Configured ERP payable and booking target",
|
||||
maturity="read",
|
||||
operations=("read", "preview", "dry_run"),
|
||||
objects=(
|
||||
ProviderObjectDeclaration(
|
||||
object_type="payable_export",
|
||||
field_groups=("identity", "amount", "coding", "evidence"),
|
||||
authority_modes=("native_authoritative",),
|
||||
default_authority_mode="native_authoritative",
|
||||
),
|
||||
ProviderObjectDeclaration(
|
||||
object_type="booking_observation",
|
||||
field_groups=("correlation", "status", "amount", "evidence"),
|
||||
authority_modes=("external_authoritative",),
|
||||
default_authority_mode="external_authoritative",
|
||||
),
|
||||
),
|
||||
behavior=ProviderBehaviorDeclaration(
|
||||
revision_tokens=(
|
||||
"Payable revisions and mapping revisions bind export plans; external revisions "
|
||||
"bind booking observations."
|
||||
),
|
||||
concurrency=(
|
||||
"Only the exact payable revision and reviewed profile digest may be reconciled."
|
||||
),
|
||||
freshness=(
|
||||
"Every booking observation carries an aware timestamp and external revision."
|
||||
),
|
||||
health=(
|
||||
"Profile mapping, export planning, target transport, correlation lookup, and "
|
||||
"reconciliation are reported separately."
|
||||
),
|
||||
max_read_items=1000,
|
||||
idempotency=(
|
||||
"The exact payable projection digest is the default idempotency identity."
|
||||
),
|
||||
retry=(
|
||||
"No export retry is allowed until the target is queried by the exact plan correlation."
|
||||
),
|
||||
timeout_seconds=30,
|
||||
conflicts=(
|
||||
"Tenant, payable, provider, profile, plan, currency, and amount mismatches are quarantined."
|
||||
),
|
||||
outcome_unknown=(
|
||||
"A missing response is unknown, never evidence that a payable was or was not booked."
|
||||
),
|
||||
outcome_unknown_supported=True,
|
||||
evidence=(
|
||||
"Payable, profile, payload, plan, external revision, and observation digests form evidence."
|
||||
),
|
||||
correction=(
|
||||
"Correct the source payable or mapping and issue a new revision-bound export plan."
|
||||
),
|
||||
rollback="An external booking is not assumed to be transactionally reversible.",
|
||||
compensation=(
|
||||
"Reversals are separate correlated observations and never overwrite the original booking."
|
||||
),
|
||||
reconciliation=(
|
||||
"Look up the external booking by stable plan correlation and compare amount, currency, "
|
||||
"profile, revision, and evidence before retry."
|
||||
),
|
||||
outage=(
|
||||
"Module-owned payable state remains authoritative while export and reconciliation wait."
|
||||
),
|
||||
classifications=("confidential", "restricted"),
|
||||
purposes=("payable export", "booking reconciliation"),
|
||||
retention=(
|
||||
"Owning finance modules retain payables; ERP and Audit retain only governed integration evidence."
|
||||
),
|
||||
secret_handling=(
|
||||
"Profiles contain only a connection reference; credentials never enter plans or evidence."
|
||||
),
|
||||
),
|
||||
documentation_topic_ids=("erp.payable-reconciliation",),
|
||||
)
|
||||
|
||||
|
||||
def _provider_states(
|
||||
context: ExternalProviderStateContext,
|
||||
) -> tuple[ExternalProviderRuntimeState, ...]:
|
||||
del context
|
||||
return (
|
||||
ExternalProviderRuntimeState(
|
||||
provider_id=ERP_PROVIDER_ID,
|
||||
observed_at=datetime.now(UTC),
|
||||
configured=False,
|
||||
active=False,
|
||||
health="inactive",
|
||||
freshness="not_applicable",
|
||||
conflict="not_applicable",
|
||||
recovery="unsupported",
|
||||
detail="No target-tested ERP product binding is configured; dispatch is disabled.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id=MODULE_ID,
|
||||
name="ERP",
|
||||
version=MODULE_VERSION,
|
||||
dependencies=("access",),
|
||||
optional_dependencies=("procurement", "payments", "ledger", "files", "audit", "policy"),
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
),
|
||||
permissions=(
|
||||
_permission(READ_SCOPE, "View ERP integration", "Read non-secret profiles, plans, and reconciliation evidence."),
|
||||
_permission(PLAN_SCOPE, "Plan payable export", "Create a digest-bound, effect-free payable export plan."),
|
||||
_permission(RECONCILE_SCOPE, "Reconcile ERP booking", "Interpret a correlated external booking observation."),
|
||||
_permission(ADMIN_SCOPE, "Administer ERP integration", "Configure and verify ERP mappings and recovery behavior."),
|
||||
),
|
||||
role_templates=(
|
||||
RoleTemplate(
|
||||
slug="erp_integration_operator",
|
||||
name="ERP integration operator",
|
||||
description="Plan payable exports and reconcile correlated booking observations.",
|
||||
permissions=(READ_SCOPE, PLAN_SCOPE, RECONCILE_SCOPE),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="erp_integration_administrator",
|
||||
name="ERP integration administrator",
|
||||
description="Configure and verify governed ERP target bindings.",
|
||||
permissions=(READ_SCOPE, PLAN_SCOPE, RECONCILE_SCOPE, ADMIN_SCOPE),
|
||||
),
|
||||
),
|
||||
external_providers=(ERP_PROVIDER,),
|
||||
external_provider_state_providers=(
|
||||
ExternalProviderStateProviderRegistration(
|
||||
module_id=MODULE_ID,
|
||||
provider_id=ERP_PROVIDER_ID,
|
||||
provider=_provider_states,
|
||||
),
|
||||
),
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="erp.boundary",
|
||||
title="ERP integration boundary",
|
||||
summary="Exchange governed finance projections without making GovOPlaN a replacement ERP or moving payable authority into the connector.",
|
||||
body=(
|
||||
"ERP owns product profiles, mapping and transport plans, correlation, and external booking observations. Procurement, Payments, and Ledger continue to own approvals, payables, payments, and accounting projections. An export plan is not evidence of external booking, and an unconfigured provider declaration is not a production integration."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "operator", "module_admin", "auditor"),
|
||||
related_modules=("procurement", "payments", "ledger", "audit"),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="ERP integration boundary",
|
||||
href="docs/PAYABLE_EXPORT_AND_RECONCILIATION.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Integrationsgrenze des ERP-Moduls",
|
||||
"summary": "Gesteuerte Finanzprojektionen austauschen, ohne GovOPlaN zum Ersatz-ERP zu machen oder die Verantwortung für Verbindlichkeiten in den Konnektor zu verlagern.",
|
||||
"body": "ERP verantwortet Produktprofile, Zuordnung und Transportpläne, Korrelation sowie externe Buchungsbeobachtungen. Procurement, Payments und Ledger bleiben für Freigaben, Verbindlichkeiten, Zahlungen und Buchhaltungsprojektionen zuständig. Ein Übergabeplan ist kein Buchungsnachweis; eine unkonfigurierte Anbieterdeklaration ist keine produktive Integration.",
|
||||
}
|
||||
},
|
||||
order=90,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="erp.payable-reconciliation",
|
||||
title="Plan payable exports and reconcile ERP bookings",
|
||||
summary=(
|
||||
"Bind an exact payable revision to an effect-free export plan and quarantine "
|
||||
"uncorrelated or unmapped external booking observations."
|
||||
),
|
||||
body=(
|
||||
"Procurement, Payments, or Ledger owns the payable and supplies integer minor-unit "
|
||||
"amounts, stable references, an invoice digest, and a revision. ERP combines this "
|
||||
"projection with an administrator-reviewed product profile, schema, company code, "
|
||||
"mapping revision, connection reference, and status mapping. The resulting canonical "
|
||||
"payload and plan are digest-bound but cannot dispatch in this release. Reconciliation "
|
||||
"accepts only an observation tied to the exact plan, provider, tenant, payable, amount, "
|
||||
"currency, and evidence. Unknown statuses or mismatches are quarantined. Bookings, "
|
||||
"rejections, and reversals remain separate evidence-bearing outcomes."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "operator", "module_admin", "auditor"),
|
||||
related_modules=("procurement", "payments", "ledger", "files", "audit"),
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
any_scopes=(READ_SCOPE, PLAN_SCOPE, RECONCILE_SCOPE, ADMIN_SCOPE),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="ERP payable and booking contract",
|
||||
href="docs/PAYABLE_EXPORT_AND_RECONCILIATION.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Kreditorische Übergaben planen und ERP-Buchungen abgleichen",
|
||||
"summary": "Eine exakte Verbindlichkeitsrevision an einen wirkungsfreien Übergabeplan binden und nicht korrelierte oder unbekannte ERP-Buchungsstände sperren.",
|
||||
"body": "Procurement, Payments oder Ledger verantwortet die Verbindlichkeit und liefert ganzzahlige Nebenwährungseinheiten, stabile Referenzen, die Rechnungsprüfsumme und eine Revision. ERP verbindet diese Projektion mit einem administrativ geprüften Produktprofil, Schema, Buchungskreis, Mapping-Revision, Verbindungsreferenz und einer Statuszuordnung. Nutzdaten und Plan sind kanonisch prüfsummengebunden, können in dieser Version aber nicht versendet werden. Der Abgleich akzeptiert nur Beobachtungen, die exakt zu Plan, Anbieter, Mandant, Verbindlichkeit, Betrag, Währung und Nachweis passen. Unbekannte Zustände und Abweichungen werden isoliert. Buchung, Ablehnung und Storno bleiben getrennte nachweisgebundene Ergebnisse.",
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"prerequisites": [
|
||||
"The owning finance module supplies an approved immutable payable revision.",
|
||||
"An administrator has reviewed the target schema and raw-status mapping.",
|
||||
"The connection reference resolves through a deployment-owned credential boundary.",
|
||||
],
|
||||
"steps": [
|
||||
"Build and review the exact canonical export payload and plan digests.",
|
||||
"Dispatch only through a separately target-tested adapter.",
|
||||
"Read the booking by stable plan correlation after success, timeout, or retry.",
|
||||
"Record, wait, or quarantine the deterministic reconciliation decision.",
|
||||
],
|
||||
"limitations": [
|
||||
"No ERP product, schema, endpoint, or transport is selected by this release.",
|
||||
"Plans cannot dispatch and observations are not persisted by this module.",
|
||||
],
|
||||
"consequences": [
|
||||
"Changing the payable or mapping produces a new plan and idempotency identity.",
|
||||
"A timeout never implies success or failure.",
|
||||
"Reversals remain linked outcomes rather than destructive status replacement.",
|
||||
],
|
||||
},
|
||||
order=100,
|
||||
),
|
||||
),
|
||||
architecture=declared_module_architecture(
|
||||
layer="data_reporting_integration",
|
||||
kind="integration",
|
||||
maturity="vertical_slice",
|
||||
documentation_ref="docs/PAYABLE_EXPORT_AND_RECONCILIATION.md",
|
||||
test_ref="tests/test_payables.py",
|
||||
known_limits=(
|
||||
"A named ERP product, schema, transport, credentials, and target test are required before dispatch.",
|
||||
),
|
||||
supported_authority_modes=("native_authoritative", "external_authoritative"),
|
||||
owned_concepts=("ERP payable profile", "payable export plan", "booking observation mapping"),
|
||||
non_owned_concepts=("invoice", "payable approval", "ledger entry", "payment"),
|
||||
recovery_docs=("docs/PAYABLE_EXPORT_AND_RECONCILIATION.md",),
|
||||
security_docs=("docs/PAYABLE_EXPORT_AND_RECONCILIATION.md",),
|
||||
operations_docs=("docs/PAYABLE_EXPORT_AND_RECONCILIATION.md",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_manifest() -> ModuleManifest:
|
||||
return manifest
|
||||
@@ -0,0 +1,451 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
|
||||
BookingState = Literal[
|
||||
"received",
|
||||
"validated",
|
||||
"booked",
|
||||
"rejected",
|
||||
"reversed",
|
||||
"cancelled",
|
||||
]
|
||||
ReconciliationOutcome = Literal[
|
||||
"pending",
|
||||
"booked",
|
||||
"rejected",
|
||||
"reversed",
|
||||
"conflict",
|
||||
]
|
||||
ReconciliationAction = Literal[
|
||||
"wait",
|
||||
"record_booking",
|
||||
"record_rejection",
|
||||
"record_reversal",
|
||||
"quarantine",
|
||||
]
|
||||
|
||||
_SOURCE_MODULE = re.compile(r"^[a-z][a-z0-9_-]{0,63}$")
|
||||
_CURRENCY = re.compile(r"^[A-Z]{3}$")
|
||||
_MAX_AMOUNT_MINOR = 10**15
|
||||
|
||||
|
||||
class ErpPayableError(RuntimeError):
|
||||
"""Stable ERP planning or reconciliation failure without invoice contents."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PayableExportInput:
|
||||
"""An exact module-owned payable projection prepared for external export."""
|
||||
|
||||
tenant_id: str
|
||||
source_module: str
|
||||
payable_id: str
|
||||
payable_revision: int
|
||||
invoice_reference: str
|
||||
creditor_reference: str
|
||||
currency: str
|
||||
gross_amount_minor: int
|
||||
due_date: date | None
|
||||
cost_center_reference: str | None
|
||||
budget_reference: str | None
|
||||
invoice_document_sha256: str
|
||||
evidence_references: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"tenant_id",
|
||||
"payable_id",
|
||||
"invoice_reference",
|
||||
"creditor_reference",
|
||||
):
|
||||
object.__setattr__(self, name, _text(getattr(self, name), name))
|
||||
source_module = _text(self.source_module, "source_module", maximum=64)
|
||||
if _SOURCE_MODULE.fullmatch(source_module) is None:
|
||||
raise ValueError("ERP source_module must be a stable lowercase module identifier.")
|
||||
object.__setattr__(self, "source_module", source_module)
|
||||
if not isinstance(self.payable_revision, int) or isinstance(self.payable_revision, bool):
|
||||
raise ValueError("ERP payable_revision must be an integer.")
|
||||
if self.payable_revision < 1:
|
||||
raise ValueError("ERP payable_revision must be positive.")
|
||||
currency = str(self.currency or "").strip().upper()
|
||||
if _CURRENCY.fullmatch(currency) is None:
|
||||
raise ValueError("ERP currency must be a three-letter ISO-style code.")
|
||||
object.__setattr__(self, "currency", currency)
|
||||
if not isinstance(self.gross_amount_minor, int) or isinstance(
|
||||
self.gross_amount_minor, bool
|
||||
):
|
||||
raise ValueError("ERP amounts must use integer minor units, never floating point.")
|
||||
if not 0 < self.gross_amount_minor <= _MAX_AMOUNT_MINOR:
|
||||
raise ValueError("ERP gross_amount_minor is outside the supported positive range.")
|
||||
for name in ("cost_center_reference", "budget_reference"):
|
||||
value = getattr(self, name)
|
||||
if value is not None:
|
||||
object.__setattr__(self, name, _text(value, name))
|
||||
object.__setattr__(
|
||||
self,
|
||||
"invoice_document_sha256",
|
||||
_sha256(self.invoice_document_sha256, "invoice_document_sha256"),
|
||||
)
|
||||
if len(self.evidence_references) > 100:
|
||||
raise ValueError("ERP payable evidence is limited to 100 references.")
|
||||
evidence = tuple(_text(value, "evidence_reference") for value in self.evidence_references)
|
||||
if len(evidence) != len(set(evidence)):
|
||||
raise ValueError("ERP payable evidence references must be unique.")
|
||||
object.__setattr__(self, "evidence_references", evidence)
|
||||
|
||||
@property
|
||||
def input_sha256(self) -> str:
|
||||
return _digest(self.to_payload())
|
||||
|
||||
def to_payload(self) -> dict[str, object]:
|
||||
return {
|
||||
"tenant_id": self.tenant_id,
|
||||
"source_module": self.source_module,
|
||||
"payable_id": self.payable_id,
|
||||
"payable_revision": self.payable_revision,
|
||||
"invoice_reference": self.invoice_reference,
|
||||
"creditor_reference": self.creditor_reference,
|
||||
"currency": self.currency,
|
||||
"gross_amount_minor": self.gross_amount_minor,
|
||||
"due_date": self.due_date.isoformat() if self.due_date else None,
|
||||
"cost_center_reference": self.cost_center_reference,
|
||||
"budget_reference": self.budget_reference,
|
||||
"invoice_document_sha256": self.invoice_document_sha256,
|
||||
"evidence_references": list(self.evidence_references),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ErpPayableProfile:
|
||||
"""Product-neutral, non-secret mapping for one external finance target."""
|
||||
|
||||
profile_id: str
|
||||
provider_id: str
|
||||
company_code: str
|
||||
payable_schema: str
|
||||
payable_schema_version: str
|
||||
mapping_revision: str
|
||||
connection_ref: str
|
||||
booking_status_mapping: tuple[tuple[str, BookingState], ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"profile_id",
|
||||
"provider_id",
|
||||
"company_code",
|
||||
"payable_schema",
|
||||
"payable_schema_version",
|
||||
"mapping_revision",
|
||||
"connection_ref",
|
||||
):
|
||||
object.__setattr__(self, name, _text(getattr(self, name), name))
|
||||
if not self.booking_status_mapping:
|
||||
raise ValueError("ERP profile requires an explicit booking-status mapping.")
|
||||
if len(self.booking_status_mapping) > 100:
|
||||
raise ValueError("ERP profile supports at most 100 booking-status mappings.")
|
||||
normalized: list[tuple[str, BookingState]] = []
|
||||
seen: set[str] = set()
|
||||
allowed = {"received", "validated", "booked", "rejected", "reversed", "cancelled"}
|
||||
for raw_status, state in self.booking_status_mapping:
|
||||
raw = _text(raw_status, "raw_booking_status", maximum=100)
|
||||
key = raw.casefold()
|
||||
if key in seen:
|
||||
raise ValueError("ERP raw booking statuses must be unique ignoring case.")
|
||||
if state not in allowed:
|
||||
raise ValueError("ERP normalized booking state is unsupported.")
|
||||
seen.add(key)
|
||||
normalized.append((raw, state))
|
||||
object.__setattr__(self, "booking_status_mapping", tuple(normalized))
|
||||
|
||||
@property
|
||||
def profile_sha256(self) -> str:
|
||||
return _digest(
|
||||
{
|
||||
"profile_id": self.profile_id,
|
||||
"provider_id": self.provider_id,
|
||||
"company_code": self.company_code,
|
||||
"payable_schema": self.payable_schema,
|
||||
"payable_schema_version": self.payable_schema_version,
|
||||
"mapping_revision": self.mapping_revision,
|
||||
"connection_ref": self.connection_ref,
|
||||
"booking_status_mapping": [list(item) for item in self.booking_status_mapping],
|
||||
}
|
||||
)
|
||||
|
||||
def normalized_status(self, raw_status: str) -> BookingState | None:
|
||||
candidate = str(raw_status or "").strip().casefold()
|
||||
return next(
|
||||
(state for raw, state in self.booking_status_mapping if raw.casefold() == candidate),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PayableExportPlan:
|
||||
tenant_id: str
|
||||
payable_id: str
|
||||
payable_revision: int
|
||||
currency: str
|
||||
gross_amount_minor: int
|
||||
provider_id: str
|
||||
profile_id: str
|
||||
profile_sha256: str
|
||||
input_sha256: str
|
||||
idempotency_key: str
|
||||
payload_json: bytes
|
||||
payload_sha256: str
|
||||
plan_sha256: str
|
||||
dispatch_allowed: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BookingObservation:
|
||||
tenant_id: str
|
||||
payable_id: str
|
||||
provider_id: str
|
||||
external_booking_id: str
|
||||
raw_status: str
|
||||
external_revision: str
|
||||
observed_at: datetime
|
||||
exported_plan_sha256: str
|
||||
evidence_sha256: str
|
||||
currency: str
|
||||
gross_amount_minor: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"tenant_id",
|
||||
"payable_id",
|
||||
"provider_id",
|
||||
"external_booking_id",
|
||||
"raw_status",
|
||||
"external_revision",
|
||||
):
|
||||
object.__setattr__(self, name, _text(getattr(self, name), name))
|
||||
if self.observed_at.tzinfo is None or self.observed_at.utcoffset() is None:
|
||||
raise ValueError("ERP booking observations require a timezone-aware timestamp.")
|
||||
object.__setattr__(
|
||||
self,
|
||||
"exported_plan_sha256",
|
||||
_sha256(self.exported_plan_sha256, "exported_plan_sha256"),
|
||||
)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"evidence_sha256",
|
||||
_sha256(self.evidence_sha256, "evidence_sha256"),
|
||||
)
|
||||
currency = str(self.currency or "").strip().upper()
|
||||
if _CURRENCY.fullmatch(currency) is None:
|
||||
raise ValueError("ERP observation currency must be a three-letter code.")
|
||||
object.__setattr__(self, "currency", currency)
|
||||
if not isinstance(self.gross_amount_minor, int) or isinstance(
|
||||
self.gross_amount_minor, bool
|
||||
):
|
||||
raise ValueError("ERP observation amounts must use integer minor units.")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BookingReconciliationDecision:
|
||||
outcome: ReconciliationOutcome
|
||||
action: ReconciliationAction
|
||||
observed_state: BookingState | None
|
||||
reason: str
|
||||
external_booking_id: str
|
||||
evidence_sha256: str
|
||||
decision_sha256: str
|
||||
|
||||
|
||||
def build_payable_export_plan(
|
||||
profile: ErpPayableProfile,
|
||||
payable: PayableExportInput,
|
||||
*,
|
||||
idempotency_key: str | None = None,
|
||||
) -> PayableExportPlan:
|
||||
payload = {
|
||||
"schema": profile.payable_schema,
|
||||
"schema_version": profile.payable_schema_version,
|
||||
"company_code": profile.company_code,
|
||||
"mapping_revision": profile.mapping_revision,
|
||||
"payable": payable.to_payload(),
|
||||
}
|
||||
payload_json = _canonical_json(payload)
|
||||
payload_sha256 = hashlib.sha256(payload_json).hexdigest()
|
||||
key = (
|
||||
_text(idempotency_key, "idempotency_key")
|
||||
if idempotency_key is not None
|
||||
else f"erp-payable:{payable.input_sha256}"
|
||||
)
|
||||
plan_payload = {
|
||||
"tenant_id": payable.tenant_id,
|
||||
"payable_id": payable.payable_id,
|
||||
"payable_revision": payable.payable_revision,
|
||||
"currency": payable.currency,
|
||||
"gross_amount_minor": payable.gross_amount_minor,
|
||||
"provider_id": profile.provider_id,
|
||||
"profile_id": profile.profile_id,
|
||||
"profile_sha256": profile.profile_sha256,
|
||||
"input_sha256": payable.input_sha256,
|
||||
"idempotency_key": key,
|
||||
"payload_sha256": payload_sha256,
|
||||
}
|
||||
return PayableExportPlan(
|
||||
tenant_id=payable.tenant_id,
|
||||
payable_id=payable.payable_id,
|
||||
payable_revision=payable.payable_revision,
|
||||
currency=payable.currency,
|
||||
gross_amount_minor=payable.gross_amount_minor,
|
||||
provider_id=profile.provider_id,
|
||||
profile_id=profile.profile_id,
|
||||
profile_sha256=profile.profile_sha256,
|
||||
input_sha256=payable.input_sha256,
|
||||
idempotency_key=key,
|
||||
payload_json=payload_json,
|
||||
payload_sha256=payload_sha256,
|
||||
plan_sha256=_digest(plan_payload),
|
||||
)
|
||||
|
||||
|
||||
def reconcile_booking(
|
||||
profile: ErpPayableProfile,
|
||||
plan: PayableExportPlan,
|
||||
observation: BookingObservation,
|
||||
) -> BookingReconciliationDecision:
|
||||
conflict = _binding_conflict(profile, plan, observation)
|
||||
if conflict is not None:
|
||||
return _decision(observation, "conflict", "quarantine", None, conflict)
|
||||
state = profile.normalized_status(observation.raw_status)
|
||||
if state is None:
|
||||
return _decision(
|
||||
observation,
|
||||
"conflict",
|
||||
"quarantine",
|
||||
None,
|
||||
"The external booking status is not present in the reviewed mapping.",
|
||||
)
|
||||
if state in {"received", "validated"}:
|
||||
return _decision(
|
||||
observation,
|
||||
"pending",
|
||||
"wait",
|
||||
state,
|
||||
"The external payable is acknowledged but has no terminal booking outcome.",
|
||||
)
|
||||
if state == "booked":
|
||||
return _decision(
|
||||
observation,
|
||||
"booked",
|
||||
"record_booking",
|
||||
state,
|
||||
"The exact exported payable has a mapped external booking outcome.",
|
||||
)
|
||||
if state == "reversed":
|
||||
return _decision(
|
||||
observation,
|
||||
"reversed",
|
||||
"record_reversal",
|
||||
state,
|
||||
"The external system reports a reversal of the correlated booking.",
|
||||
)
|
||||
return _decision(
|
||||
observation,
|
||||
"rejected",
|
||||
"record_rejection",
|
||||
state,
|
||||
"The external system rejected or cancelled the correlated payable.",
|
||||
)
|
||||
|
||||
|
||||
def _binding_conflict(
|
||||
profile: ErpPayableProfile,
|
||||
plan: PayableExportPlan,
|
||||
observation: BookingObservation,
|
||||
) -> str | None:
|
||||
checks = (
|
||||
(plan.profile_sha256 == profile.profile_sha256, "The reviewed ERP profile changed."),
|
||||
(plan.profile_id == profile.profile_id, "The ERP profile identity does not match."),
|
||||
(plan.provider_id == profile.provider_id, "The ERP provider identity does not match."),
|
||||
(observation.tenant_id == plan.tenant_id, "The observation belongs to another tenant."),
|
||||
(observation.payable_id == plan.payable_id, "The observation belongs to another payable."),
|
||||
(observation.provider_id == plan.provider_id, "The observation came from another provider."),
|
||||
(observation.currency == plan.currency, "The observation currency does not match the export."),
|
||||
(
|
||||
observation.gross_amount_minor == plan.gross_amount_minor,
|
||||
"The observation amount does not match the export.",
|
||||
),
|
||||
(
|
||||
observation.exported_plan_sha256 == plan.plan_sha256,
|
||||
"The observation is not bound to the exact export plan.",
|
||||
),
|
||||
)
|
||||
return next((reason for valid, reason in checks if not valid), None)
|
||||
|
||||
|
||||
def _decision(
|
||||
observation: BookingObservation,
|
||||
outcome: ReconciliationOutcome,
|
||||
action: ReconciliationAction,
|
||||
state: BookingState | None,
|
||||
reason: str,
|
||||
) -> BookingReconciliationDecision:
|
||||
payload = {
|
||||
"outcome": outcome,
|
||||
"action": action,
|
||||
"observed_state": state,
|
||||
"reason": reason,
|
||||
"external_booking_id": observation.external_booking_id,
|
||||
"external_revision": observation.external_revision,
|
||||
"observed_at": observation.observed_at.isoformat(),
|
||||
"evidence_sha256": observation.evidence_sha256,
|
||||
}
|
||||
return BookingReconciliationDecision(
|
||||
outcome=outcome,
|
||||
action=action,
|
||||
observed_state=state,
|
||||
reason=reason,
|
||||
external_booking_id=observation.external_booking_id,
|
||||
evidence_sha256=observation.evidence_sha256,
|
||||
decision_sha256=_digest(payload),
|
||||
)
|
||||
|
||||
|
||||
def _text(value: object, label: str, *, maximum: int = 255) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized or len(normalized) > maximum or any(ord(char) < 32 for char in normalized):
|
||||
raise ValueError(
|
||||
f"ERP {label.replace('_', ' ')} is required, bounded, and must not contain controls."
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _sha256(value: object, label: str) -> str:
|
||||
normalized = str(value or "").strip().lower().removeprefix("sha256:")
|
||||
if len(normalized) != 64 or any(char not in "0123456789abcdef" for char in normalized):
|
||||
raise ValueError(f"ERP {label.replace('_', ' ')} must be a SHA-256 digest.")
|
||||
return normalized
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
|
||||
|
||||
|
||||
def _digest(value: object) -> str:
|
||||
return hashlib.sha256(_canonical_json(value)).hexdigest()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BookingObservation",
|
||||
"BookingReconciliationDecision",
|
||||
"ErpPayableError",
|
||||
"ErpPayableProfile",
|
||||
"PayableExportInput",
|
||||
"PayableExportPlan",
|
||||
"build_payable_export_plan",
|
||||
"reconcile_booking",
|
||||
]
|
||||
Reference in New Issue
Block a user