Add DATEV accounting adapter boundary

This commit is contained in:
tegwick 2026-08-11 14:54:54 +02:00
parent fcec177ded
commit 90b7e97533
8 changed files with 583 additions and 13 deletions

View file

@ -2,7 +2,7 @@
# Custodian Brief — fin-hub
**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)*
## 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`
- · 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)

View file

@ -10,9 +10,10 @@
| --- | --- | --- | --- | --- |
| 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-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-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-T02 | 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-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-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-T02 | 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-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-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 |

View 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.

View 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",
]

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

View 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"

View file

@ -4,11 +4,11 @@ type: workplan
title: "Client attribution and billing basis"
domain: financials
repo: fin-hub
status: active
status: finished
owner: codex
topic_slug: financials
created: "2026-08-10"
updated: "2026-08-10"
updated: "2026-08-11"
related:
- FIN-WP-0001
- FIN-WP-0004
@ -167,7 +167,7 @@ only disclaimer and has no invoice, bookkeeping, or payment behavior.
```task
id: FIN-WP-0002-T05
status: wait
status: done
priority: high
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
not required to settle T00T03.
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
- [x] Repository scope explicitly allows the reporting and export boundary.
@ -188,4 +201,4 @@ not required to settle T00T03.
- [x] Shared-infrastructure allocations reconcile to authoritative booked
costs with assumptions and unattributed residuals visible.
- [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.

View 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.