Add DATEV accounting adapter boundary
This commit is contained in:
parent
fcec177ded
commit
90b7e97533
8 changed files with 583 additions and 13 deletions
|
|
@ -2,7 +2,7 @@
|
||||||
# Custodian Brief — fin-hub
|
# Custodian Brief — fin-hub
|
||||||
|
|
||||||
**Domain:** financials
|
**Domain:** financials
|
||||||
**Last synced:** 2026-08-11 12:39 UTC
|
**Last synced:** 2026-08-11 12:54 UTC
|
||||||
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
|
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
|
||||||
|
|
||||||
## Active Workstreams
|
## Active Workstreams
|
||||||
|
|
@ -15,12 +15,6 @@ Progress: 6/9 done | workplan_id: `67b6de6c-4820-4478-9789-f50260204c27`
|
||||||
- · T04 — Expose budgets, commitments, and viability constraints `3620fc2a`
|
- · T04 — Expose budgets, commitments, and viability constraints `3620fc2a`
|
||||||
- · T06 — Generalize and operate the contract `a1309d51`
|
- · T06 — Generalize and operate the contract `a1309d51`
|
||||||
|
|
||||||
### Client attribution and billing basis
|
|
||||||
Progress: 5/6 done | workplan_id: `ebc1d2de-ae11-4cde-b860-047922fc74b9`
|
|
||||||
|
|
||||||
**Open tasks:**
|
|
||||||
- ! Select the external invoicing system `d30b606f`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
## MCP Orientation (when available)
|
## MCP Orientation (when available)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,10 @@
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| workplan | FIN-WP-0000 | finished | — | workplans/FIN-WP-0000-repo-integration.md |
|
| workplan | FIN-WP-0000 | finished | — | workplans/FIN-WP-0000-repo-integration.md |
|
||||||
| workplan | FIN-WP-0001 | finished | — | workplans/FIN-WP-0001-runway-operations-lane.md |
|
| workplan | FIN-WP-0001 | finished | — | workplans/FIN-WP-0001-runway-operations-lane.md |
|
||||||
| workplan | FIN-WP-0002 | active | — | workplans/FIN-WP-0002-client-attribution-and-billing-basis.md |
|
| workplan | FIN-WP-0002 | finished | — | workplans/FIN-WP-0002-client-attribution-and-billing-basis.md |
|
||||||
| workplan | FIN-WP-0003 | proposed | — | workplans/FIN-WP-0003-fabric-authority-boundary.md |
|
| workplan | FIN-WP-0003 | proposed | — | workplans/FIN-WP-0003-fabric-authority-boundary.md |
|
||||||
| workplan | FIN-WP-0004 | active | — | workplans/FIN-WP-0004-resource-cost-evidence-contract.md |
|
| workplan | FIN-WP-0004 | active | — | workplans/FIN-WP-0004-resource-cost-evidence-contract.md |
|
||||||
|
| workplan | FIN-WP-0005 | proposed | — | workplans/FIN-WP-0005-datev-accounting-adapter-operations.md |
|
||||||
| task | FIN-WP-0000-T01 | done | — | workplans/FIN-WP-0000-repo-integration.md |
|
| task | FIN-WP-0000-T01 | done | — | workplans/FIN-WP-0000-repo-integration.md |
|
||||||
| task | FIN-WP-0000-T02 | done | — | workplans/FIN-WP-0000-repo-integration.md |
|
| task | FIN-WP-0000-T02 | done | — | workplans/FIN-WP-0000-repo-integration.md |
|
||||||
| task | FIN-WP-0000-T03 | done | — | workplans/FIN-WP-0000-repo-integration.md |
|
| task | FIN-WP-0000-T03 | done | — | workplans/FIN-WP-0000-repo-integration.md |
|
||||||
|
|
@ -28,7 +29,7 @@
|
||||||
| task | FIN-WP-0002-T02 | done | — | workplans/FIN-WP-0002-client-attribution-and-billing-basis.md |
|
| task | FIN-WP-0002-T02 | done | — | workplans/FIN-WP-0002-client-attribution-and-billing-basis.md |
|
||||||
| task | FIN-WP-0002-T03 | done | — | workplans/FIN-WP-0002-client-attribution-and-billing-basis.md |
|
| task | FIN-WP-0002-T03 | done | — | workplans/FIN-WP-0002-client-attribution-and-billing-basis.md |
|
||||||
| task | FIN-WP-0002-T04 | done | — | workplans/FIN-WP-0002-client-attribution-and-billing-basis.md |
|
| task | FIN-WP-0002-T04 | done | — | workplans/FIN-WP-0002-client-attribution-and-billing-basis.md |
|
||||||
| task | FIN-WP-0002-T05 | wait | — | workplans/FIN-WP-0002-client-attribution-and-billing-basis.md |
|
| task | FIN-WP-0002-T05 | done | — | workplans/FIN-WP-0002-client-attribution-and-billing-basis.md |
|
||||||
| task | FIN-WP-0003-T01 | todo | — | workplans/FIN-WP-0003-fabric-authority-boundary.md |
|
| task | FIN-WP-0003-T01 | todo | — | workplans/FIN-WP-0003-fabric-authority-boundary.md |
|
||||||
| task | FIN-WP-0003-T02 | todo | — | workplans/FIN-WP-0003-fabric-authority-boundary.md |
|
| task | FIN-WP-0003-T02 | todo | — | workplans/FIN-WP-0003-fabric-authority-boundary.md |
|
||||||
| task | FIN-WP-0003-T03 | todo | — | workplans/FIN-WP-0003-fabric-authority-boundary.md |
|
| task | FIN-WP-0003-T03 | todo | — | workplans/FIN-WP-0003-fabric-authority-boundary.md |
|
||||||
|
|
@ -41,3 +42,7 @@
|
||||||
| task | FIN-WP-0004-T07 | done | — | workplans/FIN-WP-0004-resource-cost-evidence-contract.md |
|
| task | FIN-WP-0004-T07 | done | — | workplans/FIN-WP-0004-resource-cost-evidence-contract.md |
|
||||||
| task | FIN-WP-0004-T08 | done | — | workplans/FIN-WP-0004-resource-cost-evidence-contract.md |
|
| task | FIN-WP-0004-T08 | done | — | workplans/FIN-WP-0004-resource-cost-evidence-contract.md |
|
||||||
| task | FIN-WP-0004-T09 | done | — | workplans/FIN-WP-0004-resource-cost-evidence-contract.md |
|
| task | FIN-WP-0004-T09 | done | — | workplans/FIN-WP-0004-resource-cost-evidence-contract.md |
|
||||||
|
| task | FIN-WP-0005-T01 | todo | — | workplans/FIN-WP-0005-datev-accounting-adapter-operations.md |
|
||||||
|
| task | FIN-WP-0005-T02 | wait | — | workplans/FIN-WP-0005-datev-accounting-adapter-operations.md |
|
||||||
|
| task | FIN-WP-0005-T03 | wait | — | workplans/FIN-WP-0005-datev-accounting-adapter-operations.md |
|
||||||
|
| task | FIN-WP-0005-T04 | wait | — | workplans/FIN-WP-0005-datev-accounting-adapter-operations.md |
|
||||||
|
|
|
||||||
87
docs/accounting-adapter-boundary.md
Normal file
87
docs/accounting-adapter-boundary.md
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
# External accounting adapter boundary
|
||||||
|
|
||||||
|
Decision date: 2026-08-11
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
DATEV Unternehmen online (DUO) is the default and authoritative external
|
||||||
|
GoBD bookkeeping and document-retention system. fin-hub remains the source of
|
||||||
|
a reporting-only billing basis; it is not a bookkeeping system and does not
|
||||||
|
issue invoices or handle payments.
|
||||||
|
|
||||||
|
Qonto is the first complementary candidate when a needed function is not
|
||||||
|
provided conveniently by DUO. In particular, Qonto may provide the customer
|
||||||
|
invoice draft/delivery workflow and the managed transfer of bank data,
|
||||||
|
documents, and structured invoice data to DUO. Selecting Qonto for one of
|
||||||
|
these capabilities does not make it the bookkeeping authority.
|
||||||
|
|
||||||
|
Other GoBD-capable systems remain possible through the same adapter contract.
|
||||||
|
No provider is selected implicitly because another provider lacks a feature.
|
||||||
|
The caller must request a capability and explicitly select a non-default
|
||||||
|
adapter.
|
||||||
|
|
||||||
|
## Data and authority flow
|
||||||
|
|
||||||
|
```text
|
||||||
|
fin-hub billing-basis v0.1
|
||||||
|
|
|
||||||
|
| side-effect-free, version-pinned adapter
|
||||||
|
v
|
||||||
|
provider draft / exception report
|
||||||
|
|
|
||||||
|
| authorized external workflow and legal/tax completion
|
||||||
|
v
|
||||||
|
DATEV Unternehmen online (bookkeeping and document authority)
|
||||||
|
```
|
||||||
|
|
||||||
|
The preferred operational route is Qonto's managed DATEV integration when the
|
||||||
|
invoice workflow already occurs in Qonto and it preserves all required fields.
|
||||||
|
A direct DATEV integration is the fallback for non-Qonto sources or missing
|
||||||
|
Qonto capabilities. Provider activation, credentials, tax configuration,
|
||||||
|
customer master data, invoice numbering, delivery, and retention validation
|
||||||
|
are deliberately outside the current adapter.
|
||||||
|
|
||||||
|
## Contract
|
||||||
|
|
||||||
|
`fin_hub.accounting` defines:
|
||||||
|
|
||||||
|
- a manifest with stable adapter ID, semantic adapter version, provider role,
|
||||||
|
exact input schema version, and declared capabilities;
|
||||||
|
- a side-effect-free `prepare` operation producing an incomplete provider
|
||||||
|
draft with the source billing-basis ID as its idempotency reference;
|
||||||
|
- an explicit schema-migration graph. An adapter rejects an unsupported source
|
||||||
|
schema, including a new major version, unless a reviewed migration is
|
||||||
|
registered;
|
||||||
|
- capability checks that fail closed. The registry never silently falls back
|
||||||
|
from DATEV to Qonto or another provider.
|
||||||
|
|
||||||
|
Provider payloads retain the billing-basis reference, client/engagement
|
||||||
|
reference, service period, currency, amount, price reference, and source
|
||||||
|
evidence IDs where supported. They explicitly mark legal and tax completion
|
||||||
|
as required and contain no invoice number, issue date, or payment state.
|
||||||
|
|
||||||
|
## Change and migration controls
|
||||||
|
|
||||||
|
Before a provider or adapter upgrade is promoted:
|
||||||
|
|
||||||
|
1. Pin and review the new provider API/export and adapter versions.
|
||||||
|
2. Add an explicit billing-basis schema migration if the exact input version
|
||||||
|
changes; never reinterpret fields in place.
|
||||||
|
3. Run contract and golden-payload tests, then dual-run old and new adapters
|
||||||
|
against the same billing-basis IDs.
|
||||||
|
4. Reconcile record counts, amounts, currency, exception sets, and evidence
|
||||||
|
references in a non-production tenant.
|
||||||
|
5. Obtain bookkeeping/tax-owner acceptance before cutover, retain the prior
|
||||||
|
adapter for rollback, and record the cutover decision.
|
||||||
|
|
||||||
|
## Custody and retention
|
||||||
|
|
||||||
|
- fin-hub retains reporting inputs, derivations, corrections, allocations, and
|
||||||
|
export provenance according to its financial-evidence policy.
|
||||||
|
- DATEV/Steuerbüro owns bookkeeping records, document retention, tax treatment,
|
||||||
|
legal invoice completion, and correction/cancellation procedures.
|
||||||
|
- Qonto owns bank records and any Qonto-created invoice/document workflow; its
|
||||||
|
DATEV transfer must be monitored and reconciled rather than treated as proof
|
||||||
|
of successful bookkeeping.
|
||||||
|
- Secrets and provider credentials are never stored in billing-basis artifacts,
|
||||||
|
workplans, logs, or adapter configuration committed to Git.
|
||||||
19
src/fin_hub/accounting/__init__.py
Normal file
19
src/fin_hub/accounting/__init__.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
"""Provider-neutral handoff of reporting data to external accounting systems."""
|
||||||
|
|
||||||
|
from fin_hub.accounting.adapters import (
|
||||||
|
AccountingCapability,
|
||||||
|
AdapterRegistry,
|
||||||
|
DatevDuoAdapter,
|
||||||
|
QontoComplementAdapter,
|
||||||
|
SchemaMigrations,
|
||||||
|
default_registry,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AccountingCapability",
|
||||||
|
"AdapterRegistry",
|
||||||
|
"DatevDuoAdapter",
|
||||||
|
"QontoComplementAdapter",
|
||||||
|
"SchemaMigrations",
|
||||||
|
"default_registry",
|
||||||
|
]
|
||||||
255
src/fin_hub/accounting/adapters.py
Normal file
255
src/fin_hub/accounting/adapters.py
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
"""Versioned, side-effect-free adapters for external accounting handoff.
|
||||||
|
|
||||||
|
Adapters prepare provider-specific drafts. They deliberately do not authenticate,
|
||||||
|
transmit, issue invoices, assign invoice numbers, or perform bookkeeping.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Callable, Mapping, Protocol
|
||||||
|
|
||||||
|
from fin_hub.services.billing import BillingBasisExport
|
||||||
|
|
||||||
|
|
||||||
|
class AccountingCapability(StrEnum):
|
||||||
|
BILLING_BASIS_HANDOFF = "billing_basis_handoff"
|
||||||
|
INVOICE_DRAFT_WORKFLOW = "invoice_draft_workflow"
|
||||||
|
STRUCTURED_INVOICE_TRANSFER = "structured_invoice_transfer"
|
||||||
|
DOCUMENT_ARCHIVE = "document_archive"
|
||||||
|
BANK_DATA_SYNC = "bank_data_sync"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AdapterManifest:
|
||||||
|
adapter_id: str
|
||||||
|
provider: str
|
||||||
|
role: str
|
||||||
|
adapter_version: str
|
||||||
|
input_schema_version: str
|
||||||
|
capabilities: frozenset[AccountingCapability]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PreparedTransfer:
|
||||||
|
schema_version: str
|
||||||
|
artifact_type: str
|
||||||
|
adapter_id: str
|
||||||
|
adapter_version: str
|
||||||
|
provider: str
|
||||||
|
provider_role: str
|
||||||
|
source_schema_version: str
|
||||||
|
records: tuple[Mapping[str, object], ...]
|
||||||
|
exceptions: tuple[Mapping[str, object], ...]
|
||||||
|
disclaimer: str
|
||||||
|
|
||||||
|
def as_dict(self) -> dict:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountingAdapter(Protocol):
|
||||||
|
manifest: AdapterManifest
|
||||||
|
|
||||||
|
def prepare(self, export: BillingBasisExport) -> PreparedTransfer: ...
|
||||||
|
|
||||||
|
|
||||||
|
Migration = Callable[[BillingBasisExport], BillingBasisExport]
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaMigrations:
|
||||||
|
"""Explicit migration graph; no implicit major-version compatibility."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._steps: dict[tuple[str, str], Migration] = {}
|
||||||
|
|
||||||
|
def register(self, source: str, target: str, migration: Migration) -> None:
|
||||||
|
if source == target:
|
||||||
|
raise ValueError("a schema migration must change the version")
|
||||||
|
key = (source, target)
|
||||||
|
if key in self._steps:
|
||||||
|
raise ValueError(f"migration already registered: {source} -> {target}")
|
||||||
|
self._steps[key] = migration
|
||||||
|
|
||||||
|
def migrate(self, export: BillingBasisExport, target: str) -> BillingBasisExport:
|
||||||
|
if export.schema_version == target:
|
||||||
|
return export
|
||||||
|
|
||||||
|
queue = deque([(export.schema_version, ())])
|
||||||
|
visited = {export.schema_version}
|
||||||
|
path: tuple[tuple[str, str], ...] | None = None
|
||||||
|
while queue:
|
||||||
|
version, steps = queue.popleft()
|
||||||
|
for source, destination in sorted(self._steps):
|
||||||
|
if source != version or destination in visited:
|
||||||
|
continue
|
||||||
|
candidate = (*steps, (source, destination))
|
||||||
|
if destination == target:
|
||||||
|
path = candidate
|
||||||
|
queue.clear()
|
||||||
|
break
|
||||||
|
visited.add(destination)
|
||||||
|
queue.append((destination, candidate))
|
||||||
|
if path is None:
|
||||||
|
raise ValueError(
|
||||||
|
"no explicit billing-basis schema migration from "
|
||||||
|
f"{export.schema_version} to {target}"
|
||||||
|
)
|
||||||
|
|
||||||
|
migrated = export
|
||||||
|
for source, destination in path:
|
||||||
|
migrated = self._steps[(source, destination)](migrated)
|
||||||
|
if migrated.schema_version != destination:
|
||||||
|
raise ValueError(
|
||||||
|
f"migration {source} -> {destination} produced "
|
||||||
|
f"schema {migrated.schema_version}"
|
||||||
|
)
|
||||||
|
return migrated
|
||||||
|
|
||||||
|
|
||||||
|
def _prepared(
|
||||||
|
manifest: AdapterManifest,
|
||||||
|
export: BillingBasisExport,
|
||||||
|
records: tuple[Mapping[str, object], ...],
|
||||||
|
) -> PreparedTransfer:
|
||||||
|
return PreparedTransfer(
|
||||||
|
schema_version="0.1",
|
||||||
|
artifact_type="accounting_handoff_draft",
|
||||||
|
adapter_id=manifest.adapter_id,
|
||||||
|
adapter_version=manifest.adapter_version,
|
||||||
|
provider=manifest.provider,
|
||||||
|
provider_role=manifest.role,
|
||||||
|
source_schema_version=export.schema_version,
|
||||||
|
records=records,
|
||||||
|
exceptions=tuple(exception.as_dict() for exception in export.exceptions),
|
||||||
|
disclaimer=(
|
||||||
|
f"{export.disclaimer} Provider draft only; requires authorized completion "
|
||||||
|
"of customer, tax, invoice-number, issue-date, delivery, and retention data."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DatevDuoAdapter:
|
||||||
|
"""Default handoff boundary for DATEV Unternehmen online."""
|
||||||
|
|
||||||
|
manifest = AdapterManifest(
|
||||||
|
adapter_id="datev-duo-v1",
|
||||||
|
provider="DATEV Unternehmen online",
|
||||||
|
role="authoritative_bookkeeping_and_document_archive",
|
||||||
|
adapter_version="1.0.0",
|
||||||
|
input_schema_version="0.1",
|
||||||
|
capabilities=frozenset(
|
||||||
|
{
|
||||||
|
AccountingCapability.BILLING_BASIS_HANDOFF,
|
||||||
|
AccountingCapability.STRUCTURED_INVOICE_TRANSFER,
|
||||||
|
AccountingCapability.DOCUMENT_ARCHIVE,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def prepare(self, export: BillingBasisExport) -> PreparedTransfer:
|
||||||
|
records = tuple(
|
||||||
|
{
|
||||||
|
"external_reference": record.billing_basis_id,
|
||||||
|
"customer_reference": record.client_id,
|
||||||
|
"engagement_reference": record.cost_attribution_key,
|
||||||
|
"performance_period": record.period_month,
|
||||||
|
"currency": record.currency,
|
||||||
|
"net_billing_basis": record.revenue,
|
||||||
|
"price_reference": record.price_id,
|
||||||
|
"source_evidence_ids": (
|
||||||
|
*record.financial_fact_ids,
|
||||||
|
*record.allocation_ids,
|
||||||
|
),
|
||||||
|
"requires_legal_and_tax_completion": True,
|
||||||
|
}
|
||||||
|
for record in export.records
|
||||||
|
)
|
||||||
|
return _prepared(self.manifest, export, records)
|
||||||
|
|
||||||
|
|
||||||
|
class QontoComplementAdapter:
|
||||||
|
"""Optional Qonto invoice-workflow draft; DATEV remains authoritative."""
|
||||||
|
|
||||||
|
manifest = AdapterManifest(
|
||||||
|
adapter_id="qonto-invoice-draft-v1",
|
||||||
|
provider="Qonto",
|
||||||
|
role="complementary_invoice_workflow_and_datev_transport",
|
||||||
|
adapter_version="1.0.0",
|
||||||
|
input_schema_version="0.1",
|
||||||
|
capabilities=frozenset(
|
||||||
|
{
|
||||||
|
AccountingCapability.BILLING_BASIS_HANDOFF,
|
||||||
|
AccountingCapability.INVOICE_DRAFT_WORKFLOW,
|
||||||
|
AccountingCapability.STRUCTURED_INVOICE_TRANSFER,
|
||||||
|
AccountingCapability.BANK_DATA_SYNC,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def prepare(self, export: BillingBasisExport) -> PreparedTransfer:
|
||||||
|
records = tuple(
|
||||||
|
{
|
||||||
|
"external_reference": record.billing_basis_id,
|
||||||
|
"customer_reference": record.client_id,
|
||||||
|
"description": record.cost_attribution_key,
|
||||||
|
"service_period": record.period_month,
|
||||||
|
"currency": record.currency,
|
||||||
|
"net_draft_amount": record.revenue,
|
||||||
|
"requires_legal_and_tax_completion": True,
|
||||||
|
"bookkeeping_destination": "DATEV Unternehmen online",
|
||||||
|
}
|
||||||
|
for record in export.records
|
||||||
|
)
|
||||||
|
return _prepared(self.manifest, export, records)
|
||||||
|
|
||||||
|
|
||||||
|
class AdapterRegistry:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
adapters: tuple[AccountingAdapter, ...],
|
||||||
|
*,
|
||||||
|
default_adapter_id: str,
|
||||||
|
migrations: SchemaMigrations | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._adapters = {adapter.manifest.adapter_id: adapter for adapter in adapters}
|
||||||
|
if len(self._adapters) != len(adapters):
|
||||||
|
raise ValueError("adapter IDs must be unique")
|
||||||
|
if default_adapter_id not in self._adapters:
|
||||||
|
raise ValueError(f"unknown default adapter: {default_adapter_id}")
|
||||||
|
self.default_adapter_id = default_adapter_id
|
||||||
|
self.migrations = migrations or SchemaMigrations()
|
||||||
|
|
||||||
|
def manifests(self) -> tuple[AdapterManifest, ...]:
|
||||||
|
return tuple(
|
||||||
|
adapter.manifest for _, adapter in sorted(self._adapters.items())
|
||||||
|
)
|
||||||
|
|
||||||
|
def prepare(
|
||||||
|
self,
|
||||||
|
export: BillingBasisExport,
|
||||||
|
*,
|
||||||
|
adapter_id: str | None = None,
|
||||||
|
required_capabilities: frozenset[AccountingCapability] = frozenset(),
|
||||||
|
) -> PreparedTransfer:
|
||||||
|
selected_id = adapter_id or self.default_adapter_id
|
||||||
|
try:
|
||||||
|
adapter = self._adapters[selected_id]
|
||||||
|
except KeyError as error:
|
||||||
|
raise ValueError(f"unknown accounting adapter: {selected_id}") from error
|
||||||
|
missing = required_capabilities - adapter.manifest.capabilities
|
||||||
|
if missing:
|
||||||
|
names = ", ".join(sorted(missing))
|
||||||
|
raise ValueError(f"adapter {selected_id} lacks capabilities: {names}")
|
||||||
|
compatible = self.migrations.migrate(
|
||||||
|
export, adapter.manifest.input_schema_version
|
||||||
|
)
|
||||||
|
return adapter.prepare(compatible)
|
||||||
|
|
||||||
|
|
||||||
|
def default_registry() -> AdapterRegistry:
|
||||||
|
return AdapterRegistry(
|
||||||
|
(DatevDuoAdapter(), QontoComplementAdapter()),
|
||||||
|
default_adapter_id=DatevDuoAdapter.manifest.adapter_id,
|
||||||
|
)
|
||||||
103
tests/test_accounting_adapters.py
Normal file
103
tests/test_accounting_adapters.py
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
from dataclasses import replace
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from fin_hub.accounting import (
|
||||||
|
AccountingCapability,
|
||||||
|
DatevDuoAdapter,
|
||||||
|
SchemaMigrations,
|
||||||
|
default_registry,
|
||||||
|
)
|
||||||
|
from fin_hub.services.billing import BillingBasisExport, BillingBasisRecord
|
||||||
|
|
||||||
|
|
||||||
|
def _export(schema_version: str = "0.1") -> BillingBasisExport:
|
||||||
|
record = BillingBasisRecord(
|
||||||
|
billing_basis_id="billing-basis:stable",
|
||||||
|
cost_attribution_key="client:acme|app:portal|instance:prod-01",
|
||||||
|
client_id="acme",
|
||||||
|
application_id="portal",
|
||||||
|
app_instance_id="prod-01",
|
||||||
|
period_month="2026-07",
|
||||||
|
currency="EUR",
|
||||||
|
price_id="price:agreement-v1",
|
||||||
|
price_revision_of=None,
|
||||||
|
price_source="agreement-v1",
|
||||||
|
revenue=Decimal("200.00"),
|
||||||
|
direct_cost=Decimal("20.00"),
|
||||||
|
allocated_cost=Decimal("80.00"),
|
||||||
|
total_cost=Decimal("100.00"),
|
||||||
|
margin=Decimal("100.00"),
|
||||||
|
financial_fact_ids=("fact:1",),
|
||||||
|
financial_fact_corrections=(),
|
||||||
|
allocation_ids=("allocation:1",),
|
||||||
|
allocation_revisions=(),
|
||||||
|
provenance=("agreement-v1",),
|
||||||
|
)
|
||||||
|
return BillingBasisExport(
|
||||||
|
schema_version=schema_version,
|
||||||
|
artifact_type="billing_basis_report",
|
||||||
|
records=(record,),
|
||||||
|
exceptions=(),
|
||||||
|
disclaimer="Reporting basis only; not an invoice.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_datev_duo_is_default_and_prepares_only_an_incomplete_draft():
|
||||||
|
transfer = default_registry().prepare(_export())
|
||||||
|
|
||||||
|
assert transfer.adapter_id == "datev-duo-v1"
|
||||||
|
assert transfer.provider == "DATEV Unternehmen online"
|
||||||
|
assert transfer.records[0]["external_reference"] == "billing-basis:stable"
|
||||||
|
assert transfer.records[0]["net_billing_basis"] == Decimal("200.00")
|
||||||
|
assert transfer.records[0]["requires_legal_and_tax_completion"] is True
|
||||||
|
assert "Provider draft only" in transfer.disclaimer
|
||||||
|
assert not any(
|
||||||
|
key in transfer.records[0]
|
||||||
|
for key in ("invoice_number", "issue_date", "payment_status")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_qonto_requires_explicit_selection_and_keeps_datev_as_destination():
|
||||||
|
transfer = default_registry().prepare(
|
||||||
|
_export(),
|
||||||
|
adapter_id="qonto-invoice-draft-v1",
|
||||||
|
required_capabilities=frozenset(
|
||||||
|
{AccountingCapability.INVOICE_DRAFT_WORKFLOW}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert transfer.provider == "Qonto"
|
||||||
|
assert transfer.records[0]["bookkeeping_destination"] == (
|
||||||
|
"DATEV Unternehmen online"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_capability_mismatch_never_silently_falls_back_to_qonto():
|
||||||
|
with pytest.raises(ValueError, match="lacks capabilities: invoice_draft_workflow"):
|
||||||
|
default_registry().prepare(
|
||||||
|
_export(),
|
||||||
|
required_capabilities=frozenset(
|
||||||
|
{AccountingCapability.INVOICE_DRAFT_WORKFLOW}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_breaking_schema_requires_an_explicit_migration():
|
||||||
|
with pytest.raises(ValueError, match="no explicit billing-basis schema migration"):
|
||||||
|
default_registry().prepare(_export("1.0"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_registered_schema_migration_must_produce_declared_version():
|
||||||
|
migrations = SchemaMigrations()
|
||||||
|
migrations.register("1.0", "0.1", lambda export: replace(export, schema_version="0.1"))
|
||||||
|
from fin_hub.accounting.adapters import AdapterRegistry
|
||||||
|
|
||||||
|
registry = AdapterRegistry(
|
||||||
|
(DatevDuoAdapter(),),
|
||||||
|
default_adapter_id="datev-duo-v1",
|
||||||
|
migrations=migrations,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert registry.prepare(_export("1.0")).source_schema_version == "0.1"
|
||||||
|
|
@ -4,11 +4,11 @@ type: workplan
|
||||||
title: "Client attribution and billing basis"
|
title: "Client attribution and billing basis"
|
||||||
domain: financials
|
domain: financials
|
||||||
repo: fin-hub
|
repo: fin-hub
|
||||||
status: active
|
status: finished
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: financials
|
topic_slug: financials
|
||||||
created: "2026-08-10"
|
created: "2026-08-10"
|
||||||
updated: "2026-08-10"
|
updated: "2026-08-11"
|
||||||
related:
|
related:
|
||||||
- FIN-WP-0001
|
- FIN-WP-0001
|
||||||
- FIN-WP-0004
|
- FIN-WP-0004
|
||||||
|
|
@ -167,7 +167,7 @@ only disclaimer and has no invoice, bookkeeping, or payment behavior.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: FIN-WP-0002-T05
|
id: FIN-WP-0002-T05
|
||||||
status: wait
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "d30b606f-43c1-4610-875d-c1fe578c412c"
|
state_hub_task_id: "d30b606f-43c1-4610-875d-c1fe578c412c"
|
||||||
```
|
```
|
||||||
|
|
@ -178,6 +178,19 @@ retention responsibilities. Candidate products from the gap assessment are
|
||||||
lexoffice and sevDesk, but selection is an operator/business decision and is
|
lexoffice and sevDesk, but selection is an operator/business decision and is
|
||||||
not required to settle T00–T03.
|
not required to settle T00–T03.
|
||||||
|
|
||||||
|
Completed 2026-08-11: the operator selected DATEV Unternehmen online as the
|
||||||
|
authoritative external GoBD bookkeeping and document-retention system. Qonto
|
||||||
|
is the preferred complementary invoice workflow and managed DATEV transport
|
||||||
|
when DUO lacks or does not conveniently expose a required function. Added a
|
||||||
|
provider-neutral, capability-declared adapter contract with DATEV as the
|
||||||
|
default, explicit Qonto selection, exact input-schema compatibility, and
|
||||||
|
fail-closed migrations. The adapters create incomplete, side-effect-free
|
||||||
|
handoff drafts only; legal/tax completion, issuance, delivery, payments, and
|
||||||
|
bookkeeping remain authorized external actions. The integration, custody,
|
||||||
|
retention, and upgrade controls are recorded in
|
||||||
|
`docs/accounting-adapter-boundary.md`. Live provider activation is tracked by
|
||||||
|
`FIN-WP-0005`.
|
||||||
|
|
||||||
## Acceptance
|
## Acceptance
|
||||||
|
|
||||||
- [x] Repository scope explicitly allows the reporting and export boundary.
|
- [x] Repository scope explicitly allows the reporting and export boundary.
|
||||||
|
|
@ -188,4 +201,4 @@ not required to settle T00–T03.
|
||||||
- [x] Shared-infrastructure allocations reconcile to authoritative booked
|
- [x] Shared-infrastructure allocations reconcile to authoritative booked
|
||||||
costs with assumptions and unattributed residuals visible.
|
costs with assumptions and unattributed residuals visible.
|
||||||
- [x] The billing-basis export is idempotent and contains no payment execution.
|
- [x] The billing-basis export is idempotent and contains no payment execution.
|
||||||
- [ ] A human records the external invoicing/bookkeeping system decision.
|
- [x] A human records the external invoicing/bookkeeping system decision.
|
||||||
|
|
|
||||||
94
workplans/FIN-WP-0005-datev-accounting-adapter-operations.md
Normal file
94
workplans/FIN-WP-0005-datev-accounting-adapter-operations.md
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
---
|
||||||
|
id: FIN-WP-0005
|
||||||
|
type: workplan
|
||||||
|
title: "DATEV accounting adapter operations"
|
||||||
|
domain: financials
|
||||||
|
repo: fin-hub
|
||||||
|
status: proposed
|
||||||
|
owner: codex
|
||||||
|
topic_slug: financials
|
||||||
|
created: "2026-08-11"
|
||||||
|
updated: "2026-08-11"
|
||||||
|
related:
|
||||||
|
- FIN-WP-0002
|
||||||
|
state_hub_workstream_id: "1c081ee5-cf39-4868-ac17-48aada6e102b"
|
||||||
|
---
|
||||||
|
|
||||||
|
# FIN-WP-0005 — DATEV accounting adapter operations
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Operationalize the provider-neutral accounting handoff selected in
|
||||||
|
`FIN-WP-0002`: DATEV Unternehmen online is the bookkeeping and document
|
||||||
|
authority, with Qonto as the preferred complementary invoice workflow and
|
||||||
|
managed DATEV transport where its capabilities are sufficient.
|
||||||
|
|
||||||
|
This workplan does not authorize fin-hub to issue invoices, assign legal
|
||||||
|
invoice numbers, execute payments, or perform bookkeeping.
|
||||||
|
|
||||||
|
## Confirm operational capabilities and ownership
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: FIN-WP-0005-T01
|
||||||
|
status: todo
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "55cb6ec1-2bd6-4f33-8a4b-7791193c4c09"
|
||||||
|
```
|
||||||
|
|
||||||
|
With the operator and Steuerbüro, confirm the enabled Qonto plan, DATEV tenant
|
||||||
|
and services, customer/tax master-data owner, invoice workflow, document
|
||||||
|
retention policy, correction procedure, and required structured fields.
|
||||||
|
Record which capabilities use Qonto's managed DATEV services and which require
|
||||||
|
a direct DATEV integration. Route credentials only after this selection.
|
||||||
|
|
||||||
|
## Implement the selected transport
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: FIN-WP-0005-T02
|
||||||
|
status: wait
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "e2400fb4-ff8e-4336-9702-60d0c2e254b3"
|
||||||
|
```
|
||||||
|
|
||||||
|
After T01, implement the authorized transport behind the existing adapter
|
||||||
|
contract. Prefer the managed Qonto-to-DATEV route when it preserves the full
|
||||||
|
required record; otherwise use the approved DATEV API/export surface. Keep
|
||||||
|
authentication and network side effects outside pure payload preparation.
|
||||||
|
|
||||||
|
## Validate legal and bookkeeping completion
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: FIN-WP-0005-T03
|
||||||
|
status: wait
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "16865fc6-5e6e-46cd-9ed1-109fa0eeb8e7"
|
||||||
|
```
|
||||||
|
|
||||||
|
In a non-production tenant, demonstrate customer and tax completion, legal
|
||||||
|
invoice numbering and issuance by the authorized external system, document
|
||||||
|
arrival in DUO, bookkeeping visibility, exception handling, and correction or
|
||||||
|
cancellation flow. Do not treat transport acceptance as bookkeeping proof.
|
||||||
|
|
||||||
|
## Certify upgrades and rollback
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: FIN-WP-0005-T04
|
||||||
|
status: wait
|
||||||
|
priority: medium
|
||||||
|
state_hub_task_id: "e372997a-a5a2-4c02-a6f4-50ea34add067"
|
||||||
|
```
|
||||||
|
|
||||||
|
Golden-test and dual-run the selected adapter against the same stable billing
|
||||||
|
basis IDs. Reconcile counts, amounts, currencies, exceptions, and evidence
|
||||||
|
references; record provider and adapter versions, cutover approval, monitoring,
|
||||||
|
and rollback procedure.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- [ ] The Steuerbüro and operator have accepted the responsibility matrix and
|
||||||
|
selected transport.
|
||||||
|
- [ ] Provider credentials are routed through their proper custodian and are
|
||||||
|
absent from Git, artifacts, and logs.
|
||||||
|
- [ ] A non-production end-to-end transfer is reconciled in DATEV.
|
||||||
|
- [ ] Issuance, payments, and bookkeeping remain outside fin-hub.
|
||||||
|
- [ ] Upgrade, breaking-migration, monitoring, and rollback controls are tested.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue