Based on the arc42 template.
Introduction and Goals
einfache-eRechnung.de (EER) converts traditional PDF invoices into structured e-invoices. German law requires all B2B invoices to use structured electronic formats by January 2027. Most small business owners and sole proprietors — whether craftsmen, freelancers, consultants, or service providers — create invoices in Word or Excel and lack the tools or appetite to adopt new software.
EER solves this by accepting PDF invoices via email, extracting invoice data with an LLM, and returning a ZUGFeRD-compliant e-invoice — also via email. The user changes one thing: the recipient email address. No new software, no training, no workflow disruption. When an uploaded invoice is not yet ready for a valid e-invoice, EER explains typical missing or inconsistent invoice data and guides the user toward correcting the source invoice. This extends the product from pure conversion to guided e-invoice readiness without turning it into accounting software.
We turn your PDF into an e-invoice — you only change the email address.
Requirements Overview
Business Goals
| Goal | Description |
|---|---|
Workflow continuity |
Small businesses keep their existing invoice-creation process (Word/Excel/Pages → PDF → email). Only the email recipient changes. |
Legal compliance |
Generated e-invoices conform to ZUGFeRD 2.5 (EN16931 profile) and satisfy German B2B e-invoicing obligations under UStG/UStDV. |
Guided invoice readiness |
Users understand whether their existing invoice is formally complete, arithmetically plausible, and ready for e-invoicing before they send it onward. |
Market validation |
Validate product-market fit through a fake-door pricing page and free trial before investing in billing infrastructure. |
Core Workflow
Key Functional Requirements
| ID | Requirement |
|---|---|
F-1 |
Accept PDF invoices as email attachments via Mailgun inbound webhook. |
F-2 |
Extract structured invoice data (seller, buyer, line items, tax, totals) from supported native-text PDFs via layout-aware document interpretation and LLM extraction. |
F-3 |
Generate ZUGFeRD 2.5 e-invoices (EN16931 profile): hybrid PDF with embedded CII XML. |
F-4 |
Return the generated e-invoice to the sender via email. |
F-5 |
Provide a diagnosis-first browser flow for validation failures and keep the edit session as a secondary path for genuine extraction or mapping errors. |
F-6 |
Authenticate users via magic links (passwordless). No separate registration required — first email submission creates the account. |
F-7 |
Enforce time-based access control: 30-day free trial with full functionality, then flat-rate subscription. All tiers produce identical, EN 16931-compliant e-invoices. |
Out of Scope
-
Full bookkeeping or accounting features
-
ERP integration or bank synchronization
-
Cross-border VAT automation
-
B2G invoicing (XRechnung / Peppol) — planned for future expansion
Quality Goals
The following quality goals drive architectural decisions. They are ordered by priority. Chapter 10 contains the full quality tree with detailed scenarios.
| Priority | Quality Goal | Scenario |
|---|---|---|
1 |
Simplicity ( |
A small business owner who has never heard of "e-invoicing" sends a PDF invoice to the EER email address and receives a valid e-invoice within 2 minutes — without creating an account first, without reading documentation, and without installing software. |
2 |
Compliance ( |
Every generated e-invoice passes EN16931 schema validation and ZUGFeRD 2.5 conformance checks. An invoice that fails validation is never delivered to the user; instead, the system opens a diagnosis-first correction path. |
3 |
Data privacy ( |
All invoice data processing — including LLM extraction — occurs within the EU. No invoice content leaves EU jurisdiction. The system deletes processing artifacts within 30 days. |
4 |
Reliability ( |
When PDF extraction fails or produces incomplete data, the system responds within 60 seconds with an actionable diagnosis or correction path. No invoice is silently lost. Every processing step is tracked via OpenTelemetry spans and persisted as business events. |
Stakeholders
| Role | Contact | Expectations |
|---|---|---|
Small business owners and sole proprietors |
Primary users |
Zero learning curve. Send email, receive e-invoice. Affordable pricing. No forced workflow change. |
Tax advisors |
Recommenders |
Tool they can trust to recommend to clients. Legally compliant output, understandable diagnosis of common formal invoice problems, and transparent processing — no black box. |
Development team |
Internal |
Maintainable codebase. Fast iteration cycles. Architecture that supports incremental feature growth without rewrites. |
Mailgun |
Email infrastructure provider |
Reliable webhook delivery for inbound emails. Stable API for outbound sending. |
Mistral AI |
LLM provider (EU-hosted) |
Consistent extraction quality. EU data residency. Predictable API costs. |
Architecture Constraints
The following constraints limit design and implementation decisions. They originate from the technology stack, the small team setup, German regulatory requirements, and agreed coding conventions.
Technical Constraints
| ID | Constraint | Background |
|---|---|---|
TC-1 |
Kotlin 2.x on JVM 21 (Eclipse Temurin) |
Developer expertise. Kotlin’s null safety and conciseness reduce defect rate for a small team. |
TC-2 |
Spring Boot 3.x with Virtual Threads |
Mature ecosystem for web, JPA, mail, AI, and security. Virtual threads ( |
TC-3 |
PostgreSQL 18 as the only database technology |
One PostgreSQL cluster hosts separate EER, Clarula, and Metabase logical databases. Product application roles have no sibling-database access. No NoSQL, Redis, foreign data wrapper, or secondary product store. |
TC-4 |
Product-local Flyway chains; Hibernate |
EER and Clarula own independent migration locations and schema histories in their logical databases. Each Hibernate persistence unit validates only its product schema at startup. |
TC-5 |
Single deployable modular monolith (no microservices) |
One Spring Boot JAR and process. Compile-time product modules and explicit Spring servlet contexts enforce internal boundaries without adding distributed-system operations. |
TC-6 |
Docker-based deployment on a single VPS |
Docker Compose orchestrates the backend, PostgreSQL, Caddy, two static Nuxt/Nginx frontend containers, Metabase, and partner demos. Frontend images contain no Node/Nuxt runtime and publish no host ports. No Kubernetes or managed container services. |
TC-7 |
Caddy as reverse proxy |
Automatic HTTPS via Let’s Encrypt. Zero certificate management overhead. |
TC-8 |
10 MB max upload size for PDF invoices |
Shared Spring multipart settings cap each file and request at 10 MB. A future multi-file upload must introduce feature-owned request limits without widening unrelated upload endpoints. |
TC-9 |
Gradle Kotlin DSL build system |
Consistent language (Kotlin) across production code and build scripts. |
TC-10 |
Mistral AI for LLM-based invoice data extraction |
EU-hosted AI provider. Selected over OpenAI to satisfy the EU data processing constraint (see LC-2). Uses Spring AI integration. |
TC-11 |
Mustang Project 2.x + PDFBox 3.x for ZUGFeRD generation |
Open source libraries for creating and validating ZUGFeRD/Factur-X PDF/A-3 invoices. No commercial PDF licenses. |
TC-12 |
OpenTelemetry Java Agent for observability |
Automatic instrumentation of HTTP, JPA, and Spring components. Exports traces, metrics, and logs via OTLP. |
TC-13 |
linux/amd64 as the only target platform |
Docker images build for amd64 only. The production VPS runs Linux x86_64. |
TC-14 |
Dev environment provisioned by Terraform + cloud-init |
The permanent dev environment is defined in |
TC-15 |
DATEV Online API behind an environment-selected port |
Local and demo operation use a deterministic mock. Sandbox and production require separate DATEV app credentials, exact callback registration, paid |
Organizational Constraints
| ID | Constraint | Background |
|---|---|---|
OC-1 |
Small team |
Lean team makes architecture, implementation, and operations decisions. Code quality relies on automation (tests, linting, CI). |
OC-2 |
No dedicated operations team |
Infrastructure must be simple and self-managing. Caddy handles TLS automatically. Docker Compose restarts containers on failure. No on-call rotation. |
OC-3 |
Budget-conscious — single VPS, no expensive cloud services |
No AWS/Azure/GCP managed services. One Hetzner VPS hosts the entire stack. Cost stays below 50 EUR/month for infrastructure. |
OC-4 |
Open source libraries preferred |
Mustang Project, PDFBox, Spring Boot — all open source. Avoids vendor lock-in and license costs. |
OC-5 |
GitHub Actions with dev-first deployment flow |
Push to |
OC-6 |
English for all code, comments, commits, and PRs |
Technical communication in English. Enables future contributors and aligns with open source conventions. |
OC-7 |
German for all user-facing text |
Target market is Germany. UI, emails, error messages, and landing pages use plain German with consistent |
OC-8 |
Compiler warnings treated as errors |
|
OC-9 |
Environment-specific secret management during rollout |
Dev deploys already fetch secrets from 1Password at workflow runtime. Prod still uses GitHub Environment secrets until the migration is completed. Documentation and operations must reflect this split accurately. |
Legal and Regulatory Constraints
| ID | Constraint | Background |
|---|---|---|
LC-1 |
GDPR compliance mandatory |
Target market is Germany. All personal data processing follows GDPR. Privacy policy and data processing transparency required. |
LC-2 |
All data processing within the EU |
Reason for choosing Mistral AI (EU-hosted) over OpenAI (US-hosted). Invoice content — potentially containing personal data — must not leave EU jurisdiction. |
LC-3 |
Generated e-invoices must comply with EN 16931 |
European e-invoicing standard. Mustang Project’s validator checks EN 16931 Schematron rules on every generated invoice. |
LC-4 |
ZUGFeRD 2.5 format for the German B2B market |
ZUGFeRD 2.5 (= Factur-X 1.09) embeds structured XML (CII) inside a PDF/A-3 document. This is the format German businesses expect. |
LC-5 |
German B2B e-invoicing mandate starting January 2027 |
Businesses with revenue above 800k EUR must send structured e-invoices. This mandate creates the market demand for EER. |
LC-6 |
No permanent invoice archive; bounded workflow and routing state only |
EER stores the source PDF and its application-owned invoice draft only in edit sessions that expire after 30 days. A completed session additionally retains its exact generated ZUGFeRD PDF, verification PDF, and XML for byte-stable delivery until the same expiry; these target artifacts are not editable state. Clarula separately stores encrypted partner authorization and minimized, provenance-bearing mandate routing snapshots, but no complete DATEV responses or general master-data edits. |
LC-7 |
AGB (Terms of Service) required before payment processing |
Open point. No real payment processing until AGB are finalized and published. Currently operating in a free trial / waitlist model. |
Conventions
Conventions are self-imposed constraints the team agreed on. They guide daily development decisions.
| ID | Convention | Background |
|---|---|---|
CO-1 |
Package-by-feature, not package-by-layer |
Top-level packages reflect business capabilities: |
CO-2 |
Constructor injection only |
No |
CO-3 |
Sealed classes for result types |
Business operations return sealed class hierarchies (e.g., |
CO-4 |
|
Provides UUID primary key, |
CO-5 |
Three-tier test segregation |
Unit tests ( |
CO-6 |
Spotless + ktlint for formatting; Detekt for static analysis |
|
CO-7 |
Kotlin null safety over Optional |
Use |
CO-8 |
|
Disables the Open Session in View anti-pattern. |
Context and Scope
The EER product (public brand: einfache-eRechnung.de) converts PDF invoices into ZUGFeRD 2.5 e-invoices; the same backend also hosts Clarula capabilities. Users interact exclusively through email and a web UI — no dedicated client software required.
This chapter defines the system boundary and all external communication partners.
Business Context
The business context shows what data the backend product system exchanges with its environment, independent of protocols or infrastructure.
External Communication Partners
The following table describes each communication partner and the data exchanged.
| Partner | Direction | Data Exchanged | Purpose |
|---|---|---|---|
Small Business Owner |
Bidirectional |
PDF invoice (inbound), ZUGFeRD PDF (outbound), correction form data (web UI) |
Primary user. Sends invoices via email, receives e-invoices back. Corrects extraction errors through the web portal. |
Tax firm representative and DATEV Online APIs |
Bidirectional |
Direct DATEV authentication and consent; OAuth authorization, organization identity, and minimized mandate master data |
Admin-assisted firm onboarding and mandate import. Clarula never receives the representative’s DATEV credentials. Local and demo operation use a deterministic mock behind the same port. |
Mailgun |
Bidirectional |
Inbound: webhook payload with email metadata and PDF attachment. Outbound: email with ZUGFeRD PDF or error notification. Also delivers DOI confirmation and welcome emails for the registration flow. |
Email infrastructure. Routes inbound emails to the EER context via webhook. Delivers all outbound emails to users, including transactional and registration emails. |
Mistral AI |
Bidirectional |
Chat API receives extraction prompt plus local document evidence/context (outbound) and returns extracted invoice JSON (inbound). |
Semantic extraction provider. Supported native-text PDFs are interpreted locally before the chat call. Scanned or image-only PDFs remain out of scope and fail closed per ADR-17. EU-hosted (data residency). |
PostgreSQL |
Bidirectional |
Separate logical databases for EER invoice/account/widget state and Clarula firm/DATEV state; independent product roles, migrations, and backup artifacts. |
Persistent storage for application state. Normal product connections cannot access the sibling database. |
New Relic |
Outbound |
OpenTelemetry traces, metrics, and logs. |
Observability. Monitors application health, performance, and errors in production. |
GHCR |
Inbound |
Docker container images for all services. |
GitHub Container Registry. Stores and delivers deployment artifacts. |
Scope
Inside the system boundary (einfache-eRechnung.de):
-
PDF-to-ZUGFeRD conversion pipeline (extraction, validation, embedding)
-
Email ingestion and response delivery
-
Invoice correction web UI
-
Account management and authentication (magic links)
-
Free website upload API
-
Business event tracking
-
Product-owned marketing, legal, content, and customer application frontends (static Nuxt/Nginx images)
-
Self-hosted registration with double opt-in (DOI) via Mailgun
-
Clarula
Firm-scoped DATEV authorization, encrypted token persistence, minimized mandate import, and mandate confirmation in an independent product database
Outside the system boundary:
-
Email transport (Mailgun)
-
Invoice data extraction intelligence (Mistral AI)
-
Monitoring infrastructure (New Relic)
-
Container registry (GHCR)
-
DNS and TLS termination (Caddy reverse proxy)
-
DATEV identity provider and mandate-master-data service
Technical Context
The technical context shows how the backend communicates with its environment: protocols, channels, and infrastructure.
Channel Details
| Channel | Protocol | Authentication | Notes |
|---|---|---|---|
Mailgun → EER (inbound email) |
HTTPS POST (webhook) |
HMAC-SHA256 signature validation |
Mailgun sends multipart/form-data to |
EER → Mailgun (outbound email) |
HTTPS POST (REST API) |
HTTP Basic Auth ( |
Sends multipart/form-data to Mailgun Messages API. Attachments include ZUGFeRD PDF. Also delivers DOI confirmation and welcome emails for the registration flow. In local development, replaced by SMTP to Mailpit. |
EER → Mistral AI (LLM extraction) |
HTTPS POST (REST API) |
Bearer token (API key) |
Sends structured evidence derived locally from the PDF through Spring AI ChatClient to the configured Mistral model. Six specialized calls return typed JSON that is mapped once into the application-owned invoice model. EU-hosted endpoints ensure data residency compliance. |
Clarula <→ DATEV Online APIs |
HTTPS (OIDC and REST) |
Authorization Code with PKCE; encrypted rotating tokens; |
An admin starts the flow under |
Backend → PostgreSQL |
JDBC over TCP |
Username/password |
Two Spring Data JPA persistence units connect on port 5432 within the Docker bridge network: role |
Backend → New Relic (observability) |
OTLP/gRPC |
API key in |
OpenTelemetry Java Agent exports traces, metrics, and logs to |
Internet → Caddy (all web traffic) |
HTTPS (ports 80/443) |
Automatic TLS via Let’s Encrypt |
Caddy terminates TLS and proxies remaining product traffic to each product’s immutable Nuxt/Nginx image on |
GHCR → VPS (deployment) |
HTTPS (Docker pull) |
GitHub token |
|
Domain Mapping
The following table maps business-level data flows to their technical channels.
| Business Data Flow | Technical Channel |
|---|---|
User sends PDF invoice |
User → email client → Mailgun → HTTPS webhook → EER |
User receives ZUGFeRD e-invoice |
EER → Mailgun REST API → Mailgun → email delivery → User |
PDF extraction to XML |
EER → HTTPS → Mistral AI Chat Completions API |
Invoice corrections via web UI |
User → HTTPS → Caddy → HTTP → backend ( |
Visitor tries free website upload |
User → HTTPS → Caddy → HTTP → backend (einfache-erechnung.de/api/…) |
Observability data |
OpenTelemetry Java Agent → OTLP/gRPC → New Relic EU endpoint |
Account registration (DOI) |
Visitor → HTTPS → site form → POST /registration/register → Caddy → backend → Mailgun (DOI email) |
Newsletter DOI confirmation |
Subscriber clicks confirmation link → Caddy → backend ( |
Solution Strategy
EER converts PDF invoices into ZUGFeRD 2.5 e-invoices through LLM-based data extraction. The architecture optimizes for low adoption friction: German SMBs send a PDF by email and receive a compliant e-invoice back — no new software, no login required. When the source invoice is not ready, the product explains the problem in plain language before asking the user to retry.
The following strategies shape every technical and product decision.
Approach to Quality Goals
| Quality Goal | Scenario | Solution Approach |
|---|---|---|
Usability |
A small business owner sends a PDF invoice via email and receives a ZUGFeRD e-invoice within minutes — without registration or training. |
Email-based interface eliminates login barriers. Web UI exists only for diagnosis, corrections, and account management. German diagnosis messages guide users through validation failures. |
Compliance |
Every generated e-invoice passes EN 16931 validation and meets ZUGFeRD 2.5 requirements. |
Mustang Project validates and generates XML against the EN 16931 schema. Hybrid PDF generation preserves the original PDF and avoids font corruption. Diagnosis-first handling keeps invalid source invoices out of delivery and explains why they failed. No tier differences in compliance quality. |
Data Privacy |
Invoice data (VAT IDs, addresses, amounts) stays within the EU at all times. |
Mistral AI (EU company, EU servers) processes all LLM extraction. No US-based provider in the data path. Spring AI abstracts the provider for future switches. |
Operability |
The team monitors and troubleshoots production issues without dedicated ops staff. |
OpenTelemetry Java agent exports traces, metrics, and logs to New Relic. Business events recorded both in the database and as OTel spans. Monolith deployment reduces moving parts. |
Modifiability |
New e-invoice formats (XRechnung, Factur-X) can be added without restructuring the codebase. |
ADR-34 places EER-owned invoice semantics and analysis orchestration in |
Key Solution Strategies
Email as Primary Interface
Users send their PDF invoice to an EER email address. Mailgun receives the email and forwards it as a webhook to the application. The application extracts invoice data, generates the ZUGFeRD e-invoice, and replies to the sender’s email with the result attached.
This removes the biggest adoption barrier for the target audience: small business owners and sole proprietors who already produce PDF invoices in Word or their trade software. They change one email address instead of learning new software.
The web UI handles corrections and account management only — tasks that need a form-based interface.
Traces to: usability goal, vision statement ("you only change the email address").
Native Document Evidence with Parallel Specialized LLM Extraction
For supported native-text PDFs, EER uses local Xberg interpretation as the evidence source. XbergPdfStructuredTextExtractor is the PDF boundary, and ADR-17 keeps layout-aware document evidence local. PDF is the only supported input format; scanned or image-only PDFs fail closed.
The implemented stages are:
-
XbergPdfStructuredTextExtractorvalidates the PDF and capturesSourceEvidence: hash and size, complete pages containing plain text plus layout-aware Markdown, separately bounded analysis text, and truncation state. The two deterministic renderings complement each other: plain text recovers labels omitted by layout detection, while Markdown preserves table associations. OCR remains disabled and each detected table is rendered once in Markdown. -
ParallelInvoiceExtractorruns four Mistral Structured Outputs calls concurrently — header, parties, lines, and payment terms. It then extracts totals and tax with line-item context, followed by notes with all five structured slices as context. -
The specialized prompts interpret one complete semantic invoice. Line recognition, implicit quantity, price kind, gross-to-net conversion, amount-only rows, settlement meaning, and semantic totals remain LLM responsibilities.
-
ExtractedInvoiceObservationMappermaps the semantic provider DTO once to evidence-bound observations with stable IDs. It does not reinterpret layout or claim exact fragments without extractor-provided anchors. -
Common validation checks required semantics, ambiguity, and deterministic monetary relationships. Typed analysis outcomes distinguish candidates, non-invoices, unreadable documents, rejected sources, and processing failures.
-
The ZUGFeRD edge adapter applies EN16931 readiness, maps validated semantics, renders and validates CII XML, compares rendered values with the semantic model, and embeds XML into the matching source PDF only after all gates pass.
This supersedes the previous sequential DocumentSemantics + ExtractedInvoice chain and removes code-side layout interpretation and monetary reconciliation. The EER model keeps one value per invoice concept; the PDF remains immutable evidence. Internally inconsistent source PDFs surface as validation findings and default to source correction or explicit review. The editor is reserved for genuine recognition or mapping errors.
Trade-off: semantic extraction remains non-deterministic, every extractor prompt needs its own benchmarking, and scanned or image-only PDFs remain out of scope and fail closed.
Traces to: usability goal (low-friction conversion for supported documents), modifiability goal (format-neutral pipeline without template maintenance).
Product Isolation inside One Backend Process
The backend remains one BootJar and deployment unit, while Gradle modules, servlet mappings, and Spring application-context boundaries separate product code at construction and runtime.
The persistence-free :app root eagerly starts sibling EER and Clarula web contexts behind one parentless bridge of explicitly shared stateless services.
:eer owns the established public routes through the default / DispatcherServlet; :clarula owns /admin/clarula/, whose longer servlet mapping wins.
Each product owns its DataSource, Flyway chain, JPA persistence unit, and one conventional local transactionManager; the root owns none of them and exposes aggregate product health through /actuator/.
One PostgreSQL cluster keeps operations small, but distinct EER and Clarula logical databases and login roles reject normal cross-product connections.
This completes ADR-30 and ADR-31 without introducing a second process.
EER-Owned Invoice Semantics and Edge Adapters
ADR-34 establishes the semantic processing boundary inside :eer.
Native evidence and specialized extraction map into EER-owned observations, drafts, and commonly validated invoice semantics.
The unchanged source PDF remains authoritative evidence for issuer-visible content; semantic claims and recognition corrections retain provenance.
Email, website, widget, diagnosis, and correction consume this model rather than generated ZUGFeRD XML.
Target-specific defaults, calculations, schemas, and libraries live behind EER adapters.
Mustang and PDFBox remain inside the ZUGFeRD adapter, while a future EER XRechnung adapter would map the same semantics and apply independent readiness rules.
The one-time destructive cutover deletes all pre-model correction sessions and removes XML-backed edit state rather than manufacturing source evidence from generated artifacts.
Clarula remains isolated in :clarula and does not import the EER invoice model.
Imported Routing Identity Instead of General Master Data Management
The e-invoice conversion product stores no customer master data and no invoice content beyond temporary edit sessions. All data needed to create an e-invoice comes from the source PDF. Edit sessions expire after 30 days; a daily cleanup job deletes them.
The Clarula identity-led partner demo adds one bounded exception defined by PDR: Identity-led client widget. DATEV remains the source of mandate identity and routing targets. A Clarula administrator starts a Firm-scoped DATEV authorization; the authorized firm representative authenticates directly at DATEV. Clarula encrypts the resulting tokens and verifies the DATEV business-partner identity before importing mandate data into the separate Clarula database. The EER Partner registry has no identity or persistence relationship to this Firm.
A DatevApi port isolates authorization, single-use token refresh, revocation, firm verification, and mandate import. Its deterministic mock keeps local and demo flows reproducible. One configurable HTTP adapter targets sandbox or production. Both map into the same minimized snapshot containing mandate labels, available names and addresses, available strong identifiers, provenance, and one simulated target per mandate. Firm administrators select and confirm imported mandates and maintain uploader email grants; imported mandate, identity, and target fields are read-only. Manual replacement data is outside the demo scope.
At submission start, Clarula copies the selected mandate’s required current import values and provenance into an immutable per-submission mandate context. Classification uses that capture only to prove mandate and seller/buyer role before automatic handover, so a later re-import cannot change an in-flight or historical assessment. An incomplete snapshot does not block invitation or upload, but every affected invoice fails closed to firm review and cannot call the DATEV demo port. The imported records never pre-fill or rewrite invoice content.
This keeps recurring invoice fields and general customer-master-data editing outside Clarula while accepting the smaller privacy, provenance, and lifecycle surface required for conservative routing. ADR-29 fixes the OAuth lifecycle, adapter boundary, and same-source re-import semantics; ADR-32 supersedes only its ownership anchor from EER Partner to Clarula Firm. Automatic synchronization, source replacement, and production document handover remain later decisions.
Trade-off: the partner demo stores sensitive routing snapshots and may route more invoices to review when imported DATEV data is incomplete. Users still cannot pre-fill recurring invoice fields; each PDF remains self-contained.
Traces to: data privacy goal, operability goal (bounded state), conservative routing requirement.
Hybrid ZUGFeRD Generation
ZUGFeRD e-invoice generation uses two libraries in sequence:
-
Mustang Project generates and validates the EN 16931-compliant XML.
-
PDFBox attaches the XML as an embedded file to the original PDF and sets the required PDF/A-3 metadata.
Mustang’s built-in PDF/A-3 conversion re-renders the entire PDF, which corrupts fonts in many real-world invoices. The hybrid approach preserves the original PDF byte-for-byte and only adds the XML attachment.
GhostScript handles PDF/A-3 color profile conversion when needed.
Traces to: compliance goal, usability goal (preserves original visual appearance).
EU-First Data Processing
All invoice data processing happens within the EU:
-
LLM extraction: Mistral AI (French company, EU data centers)
-
Email routing: Mailgun EU region
-
Application hosting: Hetzner (Germany)
-
Database: PostgreSQL on the same host
The migration from OpenAI to Mistral was driven by documented customer concerns about US data processing and CLOUD Act exposure (see PDR: LLM Provider Migration).
Spring AI’s provider abstraction made this a configuration change rather than a code rewrite.
Traces to: data privacy goal, compliance with GDPR.
Fail-Fast with Diagnosis-First Retry
When validation fails, the system does not silently drop the invoice or return a cryptic error. Instead:
-
The user receives an email with a specific, German-language explanation of what failed.
-
The email links to a diagnosis page that explains whether the likely problem is in the source invoice or in recognition.
-
The default next step is to correct the original invoice and upload a new PDF.
-
The edit form remains available as an exception path when the visible PDF is correct but extraction or mapping was wrong.
-
The system generates a new ZUGFeRD e-invoice only after the corrected retry passes validation.
This turns validation failures from dead ends into guided e-invoice readiness checks with a bounded manual-correction fallback.
Traces to: usability goal (actionable errors), compliance goal (invalid invoices never ship), product positioning (explain source-invoice problems without replacing the user’s invoicing tool).
Technology Decisions
| Technology | Decision | Rationale |
|---|---|---|
Kotlin |
Primary language (Kotlin 2.x / JVM 21) |
Null safety reduces defensive code. Concise syntax (data classes, sealed results, extension functions) keeps the codebase small. Full Spring Boot interop without friction. |
Spring Boot |
Application framework (3.5.x) |
Mature ecosystem covers all needs: web, email, JPA, scheduling, configuration. Spring AI provides a clean abstraction over LLM providers. Large community means problems are well-documented. |
Mistral AI |
LLM provider via Spring AI |
EU company with EU-only data processing. 67% cheaper than GPT-4o-mini. Spring AI starter makes provider switches a configuration change. See PDR: LLM Provider Migration. |
PostgreSQL |
Relational database (v16) |
Reliable, well-understood. Flyway manages schema migrations. JSONB columns store flexible event payloads. Testcontainers provides identical database in tests. |
Mustang Project |
ZUGFeRD XML generation and validation (v2.22.0) |
Only actively maintained open-source ZUGFeRD library on the JVM. Implements the full EN 16931 validation. No viable alternative exists. |
PDFBox |
PDF manipulation (v3.0.1) |
Embeds XML into PDF without re-rendering the document. Preserves original fonts and layout. Apache-licensed, well-maintained. |
Caddy |
Reverse proxy |
Automatic HTTPS via Let’s Encrypt. Simple configuration file. Handles TLS termination and HTTP/2 without manual certificate management. |
OpenTelemetry |
Observability (Java agent v2.25.0) |
Zero-code instrumentation via Java agent. Exports traces, metrics, and logs to New Relic. Business events are both persisted to PostgreSQL and emitted as OTel spans for correlation. |
Mailgun |
Email sending and receiving |
Webhook-based inbound email routing. EU region available. Handles DKIM/SPF/DMARC for deliverability. Switchable — the |
Organizational Decisions
Monolith-First
The backend deploys as the backend Spring Boot modular monolith containing all business logic, including registration and DOI. Two independently generated static Nuxt product frontends live under applications/frontend/, with partner demo pages under demos/partner/. The application keeps one process and one deployment unit while ADR-31 supersedes ADR-5’s one-database clause with separate EER and Clarula logical databases and roles in one PostgreSQL cluster. Static frontends, widget bundles, and demos do not constitute a microservices architecture.
For a small team, splitting business logic across services would add operational overhead (networking, deployment orchestration, distributed tracing) without proportional benefit. The package-by-feature structure keeps modules decoupled within the backend monolith. Static frontends consume the explicit /api/v1/ OpenAPI contract and contain no business logic. Splitting the backend into separate services remains possible if scale demands it.
Static Delivery and Explicit Route Ownership
Nuxt is build-time tooling only. Each product image combines one immutable generated release with product-owned unprivileged Nginx. Caddy proxies same-origin /api/v1/ plus an explicit allow-list of backend-owned widget, webhook, actuator, and compatibility paths before forwarding remaining traffic to the matching frontend container. The dedicated operator origin serves its static frontend and proxies only the explicit /api/operator/v1/ owners, including Clarula callbacks. Dynamic diagnosis and correction navigation uses two dedicated client-shell files restricted to HTML GET/HEAD and one token segment. Generated fixed application documents are matched exactly; Clarula has no generic auth fallback. Unknown content, assets, and application paths therefore retain real 404 semantics. Unknown canonical API operations remain backend-owned: authenticated requests receive the backend 404, while unauthenticated requests may receive the API security boundary’s 401 first; neither case can fall back to frontend HTML.
Each frontend, backend, Caddy, and demo keeps an independent immutable image version. Dev recreates only selected components; production promotes the complete runtime. A brief product-specific interruption during frontend replacement is accepted.
Package-by-Feature
Code is organized by business capability, not by technical layer:
applications/backend/
├── app/ # persistence-free BootJar and servlet composition root
│ └── com.clarula.app/{runtime,security,health}
├── core/ # product-neutral stateless configuration and contracts
│ └── com.clarula.core/{config,security,web}
├── eer/ # EER application, persistence, assets, widget, and tests
│ └── com.clarula.backend/{email,invoice,formats,identity,widget,...}
└── clarula/ # Clarula application, persistence, assets, and tests
└── com.clarula.clarula/{firm,datev,...}
Each package owns its controllers, services, repositories, and domain types. No cross-package database access. infrastructure is the only horizontal package.
ADR-30 defines module ownership; ADR-34 refines EER invoice processing. PDF/LLM analysis lives under :eer package invoice.conversion.analysis.pdf; formats.zugferd contains only target mapping, readiness, validation, and output generation. The architecture still "screams" its domain: a new developer sees product boundaries, invoice conversion, target formats, routing, and email capabilities before JpaConfig or SecurityConfig.
Time-Based Monetization
The pricing model uses time-based access (30-day free trial, then flat-rate subscription) rather than volume limits. All tiers produce identical, EN 16931-compliant e-invoices.
Volume-based pricing penalizes high-volume businesses and lets low-volume users stay free indefinitely — neither outcome aligns with the product’s value delivery. See PDR: Tiering v2 for the full rationale.
Dev-First Delivery, Manual Production Promotion
The release flow now treats dev as the automatic proving ground and production as an explicit promotion step.
Every merge to main deploys to the permanent dev environment.
Production deploys are triggered manually after the change has been observed in dev.
This protects customer experience without introducing a heavier branching or release-management model.
The same application topology and Spring production profile run in dev, so the team validates real deployment behavior before shipping to customers.
Trade-off: one more human step exists between main and prod.
This is intentional friction.
Traces to: operability goal, customer-quality protection, small-team operations.
Building Block View
This chapter shows the static decomposition of the backend product system. Level 1 decomposes the overall system into deployment units. Level 2 zooms into the backend’s four Gradle modules and application-context boundaries. Level 3 opens each module separately so package responsibilities and dependencies remain readable.
Cross-cutting implementation rules live in Chapter 8. The package-by-feature organization keeps the Level 3 structures aligned with the source tree.
Level 1: Whitebox Overall System
The system consists of the backend, two independently deployable static product frontends, one dedicated static operator frontend, framework-independent widget bundles, partner demos, and an analytics dashboard.
The backend contains all business logic, including registration and double opt-in, in one BootJar and process.
EER and Clarula run in eager sibling Spring web contexts loaded from compile-time-independent product modules; the persistence-free :app context composes their servlet mappings and the one server.
The Nuxt applications under applications/frontend/ and partner demos under demos/partner/ are stateless companions with no shared database or domain logic.
Metabase provides business event dashboards.
One PostgreSQL cluster hosts separate logical databases and login roles for einfache-eRechnung and Clarula plus the operational Metabase database.
This decomposition follows ADR-5: Monolith-First Architecture, ADR-30, and ADR-31.
Motivation
The system separates by deployment independence and technology fit:
-
backend — Spring Boot modular monolith that owns all business capabilities.
:appis the persistence-free composition root and sole BootJar owner. Eager sibling contexts from:eerand:clarulaown their controllers, assets, schedulers, migrations, and persistence infrastructure. It remains one process and deployment unit with separate product databases. -
product frontends — Nuxt generates one static artifact for einfache-eRechnung and one for Clarula. Each product image contains its artifact and unprivileged Nginx configuration; Caddy proxies remaining product traffic to it. Product presentation changes independently from backend releases.
-
operator frontend — Nuxt generates the internal EER/Clarula workspace as a third static artifact. Only
admin.clarula.deserves it; Caddy proxies three explicit operator API namespaces to the owning backend sibling and strips credentials before static requests reach Nginx. -
widget bundles — esbuild produces independent vanilla-JavaScript web components. Their stable script URLs remain outside the Nuxt applications.
-
partner demos — static demo pages for widget and partner flows.
PostgreSQL serves the backend exclusively. The sites and demos are stateless.
Contained Building Blocks (Level 1)
| Building Block | Responsibility | Technology |
|---|---|---|
backend |
Converts PDF invoices to ZUGFeRD e-invoices. Handles email ingestion, document interpretation, semantic extraction, XML validation, PDF generation, invoice correction UI, user identity, self-hosted registration with DOI via Mailgun, and business event tracking. |
Kotlin 2.x, Spring Boot 3.5, JVM 21 |
product frontends |
Own the complete static product experiences, including marketing content, legal pages, current registration/contact/free-upload interactions, and future customer application routes. Consume the generated product-v1 API client. |
Nuxt 4, Vue 3, TypeScript, static HTML/CSS/JS |
operator frontend |
Owns the static operator login, navigation, EER partner, Clarula firm, DATEV, and mandate presentation. It consumes separate auth, EER, and Clarula operator clients without owning business logic or sharing product transport types. |
Nuxt 4, Vue 3, TypeScript, static HTML/CSS/JS |
widget bundles |
Provide the standard partner upload widget through a stable embed script without importing its implementation into Nuxt. |
Vanilla JavaScript Web Components, esbuild |
partner demos |
Static partner/demo pages for widget flows under |
HTML/CSS/JS |
metabase |
Business analytics dashboard. Reads business events from PostgreSQL. Internal tool for monitoring conversion rates and error patterns. |
Metabase (Docker image) |
PostgreSQL |
One cluster hosting independently migrated logical product databases. The EER database stores accounts, invoice workflow state, widget partners, and analytics; the Clarula database stores firms, DATEV authorization, imported mandates, and confirmations. Distinct application roles are denied connection to the sibling product database. |
PostgreSQL 18 |
Important Interfaces
| Interface | Protocol | Description |
|---|---|---|
Inbound email webhook |
HTTPS POST (multipart/form-data) |
Mailgun forwards incoming emails to |
Browser product API |
HTTPS (JSON and multipart/form-data) |
Product frontends call canonical operations under |
Free website upload API |
HTTPS POST (multipart/form-data) |
Anonymous users upload a PDF via the website’s upload form. |
Invoice diagnosis and correction UI |
HTTPS (static HTML, JSON, and PDF) |
The einfache-eRechnung Nginx container serves scoped Nuxt client shells for token-capability routes. The shells use canonical diagnosis/correction APIs; no login is required because the unguessable token grants scoped access. |
Portal (account management) |
HTTPS (static HTML and JSON) |
The einfache-eRechnung frontend owns auth, portal, and plan documents and uses v1 customer-session, portal-summary, logout, plan-overview, and plan-action operations with host-only HTTP-only cookies. |
Trial-email subscription results |
HTTPS POST (JSON) |
Product pages use canonical unsubscribe and resubscribe result operations. The backend continues to handle RFC 8058 one-click POSTs and legacy links. |
Account registration (DOI) |
HTTPS JSON and browser redirect |
Product form submits to |
Operator API |
HTTPS JSON and provider callback |
|
Level 2: Whitebox backend
The backend is one deployed process composed from four Gradle modules.
:app is the persistence-free composition root, :core provides product-neutral stateless capabilities, and :eer and :clarula provide the two product applications.
The arrows below point from each consumer to an allowed compile-time dependency; their labels also name the corresponding runtime composition.
This boundary follows ADR-30: One BootJar with Product-Isolated Spring Child Contexts.
Motivation
The four-module decomposition makes product ownership enforceable without introducing another process or deployment unit.
:eer and :clarula cannot depend on each other, and :core cannot depend on either product.
Gradle checks enforce the source dependency direction; disjoint Spring scans and sibling contexts enforce bean visibility at runtime.
:core is a library, not a standalone application context.
:app copies an explicit set of its stateless services into a parentless shared-services context, which becomes the parent of both product contexts.
The root and bridge expose no repository, entity manager, Flyway instance, DataSource, or transaction manager.
Each product context owns its persistence infrastructure and exactly one local transactionManager, as required by ADR-31: Separate Logical PostgreSQL Databases and Roles.
Package-by-feature from ADR-6: Package-by-Feature Organization remains the organization within each product module. The Level 3 whiteboxes refine one module at a time so package responsibilities and dependencies remain readable.
Contained Building Blocks (Level 2)
| Module | Runtime Boundary | Responsibility |
|---|---|---|
|
Root |
Owns the sole BootJar, embedded servlet server, product-context lifecycle, servlet mappings, container-level security composition, and aggregate health. It contains no product business logic or persistence. See Level 3: :app. |
|
Product-neutral code loaded into the root and shared-services bridge |
Provides stateless shared defaults and contracts for time, HTTP clients, JWT processing, operator authentication, and MDC cleanup. It contains no product model or persistence dependency. See Level 3: :core. |
|
Eager EER sibling |
Owns einfache-eRechnung conversion, email, identity, correction, widget, EER operator APIs, analytics-event, persistence, migration, asset, scheduler, and test capabilities. It remains the default |
|
Eager Clarula sibling |
Owns the Clarula |
Important Interfaces
| Interface | Direction | Contract |
|---|---|---|
Product context bootstrap |
|
|
Shared-services bridge |
|
|
Servlet ownership |
Container → |
The root operational servlet owns |
Persistence ownership |
Product context → product database |
Each product owns one local |
Level 3: Whitebox :app
:app is the persistence-free composition and operational root at applications/backend/app/.
It owns process startup and the one servlet container but delegates every business request to a product context.
:app Internal StructureMotivation
:app separates process-wide composition from product behavior.
Its component scan is restricted to com.clarula.app, while the runtime package starts each product through an explicit module entry point.
The root disables product web, mail, template, AI, task, and persistence auto-configuration so it cannot become a third business or persistence module.
This realizes the composition boundary defined by ADR-30.
Contained Building Blocks (Level 3)
| Building Block | Responsibility | Code Location |
|---|---|---|
bootstrap |
|
|
runtime |
Creates the parentless shared-services bridge and both eager sibling contexts, forwards application runners after server startup, closes contexts in reverse order, and fails the process if either product fails to start. Registers the root |
|
security |
Composes one container-level security and CORS chain before servlet dispatch. It combines product-neutral operator authentication from |
|
health |
Publishes aggregate product health through the root Actuator context. A product is healthy only when its sibling context is active and its own |
|
Important Interfaces
| Interface | Connected Boundary | Contract |
|---|---|---|
Product module entry points |
|
Runtime registration creates the EER context from |
Shared-services context |
|
The bridge publishes only explicitly selected stateless beans: |
Servlet registry |
Servlet container, product contexts |
Standard longest-path servlet matching routes |
Root security chain |
|
Operator authentication runs before product dispatch. EER contributes customer/API filters and dynamic widget CORS policy; no EER repository becomes visible to Clarula. |
Aggregate health |
Product contexts, |
The root reports context and product-database reachability without owning either product’s |
Level 3: Whitebox :core
:core is the product-neutral, persistence-free library at applications/backend/core/.
It has no application context of its own; :app loads selected configurations and services into the root and shared-services bridge.
:core Internal StructureMotivation
:core contains only capabilities that both products can share without sharing domain or persistence state.
The module remains deliberately small: implementation convenience alone does not justify moving code into it.
Keeping product entities, repositories, migrations, DataSource configuration, and transaction boundaries out of :core prevents the shared module from becoming a coupling point.
These constraints follow ADR-30.
Contained Building Blocks (Level 3)
| Building Block | Responsibility | Code Location |
|---|---|---|
config |
Supplies overridable defaults for process-wide |
|
security |
Binds common JWT policy, creates the HS256 encoder and decoder, issues and validates the short-lived operator JWT, establishes operator authentication for |
|
web |
Clears the complete MDC after every servlet request so pooled threads cannot leak diagnostic context between requests. |
|
Important Interfaces
| Interface | Consumer | Contract |
|---|---|---|
Shared service defaults |
|
|
JWT codec |
|
Encoder and decoder configuration is stateless. Consuming filters enforce their own audience and domain claims. |
Operator authentication |
|
|
Remember-session refresh contract |
|
|
Level 3: Whitebox :eer
:eer is the einfache-eRechnung product module at applications/backend/eer/ and the default / sibling web context.
It owns all EER business capabilities, product assets, schedulers, asynchronous event processing, tests, and persistence.
Four focused structural views keep each diagram within one concern and a bounded number of elements; repeated packages mark interfaces between those concerns, not duplicate implementations.
:eer Invoice Conversion and Correction:eer Identity and Partner Administration:eer Partner Widget:eer Operations and PersistenceMotivation
The decomposition follows business capabilities rather than technical layers, as required by ADR-6: Package-by-Feature Organization.
A developer sees email.ingestion, formats.zugferd, invoice.editing, identity, and widget before framework configuration.
infrastructure remains the only horizontal package.
The sibling context scans only EER-owned packages and depends on the stateless contracts in :core.
It owns one EER DataSource, Flyway history, JPA persistence unit, and local transactionManager; no Clarula bean, entity, repository, or migration participates in EER injection or persistence.
Both siblings share one process environment and classpath, but product-local configuration selects only the owning database credential and resources.
This applies ADR-30 and ADR-31.
Detailed Building Block Responsibilities (Level 3)
| Building Block | Responsibility |
|---|---|
email.ingestion |
Receives inbound emails from Mailgun, validates webhook signatures, parses email payloads, and dispatches sender metadata and PDF attachments to |
invoice.conversion |
EER-owned semantic processing boundary defined by ADR-34. All productive PDF-conversion channels depend on it. The implementation binds the PDF hash and size to combined plain and layout-aware Xberg page evidence, runs six specialized LLM extractors in dependency-aware phases, maps the provider DTO once into |
formats.zugferd.processing |
Orchestrates the email channel around |
formats.zugferd |
EER target edge for ZUGFeRD 2.5 EN16931. It checks target readiness, maps |
email.delivery |
Sends all outbound transactional emails: success notifications with ZUGFeRD attachments, validation errors with edit links, magic-link emails, DOI confirmations, partner affiliation requests, partner affiliation confirmation mails for end customers, trial lifecycle notifications, and contact form forwarding. Provider-agnostic interface with Mailgun (production) and SMTP (development) implementations. See Email Architecture for details. |
invoice.editing |
Manages temporary diagnosis and correction capabilities backed by the original PDF plus a versioned, integrity-checked |
invoice.editing.ui |
Owns diagnosis presentation, source-correction-first editor access, complete correction-form canonicalization, structured validation feedback, submission/delivery state, and corrected result artifacts. JSON/PDF controllers under |
identity |
Manages user accounts, passwordless authentication via magic links, plan lifecycle, trial management, customer session and portal-summary APIs, DOI registration, trial-email subscription results, and the contact form. |
admin |
Founder-only authentication and EER partner JSON APIs under |
conversion |
Persists anonymous and widget conversion result snapshots, claim state, source attribution, and expiration state. Cleanup remains EER-owned, and corrected anonymous results return through the same claim-and-email workflow. |
widget.partner |
Owns the server-side partner registry and affiliation-confirmation flow: persisted partner metadata, active/revoked state, one or more exact allowed origins per partner, redirect base URLs, the affiliation notification email address, the direct confirmation endpoint under |
widget.api / widget.auth |
Server-side HTTP API and identity boundary for the partner widget: |
widget embed |
An independently bundled vanilla-JavaScript Web Component is served from the stable |
business.tracking |
Captures domain events (invoice received, processed, sent, errors, trial started) and routes them to three sinks: database persistence, OpenTelemetry spans, and OpenTelemetry metrics. See Observability for the event architecture. |
EER infrastructure and persistence |
EER-local rate limiting, provider configuration, frontend operational reporting, |
Important Interfaces
| Interface | Protocol | Description |
|---|---|---|
Inbound email webhook |
HTTPS POST (multipart/form-data) |
Mailgun forwards incoming emails to |
Browser product API |
HTTPS (JSON and multipart/form-data) |
Canonical operations use |
Free website upload API |
HTTPS POST (multipart/form-data) |
Anonymous users upload a PDF through |
Frontend operational report API |
HTTPS POST (JSON) |
Static product frontends send anonymous, allow-listed release and failure dimensions to |
Invoice diagnosis and correction API |
HTTPS (JSON and PDF) |
Public token-capability operations under |
Static invoice correction UI |
HTTPS (static HTML) |
The einfache-eRechnung Nginx container serves dedicated Nuxt client shells for exact |
Portal (account management) |
HTTPS (JSON plus static HTML) |
The static product frontend owns |
Trial-email subscription results |
HTTPS POST (JSON) |
|
Account registration (DOI) |
HTTPS JSON and browser redirect |
The product form submits to |
Level 3: Whitebox :clarula
:clarula is the Clarula product module at applications/backend/clarula/ and the sibling web context for /api/operator/v1/clarula/*.
It depends only on :core and owns the complete Clarula firm, DATEV, web, and persistence boundary.
:clarula Internal StructureMotivation
The Firm aggregate anchors Clarula tenancy and all DATEV-owned state.
It is independent of EER’s Partner registry, so a partner lifecycle or identifier cannot change Clarula authorization or mandate ownership.
This applies ADR-32: Clarula Firm as DATEV and Tenant Anchor.
Clarula keeps provider integration behind DatevApi and owns a local persistence boundary.
Its sibling context scans only com.clarula.clarula, has one Hikari DataSource, one Flyway history, one JPA persistence unit, and one local transactionManager.
It has no compile-time or persistence dependency on :eer, as required by ADR-30 and ADR-31.
Contained Building Blocks (Level 3)
| Building Block | Responsibility |
|---|---|
clarula.firm |
Owns the Clarula |
clarula.datev |
Owns firm-scoped DATEV authorization and token lifecycle in |
clarula.datev.mandates |
Imports minimized read-only mandate snapshots through a verified firm connection, evaluates routing completeness, retains simulated targets, and stores Clarula-owned confirmations separately from DATEV source data. |
Clarula operator API support |
Owns Clarula JSON/problem contracts, product-local sensitive-value logging rules, component discovery, and web configuration. Clarula accepts the shared operator JWT and root CSRF boundary but owns no operator or customer identity entity; the static operator app owns presentation. |
Clarula persistence |
The Clarula sibling owns one Hikari |
Important Interfaces
| Interface | Protocol | Description |
|---|---|---|
Clarula firm and DATEV operator API |
JSON, |
The Clarula sibling exclusively owns |
DATEV port |
Kotlin interface and HTTPS |
|
Clarula database |
JDBC |
The Clarula role can connect only to the Clarula logical database. Migrations, entities, repositories, and transactions remain local to this sibling context. |
Runtime View
This chapter describes how backend product building blocks interact at runtime. Eight scenarios cover the architecturally significant flows: the email-based conversion pipeline, the shared invoice extraction algorithm, validation failure with diagnosis-first recovery, the anonymous free website upload, magic link authentication, account registration with double opt-in, the partner widget state machine, and the Clarula Firm-scoped DATEV mandate import.
The conversion scenarios treat Invoice Extraction Algorithm as a shared sub-flow for PDF interpretation and fail-closed semantic extraction. They also share an outbound provider rate-limiting layer in infrastructure.ratelimit. Before calling external APIs such as Mistral chat or Mailgun, the application enters an in-memory queue when the configured provider budget is exhausted. When a provider still answers with HTTP 429, the client honors Retry-After or provider-specific reset headers and performs exactly one retry before surfacing the failure.
Email-Based Invoice Conversion (Happy Path)
Scope: Core business flow — a registered user sends a PDF invoice by email and receives a ZUGFeRD e-invoice in return.
Trigger: Mailgun delivers an inbound email webhook to POST /webhooks/inbound-email.
Participants: EmailWebhookController, EmailProcessingServiceImpl, PdfToZugferdConversionService, InvoiceEditService, ResponseMailService, Mailgun, PostgreSQL
This scenario treats Invoice Extraction Algorithm as a shared black box so the end-to-end email flow stays at one abstraction level.
| Step | Description |
|---|---|
Signature check |
|
Async handoff |
The controller returns |
Invoice extraction |
The shared Invoice Extraction Algorithm validates the PDF, builds immutable evidence, performs six specialized semantic Mistral calls, maps their result into an evidence-bound draft, and fails closed when the document is unsupported or ambiguous. Supported native-text PDFs stay on this path; scanned or image-only PDFs remain out of scope per ADR-17. |
Deterministic conversion tail |
After extraction, |
Outbound rate limiting |
The simplified diagram hides |
Edit session |
Every processed invoice creates an |
Response delivery |
|
Invoice Extraction Algorithm
Scope: EER sub-flow that turns a supported native-text PDF into immutable evidence, one semantic application invoice draft, and a target-specific result. Reflects ADR-18 and ADR-34.
Trigger: PdfToZugferdConversionService.convert(pdfBytes, trackingContext) reaches semantic analysis after the caller has accepted an upload or inbound email.
Participants: PdfToZugferdConversionService, PdfInvoiceAnalysisService, XbergPdfStructuredTextExtractor, ParallelInvoiceExtractor, DocumentHeaderExtractor, PartiesExtractor, LineItemsExtractor, TotalsAndTaxExtractor, PaymentTermsExtractor, NotesExtractor, ExtractedInvoiceObservationMapper, common invoice validation, ZUGFeRD EN16931 adapter, Mistral AI
This scenario is the shared black box used by Email-Based Invoice Conversion (Happy Path) and Free Website Upload. Static responsibilities and design rationale live in LLM Integration.
| Step | Description |
|---|---|
Evidence extraction |
|
Six specialized LLM calls in three stages |
|
Semantic extraction contract |
Line items return one VAT-exclusive |
Evidence-bound mapping |
|
Deterministic boundary |
Kotlin maps, fingerprints, checks arithmetic, and validates targets. It does not decide table meaning, derive an extraction-time unit price from line amount and quantity, or replace contradictory totals. |
Common validation and analysis gates |
|
Typed non-success outcomes |
Semantic emptiness or an explicit extractor marker yields |
ZUGFeRD edge adapter |
Only a commonly validated invoice reaches the target adapter. The adapter applies ZUGFeRD EN16931 readiness, renders CII XML with Mustang, compares rendered values with the semantic invoice model, runs XSD/Schematron validation, and returns explicit |
Delivery and correction handoff |
Successful target output is embedded into the unchanged source PDF. Common or target readiness findings persist the |
Validation Failure with Diagnosis-First Recovery
Scope: PDF analysis succeeds, but common invoice validation or ZUGFeRD EN16931 readiness fails. The user reviews diagnosis guidance, corrects the source invoice by default, and enters the editor only for a confirmed recognition or mapping error.
Trigger: PdfToZugferdConversionService returns a draft-backed ValidationFailure.
Participants: EmailProcessingServiceImpl, PdfToZugferdConversionService, InvoiceEditService, InvoiceEditSessionDraftStore, ResponseMailService, static product frontend, DiagnosisApiController, CorrectionApiController, CorrectionFormWorkflow, CorrectionSubmissionService, ZUGFeRD EN16931 adapter, Mailgun
Every diagnosis and correction session uses the draft state defined by ADR-34. The cutover deletes pre-model edit sessions instead of reconstructing source evidence from generated XML.
| Step | Description |
|---|---|
Diagnosis-first recovery |
Validation-failure responses route through |
Error humanization |
EN16931 and internal findings map to plain German presentation through |
Draft-only correction state |
Every email, website, and widget correction capability persists an application draft. The deployment migration deletes existing edit sessions and removes the XML state column because generated XML cannot be converted into trustworthy source evidence. |
Draft snapshot integrity and compatibility |
The draft lane stores the schema version relationally beside fachliche revision, serialized snapshot, and an integrity hash bound to session identity and source PDF. Compatibility preparation occurs before the request transaction and before JSON binding. A lower-version active snapshot at revision zero is reconstructed from the source PDF without an active database transaction or bound connection. A short compare-and-swap write replaces it only while entity version, old schema, revision zero, snapshot hash, incomplete lifecycle, and original 30-day deadline still match. Corrected, completed, corrupt, future-version, expired, and concurrently changed snapshots are never silently overwritten. |
Lossless form handoff |
Draft forms preserve nullable values instead of filling absent facts with zero or one. Repeated editable structures carry opaque draft element IDs; existing IDs remain stable, while explicit additions receive new IDs. Removal and additions become evidence-bound revision events. |
Recognition provenance |
A user revision records every changed semantic field, corrected-value fingerprint, timestamp, revision, and document evidence. Original extraction claims remain immutable but are superseded when computing effective ambiguity. Every user change is one evidence-bound semantic recognition correction; no parallel canonical or source-observation value is created. |
Re-validation loop |
Every preview rebuilds common validation and immutable source-analysis findings from the revised draft before invoking target readiness. The ZUGFeRD adapter compares rendered XML with the effective semantic draft values. Invalid submissions return the same form plus structured errors as a |
Durable completion |
A changed draft is persisted and optimistic locking is checked before widget or email side effects. An unchanged but valid draft can complete without inventing a revision. Completion atomically records |
Result handoff |
Email sessions send the standard success mail, widget sessions replace the anonymous conversion and become claimable only after their status changes to |
Free Website Upload
Scope: An anonymous user uploads a PDF on the website and receives either a direct-download ZUGFeRD result or a diagnosis-first retry path — without creating an account.
Trigger: POST /api/v1/free-upload/convert with a PDF file upload.
Participants: Caddy, FreeWebsiteUploadController, PdfToZugferdConversionService
This scenario treats Invoice Extraction Algorithm as a shared black box so the website flow stays focused on user-visible behavior.
| Step | Description |
|---|---|
Rate limiting |
Caddy enforces 5 requests/hour per client IP on |
Shared conversion pipeline |
|
Provider retry |
When Mistral responds with HTTP |
No account persistence |
Free website uploads create no account-bound conversion records. Direct-download edit sessions and shared invoice processing events are still created so the anonymous result can be restored and monitored. |
Validation failure recovery |
Validation failures no longer keep users inside a website-local edit-first warning state. The product frontend receives the existing |
Timeout |
The controller wraps the conversion in a 60-second timeout (virtual thread executor). If the LLM takes too long, the user receives |
Consent required |
The endpoint requires an explicit |
Response privacy |
Canonical conversion and restoration responses set |
Partner Widget State Machine
Scope: The embeddable partner widget manages anonymous upload, correction, partner-scoped identity reuse, and result delivery by email as a client-side state machine.
Trigger: Browser events, widget API responses, or widget-specific URL parameters on the partner page.
Participants: widget.embed, widget.api, invoice.editing.ui, PostgreSQL, email.delivery
The widget reducer is the single source of truth for visible screens. Events from the browser, /api/widget/* responses, and URL parameters are translated into semantic actions before they reach the reducer. The reducer never receives instructions such as "show result screen" directly.
[ idle ]
|
| SUBMIT_FILE
v
[ uploading ]
|
| UPLOAD_SUCCESS
v
[ result-ready ]
|
| email CTA
|
+-- no stored token --> REQUEST_AUTH_PROMPT --> [ auth-required ]
| |
| | REQUEST_AUTH(email)
| v
| [ auth-pending ]
|
+-- stored token ----> REQUEST_SESSION_CLAIM --> [ session-claim-pending ]
| |
AUTH_SUCCESS | | SESSION_INVALID
v v
[ auth-pending ] [ auth-required ]
[ auth-pending ] -- USE_OTHER_EMAIL --> clear partner token --> [ auth-required ]
[ uploading ] | | UPLOAD_VALIDATION_FAILURE v [ validation-issue ] -- open diagnosis/correction --> product frontend The result is not claimable and the widget cannot enter authentication until correction changes the persisted conversion status to SUCCESS.
Correction return: Partner page URL: ?widget_event=correction_saved&claim_token=... [ idle ] | | CORRECTION_SAVED_FROM_URL v [ restoring-result ] | | GET /api/widget/result | +-- RESULT_RESTORED ---------> [ result-ready ] | +-- RESULT_RESTORE_EXPIRED --> [ expired-result ] | +-- RESULT_RESTORE_ERROR ----> [ temporary-failure ]
Upload/API failures: [ uploading ] -------- UPLOAD_RATE_LIMITED --> [ rate-limited ] [ uploading ] -------- UPLOAD_ERROR --------> [ temporary-failure ] [ auth-pending ] ---------- AUTH_EXPIRED -----> [ expired-result ] [ session-claim-pending ] - AUTH_EXPIRED -----> [ expired-result ] [ session-claim-pending ] - AUTH_RATE_LIMITED -> [ rate-limited ] [ session-claim-pending ] - AUTH_ERROR --------> [ temporary-failure ] Global recovery: [ rate-limited ] ------ RETRY -----> [ idle ] [ temporary-failure ] - RETRY -----> [ idle ] [ result-ready ] ------ RESTART ---> [ idle ] [ validation-issue ] -- RESTART ---> [ idle ] [ expired-result ] ---- RESTART ---> [ idle ]
| State | Meaning |
|---|---|
|
The widget is ready for a new PDF and has no active conversion context. |
|
The widget has submitted a PDF to |
|
A valid ZUGFeRD result exists for the current anonymous claim token. The user sees the invoice summary and can request email delivery. The widget first checks whether a token exists for the current API origin and partner. |
|
Conversion produced a validation failure with diagnosis guidance and an edit token. The result cannot be claimed or delivered. The user can open the diagnosis/correction capability without logging in; successful correction changes the conversion to |
|
The partner page was opened after a successful correction. The widget uses the claim token to fetch the current result snapshot before showing any result screen. |
|
No usable partner-scoped identity exists. The widget asks for an email address so the anonymous conversion can be claimed and delivered. Definitive session rejection clears only the current partner’s storage key before entering this state. |
|
The widget calls |
|
The delivery request has been accepted. Existing accounts and valid sessions receive the standard success email immediately. New users receive DOI first; confirmation activates their provisional widget identity and then sends the result email. Whenever a stored identity exists, every widget state displays its fixed recipient as “Versand an …”. In this already-delivered state the action says “nicht mehr merken” and clears the future identity without reopening recipient entry for the claimed conversion. |
|
The claim token expired. The user must start a new upload. |
|
A widget API endpoint rejected the request with |
|
A transient network or server error occurred. The user can retry from a clean |
For a result that was already claimed, the same correction submission path compares the conversion owner with the account attached to the edit session. Only an exact owner match may replace the artifacts; the corrected result is then sent directly to that account by the standard success email.
| Step | Description |
|---|---|
Event-only URLs |
Widget URL parameters encode flow events only. |
Snapshot restore |
After a correction redirect, the widget calls |
Origin-partner binding |
A preflight may accept an origin mapped to any active partner because it has no partner header. Every actual browser request with |
Same result screen |
A corrected invoice returns to |
Conversion-token safety |
|
Identity-token safety |
The browser stores one opaque token per API origin and partner ID. The server stores only its SHA-256 hash and requires the request, session, and conversion partners to match. The session grants only delivery to its fixed account email; it grants no portal, admin, correction, or download access. |
Storage fallback |
Missing or unavailable |
Remembered-recipient visibility |
On startup, a stored token is resolved through read-only |
Email split |
|
DATEV Connection and Mandate Import
Scope: A Clarula administrator creates or selects one active Clarula Firm, connects it to DATEV, imports a minimized mandate snapshot, and confirms selected mandates.
Trigger: Authenticated admin operations under /admin/clarula/firms/{firmId}/datev, dispatched exclusively to the Clarula sibling context.
Participants: root operator security filter, Clarula sibling DispatcherServlet, firm, datev, DATEV Online APIs, Clarula PostgreSQL database
The HTTP adapter fails the complete import when the firm is ambiguous, differs from the stored connection, or a required provider record is malformed.
A same-source import preserves confirmations; a firm change requires an explicit replacement flow that is outside this milestone. Adapter-mode changes happen only after the complete Clarula database has been recreated while the backend is stopped.
The mock follows the same port, child-servlet route, and Clarula-local persistence path with deterministic data.
No EER Partner, table, repository, identifier, foreign key, or transaction participates in this flow.
The root operator filter authenticates /api/operator/v1/clarula/** before servlet dispatch; the child receives only the resulting Authentication and the three explicitly bridged stateless beans (Clock, ObjectMapper, and RestClient).
Magic Link Authentication
Scope: A registered user authenticates via a passwordless magic link to access the portal.
Trigger: POST /api/v1/auth/magic-link with the user’s email address.
Participants: MagicLinkController, MagicLinkService, AccountRepository, LoginTokenRepository, EmailSender, AuthTokenService, CustomerRememberSessionService, Mailgun
| Step | Description |
|---|---|
Enumeration prevention |
Whether the account exists or not, the controller returns the same |
Token invalidation |
When a user requests a new magic link, all pending (unused) tokens for that account are invalidated. Only the latest link works. |
Single-use tokens |
Each token can be used exactly once. After verification, |
Secure token generation |
|
JWT cookie |
On successful verification, |
Remember session |
On successful verification, |
Response privacy |
Token redirects and account API responses use |
Account Registration with Double Opt-In
Scope: A visitor registers on the website and, after confirming the double-opt-in email, becomes a registered user with a 30-day free trial. Business processing runs in the backend and Mailgun delivers the email; the static product frontend owns the registration and result pages. No external DOI service is involved.
Trigger: Visitor submits the registration form on the website.
Participants: static Nuxt product frontend, Caddy, RegisterController, RegistrationRequestService, ConfirmController, RegistrationService, AccountRepository, EmailSender, Mailgun
| Step | Description |
|---|---|
Email check |
|
Cooldown |
If a pending registration for the same email was created less than 2 minutes ago, the service returns success without resending. This prevents abuse while remaining user-friendly. |
Token generation |
Each pending registration gets a 32-byte secure random confirmation token. New emails link to the configured product origin at |
DOI confirmation email |
Sent via Mailgun using a self-hosted Thymeleaf template ( |
Token validation |
|
Idempotent account creation |
|
Initial plan assignment |
Website signups and partner-widget signups both start on |
Cleanup |
A scheduled task ( |
Response privacy |
Confirmation redirects set |
Transaction boundary |
The |
Deployment View
The hosted Clarula platform uses one Terraform-managed Hetzner VPS per environment. Docker Compose orchestrates the backend, idempotent product-database provisioning, one PostgreSQL cluster with isolated EER and Clarula logical databases, three independently versioned static Nuxt/Nginx frontend containers, and operational containers. Caddy terminates TLS, routes shared backend and security paths, and proxies remaining product traffic to the matching frontend container. There is no Kubernetes, Node/Nuxt runtime server, or container orchestrator beyond Compose.
This chapter describes production, the permanent dev environment, component-selective dev delivery, certified composite runtime snapshots, build-free full-runtime production promotion, infrastructure provisioning, the local Nuxt/backend development setup, and credentialed DATEV sandbox validation.
Production Environment
Production runs on one Terraform-managed Hetzner VPS (Debian 13, Nuremberg). Docker Compose runs the shared backend, one PostgreSQL cluster with separate EER and Clarula logical databases, Caddy, two static product frontend containers, one static operator frontend container, two partner-demo containers, and Metabase. Caddy terminates TLS, owns shared backend/security routing, and proxies remaining product traffic to product-owned unprivileged Nginx containers. There is no Nuxt, Node.js, or Hugo process in production.
Deployment Units
| Unit | Version | Runtime role |
|---|---|---|
backend |
|
Spring Boot modular monolith. Persistence-free |
Caddy |
The shared |
Custom Caddy plus |
einfache-eRechnung frontend |
The shared |
Generated Nuxt |
Clarula frontend |
The shared |
Separately generated files plus product-owned unprivileged Nginx configuration. |
operator frontend |
The shared |
Generated operator workspace files plus operator-owned unprivileged Nginx configuration. It has no backend proxy capability and shares only the internal |
partner-demo |
The shared |
Static third-party integration demonstration in its own Nginx container. |
clarula-partner-demo |
The shared |
Static Clarula partner demonstration in its own Nginx container. |
PostgreSQL |
|
EER ( |
product-db-init |
|
Idempotent one-shot database/role provisioning. Backend startup depends on successful completion; it is not a long-running application service. |
Metabase |
|
Internal analytics dashboard. |
Each component has an independent image version.
All three frontend containers connect only to an internal edge network shared with Caddy; they cannot reach PostgreSQL or publish host ports.
Each rollout step recreates one Compose service with --no-deps; a production operation sequences all image services and applies Caddy last.
Replacing a frontend can briefly interrupt that product; Caddy, the backend, and the other frontend keep running during that step.
Public Routing and Ownership
| Origin/path | Owner | Behavior |
|---|---|---|
|
einfache-eRechnung Nginx container |
Pre-rendered HTML and static assets. Generated route directories are resolved to |
|
einfache-eRechnung Nginx container |
HTML |
Fixed einfache-eRechnung auth, portal, error, email-result, and |
einfache-eRechnung Nginx container |
Only the explicitly generated documents are served. There is no family-wide fallback. |
|
Clarula Nginx container |
Only route-manifest files are served. Clarula has no invented auth or token fallback. |
Both product origins |
backend |
Same-origin browser API. Caddy preserves Spring CORS, cookies, status, and response headers. |
Both product origins |
backend |
Temporary compatibility aliases during the deployed-client migration window. |
|
backend EER or Clarula sibling according to the explicit namespace |
The EER sibling owns authentication and partner operations; the Clarula sibling owns firm, DATEV, and mandate operations, including |
Remaining declared |
operator Nginx container |
Fixed documents and bounded UUID/result shells are served with no-store/noindex security policy; content-addressed assets are immutable. Caddy removes |
Every product origin |
Caddy rejection |
Returns |
Embeddable |
backend EER context, except root-owned |
EER’s default dispatcher owns established backend routes; the persistence-free root operational dispatcher owns actuator routes. Other backend-owned exceptions remain available according to the host route manifest. The generated customer confirmation document |
Unknown content, marketing, asset, or application paths |
Product Nginx container |
Real HTTP |
|
backend |
Dedicated backend and Mailgun webhook origin. |
|
temporary widget compatibility |
Existing partner embeds may load |
The former app origin remains only because partner sites have already installed its versioned widget URL. It is not a first-party application origin and can be removed after those partners migrate. Canonical APIs, compatibility APIs, and RFC 8058 subscription posts use the product or api.* origins.
Static Cache Policy
-
Content-addressed
/_nuxt/**assets usepublic, max-age=31536000, immutable; the mutable/_nuxt/builds/latest.jsonpointer usesno-store. -
/.well-known/frontend-release.jsonusesno-storeandno-referrerbecause it is a release pointer. -
Diagnosis, correction, fixed private application documents, and operator documents use
no-store,no-referrer, andnoindex, nofollow; the entire operator host is non-indexable. -
Unknown files are never replaced by a global application shell.
Certified Runtime Promotion
Production promotion is a manual full-runtime workflow operation.
After every successful Dev smoke suite, the Dev workflow publishes one certified snapshot containing the seven running OCI digests and their individual source SHAs.
The default production dispatch freezes runtime-snapshot:dev and promotes all seven exact digests without rebuilding any runtime component.
The production guard resolves the selected snapshot to one immutable digest before mutation, so a later Dev deployment cannot change the promotion source.
After production approval, the workflow validates that the snapshot’s immutable cohort tags still resolve to the recorded digests.
The target pulls each component by its recorded digest, uses the cohort ID as a local rollout reference, and deploys all image components with Caddy last.
It then verifies health, public routing, and each frontend’s recorded source release.
After smoke tests pass, all seven packages and the runtime snapshot receive the same immutable prod-<UTC timestamp>-<run-id> release tag; package-local prod tags then move to those digests.
A failed check stops the workflow without automated restoration.
Operational recovery uses the same promotion path with a corrective certified Dev runtime.
See deployment/FRONTEND_RELEASES.md for the operational sequence.
Edge Rate Limiting
All public product and API origins import the same named per-IP zones. The temporary widget origin imports those zones as well but exposes only widget bundle and API paths. Canonical and compatibility paths appear in one matcher per budget, so switching product host or API alias does not reset the budget.
| Operations (canonical + alias) | Budget | Reason |
|---|---|---|
Contact |
5/hour |
Unauthenticated outbound-email spam protection. |
Free upload |
5/hour |
Protect document interpretation, extraction, and conversion resources. |
Customer magic link and registration |
20/5 minutes each |
Bound public email-triggering operations while tolerating shared-office bursts. |
Operator magic-link submission on exact |
20/5 minutes |
Enumeration-safe email issuance has a dedicated operator-origin bucket that cannot be multiplied through product origins. |
Widget upload |
30/5 minutes |
Permit short partner-office bursts while bounding sustained conversion abuse. |
Widget auth, claim-with-session, and session-identity |
30/5 minutes in one shared bucket |
First-time email requests and remembered-identity deliveries can occur in short bursts. Sharing the budget across canonical and compatibility paths prevents callers from multiplying the allowance by switching operations, aliases, or public hosts. |
Widget result/status reads |
300/5 minutes |
Permit restore and polling traffic. |
Diagnosis editor entry and correction preview/submission |
30/5 minutes each |
Bound capability mutations and PDF regeneration. |
Frontend operational diagnostics |
120/5 minutes |
Prevent abuse of the anonymous, privacy-minimized diagnostic endpoint. |
Caddy returns a no-store RFC 9457 429 rate_limit_exceeded problem before Spring Boot when a budget is exceeded.
Application-level outbound provider limits remain separate.
Product Database Provisioning
Every full environment model contains an idempotent product-db-init one-shot service and executes the single repository-managed deployment/scripts/product-database-init.sh implementation.
Hosted workflows install that script at /opt/apps/invoicex/scripts/product-database-init.sh before provisioning.
Deployment starts PostgreSQL, runs this service to completion, and only then starts or replaces the backend.
The job uses the operational invoicex superuser solely to:
-
create or rotate the non-superuser
eerandclarulalogin roles; -
create the
clarulalogical database and assign product database ownership; -
transfer historical EER relations, routines, enum/domain types, and schemas from the former superuser owner to
eerso future Flyway migrations can alter them; -
revoke
PUBLICconnection access; and -
grant
eeraccess only toinvoicexandclarulaaccess only toclarula.
The runtime environment renders DATABASE_USER=eer with its dedicated EER_DB_PASSWORD, plus a separate CLARULA_DB_URL, CLARULA_DB_USER=clarula, and CLARULA_DB_PASSWORD. Rendering rejects any equal pair among the EER, Clarula, and operational passwords.
Compose passes only an explicit application-variable allowlist to the backend, so the process receives both product credentials but not POSTGRES_PASSWORD or the redundant bootstrap password names. Neither child persistence configuration receives the sibling credential or DataSource.
deployment/tests/product-database-isolation-test.sh executes the provisioning model and proves both denied sibling connections.
Persistence and External Services
Only PostgreSQL and Caddy ACME/config state are persistent:
-
/mnt/data/postgres→/var/lib/postgresql -
/mnt/data/caddy-data→/data -
/mnt/data/caddy-config→/config
Frontend images are reproducible artifacts in GHCR and are not customer-data backups.
The host stores no frontend files or content bind mounts.
Host replacement restores PostgreSQL/Caddy state and pulls the component images recorded in runtime configuration and the backup manifest.
Production full-state archives contain independently identifiable postgres/invoicex.dump, postgres/clarula.dump, and postgres/metabase.dump files.
The EER and Clarula custom-format dumps can be inspected and restored independently; restore commands create each product database with its own owner and never restore one product into the sibling database.
Mailgun and Mistral are EU endpoints; New Relic receives OTLP telemetry; GHCR stores all deployable image components.
Secrets are rendered from the environment-scoped 1Password vault into a mode-0600 .env file during deployment; each long-running service receives only its explicit Compose environment subset. The production workflow additionally requires the GitHub Environment’s INVOICEX_ADMIN_EMAILS, fixes OPERATOR_ORIGIN to exactly https://admin.clarula.de, and derives the DATEV callback from that validated origin. Compose passes both values explicitly to the backend without printing the allow-list. OpenTelemetry sanitization redacts signed-URL fields and OAuth/OIDC query names including code, state, token, error, error_description, error_uri, nonce, and code_challenge before export.
Dev Environment
Dev is the permanent proving environment for every qualifying merge to main.
It uses the same static frontend containers, shared Caddy edge routing, Spring production profile, PostgreSQL, demos, and Metabase topology as production on a separate Terraform-managed Hetzner host.
Origins
| Public origin | Purpose |
|---|---|
|
Canonical einfache-eRechnung frontend container plus same-origin |
|
Canonical Clarula frontend container plus same-origin |
|
Dedicated static operator frontend. Only explicit |
|
Temporary compatibility for already-installed |
|
Dedicated backend and Mailgun webhook origin; |
|
Standard partner widget demonstration. |
|
Clarula partner demonstration. |
|
Internal Metabase access. |
Every dev host sends X-Robots-Tag: noindex, nofollow.
Product route ownership, scoped shells, backend exceptions, cache policy, bounded legacy-widget compatibility, and rate-limit zones match production.
Provisioning and Replaceability
deployment/environment/dev/ provisions the server, firewall, attached volume, and DNS through Terraform.
cloud-init.yaml installs Docker and Python, creates the deploy user, prepares /opt/apps/invoicex, and mounts /mnt/data.
The host is replaced from code rather than patched manually.
Stateful data survives replacement on the attached volume:
-
/mnt/data/postgres -
/mnt/data/caddy-data -
/mnt/data/caddy-config
Frontend images are immutable, reproducible delivery artifacts in GHCR. The host stores no frontend files; a replacement host pulls the selected image tags through the component workflow.
Component Delivery
The dev workflow selects components by changed path.
An app-local change packages and activates only that frontend; shared frontend package, generation-script, or lockfile changes select all three.
Backend, Caddy, and both partner demos use independent dev-<full-sha> image tags.
A frontend deployment recreates only that product’s Nginx container; it does not restart the other frontend, Caddy, or the backend. A brief product-specific interruption is accepted.
The workflow fetches secrets from the invoicex_dev 1Password vault, requires the environment-owned operator allow-list, renders the exact https://admin.dev.clarula.de operator origin and its derived DATEV callback into the mode-0600 .env, preserves unselected component versions, copies the complete environment-owned Compose model and scripts, applies only selected components, and runs production-like route and exact-release smoke checks. The smoke suite POSTs the enumeration-safe operator magic-link endpoint with the exact operator Origin and a reserved address that rendering forbids from the allow-list, so no provider email is sent.
After success, it records the seven running immutable tags and OCI digests in one runtime-snapshot:dev-run-<run-id>-<attempt> image.
All seven components receive the snapshot’s immutable dev-run-<run-id>-<attempt> cohort tag, and package-local dev tags point to the corresponding certified digests; runtime-snapshot:dev moves last and identifies the authoritative certified combination.
A failed deployment or smoke test does not replace that pointer.
A manual all deployment bootstraps a replacement host.
Dev is isolated from production by VPS, DNS, GitHub Environment, database, Caddy state, component versions, and telemetry environment name.
Caddy uses the same promoted image digest in both environments, but Dev Compose selects the image’s validated /etc/caddy/Caddyfile.dev configuration while production selects Caddyfile.prod.
Mailgun account/domain remain shared, with postfach-dev@einfache-erechnung.de routed only to dev. OpenTelemetry URL sanitization redacts OAuth/OIDC query names including code, state, token, error, error_description, error_uri, nonce, and code_challenge before export.
Because main auto-deploys, dev is not a stable long-lived QA snapshot.
CI/CD Pipeline
GitHub Actions keeps required checks always reporting while selecting expensive work by path.
ADR-14 remains unchanged: main automatically delivers affected components to dev; production requires an explicit full-runtime promotion.
Pull-request Gates
| Gate work | Selection | Contract |
|---|---|---|
Backend |
|
Formatting, unit, integration, Docker build, and native-runtime checks. |
Product API contract |
Backend main/API contract or generated client changes |
|
einfache-eRechnung frontend |
Product app or shared frontend inputs |
Workspace install, generated-client check, product type-check/tests, static generation, static-output inspection, and hardened Nginx image contract. |
Clarula frontend |
Product app or shared frontend inputs |
Same independent contract; no other frontend build is required for an app-local change. |
Operator API contracts |
Backend or any of the three generated operator clients |
Explicit drift and additive-compatibility tasks for operator auth, EER operator, and Clarula operator OpenAPI documents, followed by explicit generated-client drift checks and tests for all three TypeScript clients. The result participates in the always-reporting |
Operator frontend |
Operator app, its three generated operator clients, or shared frontend inputs |
Type-check, tests, static generation, declared-route inspection, and hardened operator Nginx image contract including noindex/no-store policy, scoped dynamic shells, and real |
Deployment contracts |
Caddy, Compose, scripts, or workflows |
Shellcheck, isolated image-component deployment, runtime-snapshot validation and cohort tagging, frontend image hardening, secret-safe environment rendering, all Compose models, hosted and local Caddy configurations, plus Terraform 1.15.0 |
Product and backend visual |
Backend pages/widgets, either product frontend, demos, Caddy, or visual harness |
One Docker preparation job builds the backend JAR, both static frontends, the shared backend runtime base, and custom Caddy image. Concurrent desktop, tablet, and mobile screenshot jobs consume those artifacts in isolated Compose projects; a fourth isolated job checks non-screenshot frontend contracts. |
Full-stack E2E |
Backend, either frontend, retained compatibility sites, demos, Caddy, scripts, or E2E harness |
Docker-generated Nuxt/Nginx images behind shared Caddy, backend routes, APIs, scoped shells, bounded legacy-widget compatibility, backend exceptions, 404s, immutable cache headers, and shared edge-rate budgets. |
The final Build & Test, Visual Regression Tests, and Full-stack E2E jobs always run and own the required status contexts.
Skipped selective jobs count as success; failed or cancelled selected work fails the gate.
Dev Build and Delivery
The dev workflow’s path selector treats these as independent components:
-
backend (plus builder/runtime bases only when their inputs change),
-
einfache-eRechnung frontend,
-
Clarula frontend,
-
operator frontend,
-
Caddy,
-
einfache-eRechnung partner demo, and
-
Clarula partner demo.
A shared frontend package, generation script, API client, workspace manifest, or lockfile selects all three frontend builds.
Each frontend image contains only .output/public/, exact release metadata, and its product-owned unprivileged Nginx configuration.
CI verifies non-root/read-only execution, scoped shells, privacy and cache headers, real 404 behavior, and absence of Node.js.
The release ID fixes Nuxt’s build ID and identifies the source bytes inside the image.
All deployable components use immutable dev-<full-sha> tags in separate .env variables. Build jobs reuse an existing full-SHA tag rather than overwriting it.
The deploy job preserves every unselected version and updates only selected services.
Caddy packages both validated environment configurations; each Compose model selects its own configuration from the same image digest.
After the complete Dev smoke suite passes, the workflow resolves all seven running OCI digests and publishes runtime-snapshot:dev-run-<run-id>-<attempt>.
It adds the same immutable dev-run-<run-id>-<attempt> cohort tag to every component digest, moves each package-local dev tag, and moves runtime-snapshot:dev last.
That single snapshot pointer is the authoritative production input; a failed deployment, smoke test, or certification cannot replace it.
Manual Production Promotion
The production workflow is workflow_dispatch only and is additionally constrained by the prod GitHub Environment and a workflow guard to main.
There is no component selector: every dispatch promotes the complete certified runtime.
A blank input resolves runtime-snapshot:dev; an operator can select a retained immutable dev-run-<run-id>-<attempt> snapshot.
The production guard resolves the selected snapshot to one immutable digest before mutation, so later Dev delivery cannot change the promotion source. The workflow validates the successful Dev certification and main-reachable deployment-control revision before approval, then revalidates the immutable cohort tags after approval. It builds no runtime image and creates no intermediate candidate aliases. The target pulls each component by its recorded digest and deploys backend, all three frontends, both demos, and Caddy in that order. The image deploy script verifies each component, then public smoke tests verify the complete route contract and all three recorded frontend source releases.
After smoke tests pass, every component package and the runtime-snapshot package receive the same immutable prod-<UTC timestamp>-<run-id> tag.
The production host records that shared tag and package-local prod pointers move to the active digests.
A failed check stops the workflow; operators recover through a new promotion of a corrective certified Dev runtime.
Detailed commands and retention rules are in deployment/FRONTEND_RELEASES.md.
Engineering Documentation Publication
The engineering-pages.yml workflow publishes a static engineering observatory to the gh-pages branch.
Changes under docs/architecture/ or docs/pages/ render the complete arc42 document with its diagrams and update the landing page automatically from main without running the paid benchmark.
A manual dispatch additionally runs the real LLM benchmark with the development Mistral credential and retains all historical benchmark runs.
The landing page is served at the GitHub Pages root, architecture documentation at /architecture/, and benchmark reports at /llm-benchmark/.
The workflow migrates the former root-level benchmark site into that namespaced path on its first publication.
Cleanup
-
GHCR: ten immutable
dev-<SHA>source builds, ten certifieddev-run-cohorts, and ten timestamped successfulprod-20release tags per component and runtime-snapshot package. Package-local movingdevandprodtags protect active manifests. -
Host Docker images: unused images older than 72 hours after verified deployments.
-
Volumes and customer data are never part of image or release cleanup.
Infrastructure Provisioning
Dev and production infrastructure use environment-local Terraform modules and cloud-init as their source of truth.
Both environments follow the same replaceable-host pattern: Terraform provisions the Hetzner server, firewall, persistent volume, and DNS records; cloud-init installs Docker and Python, creates the deploy user, prepares /opt/apps/invoicex, and mounts /mnt/data.
| Component | Current State | Location |
|---|---|---|
Dev server, firewall, persistent volume, DNS records |
Terraform ( |
|
Dev host bootstrap |
|
|
Production server, firewall, persistent volume, DNS records |
Terraform ( |
|
Production host bootstrap |
|
|
Terraform state for Hetzner-managed infrastructure |
Dev and production use shared remote backends on Hetzner Object Storage with bucket versioning and lockfile-based locking under ADR-15. |
|
Local operator secret resolution |
Local Terraform and |
|
Terraform static validation |
Pull-request CI pins Terraform 1.15.0, checks recursive formatting, combines each partial backend block with its committed non-secret |
|
Mailgun account, domain, and routes |
Mailgun is managed manually. Dev and production share one account and domain; routes direct |
No current Terraform root |
The dev and production servers are treated as replaceable infrastructure. Terraform plus cloud-init rebuild each host, while the attached Hetzner volume preserves the only state that must survive rebuilds: PostgreSQL data and Caddy’s ACME/TLS state. Static frontend images are reproducible immutable GHCR artifacts, not Terraform resources or persistent customer data; component workflows pull the selected image tags after replacement.
Each infrastructure directory keeps its own environment-local variables, wrapper scripts, and non-secret overrides. Under ADR-15, shared Terraform state is not part of that self-containment boundary anymore: Hetzner-managed infrastructure moves to a shared remote backend instead of one module-local state file per operator. There is no shared Terraform module spanning dev and prod, which limits blast radius inside the shared Hetzner project.
Local Development Environment
local-development/docker-compose.yml runs the two Nuxt product applications and the dedicated Nuxt operator application against one shared Spring Boot backend.
Caddy keeps API and backend-exception routing ahead of each frontend and deliberately disables edge throttling locally. Product APIs remain on the product origins; operator APIs use the separate local operator origin.
Local Origins
| URL | Owner |
|---|---|
einfache-eRechnung Nuxt app; canonical local product origin. |
|
Clarula Nuxt app. |
|
Dedicated operator Nuxt app plus the explicit auth, EER, and Clarula operator API namespaces. |
|
Standard partner widget demo. |
|
Clarula partner demo. |
|
Mailpit UI. |
Both product origins proxy /api/v1/, the embeddable-widget bundle, webhooks, actuator endpoints, and explicit backend-static exceptions to the same backend. They return 404 for /admin/ and /api/operator/.
Only http://admin.localhost proxies the explicit /api/operator/v1/auth/, /eer/, and /clarula/ namespaces; remaining requests reach the operator Nuxt development server. Unknown product and operator routes remain 404 responses.
Start and Hot Reload
./start-local.sh
./start-local.sh --metabase --otel
./start-local.sh --no-build
The script builds and mounts applications/backend/build/libs/backend.jar, starts Gradle bootJar --continuous, and restarts only the backend when the JAR changes.
The three Node 26.5 containers install the pinned workspace dependencies into separate named volumes and run Nuxt dev servers with source mounts.
Ctrl+C stops the Compose project.
Backend JDWP remains available on port 5005.
PostgreSQL is exposed on 5432, optional Metabase on 3000, and optional OpenTelemetry Collector ports on 4317/4318.
Before the backend starts, product-db-init creates local eer and clarula roles, provisions the clarula database, revokes sibling connection access, and performs the idempotent ownership transition for historical EER objects.
The local backend uses eer for invoicex and clarula for clarula; the development passwords are non-secret Compose defaults.
Local runtime configuration sets both INVOICEX_PRODUCT_ORIGIN and INVOICEX_BASE_URL to http://localhost, while OPERATOR_ORIGIN is http://admin.localhost. The local backend receives an explicit non-secret INVOICEX_ADMIN_EMAILS=admin@example.com allow-list.
DATEV_ENVIRONMENT=mock is the default: /clarula/firms in the operator app creates a Clarula-local firm and executes the complete connection, encrypted-token, import, and confirmation path, while authorization consent and mandate data remain local and deterministic. DATEV_REDIRECT_URI is http://admin.localhost/api/operator/v1/clarula/datev/callback, derived from the same canonical local operator origin used by hosted environments. DATEV Sandbox Validation describes the credentialed HTTP-adapter check.
Visual Regression Environment
applications/visual-tests/update-screenshots.sh keeps screenshot generation inside the pinned Playwright container.
A normal update or check builds a production-like backend runtime plus the static frontend and custom Caddy artifacts, creates fresh project-scoped Compose state with both product databases, runs Playwright, and tears the stack down.
The explicit --up, --reuse, and --down lifecycle retains those containers and build outputs for repeated local screenshot updates. Reuse is rejected when runtime source inputs or built artifacts changed after --up.
Developers run a normal hermetic --check after incremental updates.
Screenshot updates select only the visual specifications; checks can select visual and non-screenshot contract specifications independently.
DATEV Sandbox Validation
The deterministic mock adapter is the explicit operating mode for local development and automated tests.
Sandbox validation exercises the same admin, connection, token, import, persistence, and confirmation path with the HTTP adapter.
It is a manual credentialed check because DATEV authentication requires an interactive authorized user.
Configuration
For a confidential DATEV sandbox application, register the exact Dev callback URI https://admin.dev.clarula.de/api/operator/v1/clarula/datev/callback (production uses https://admin.clarula.de/api/operator/v1/clarula/datev/callback) subscribed to master-data:master-clients.
Start the backend with:
DATEV_ENVIRONMENT=sandbox
DATEV_CLIENT_ID=<sandbox client id>
DATEV_CLIENT_SECRET=<sandbox client secret>
DATEV_REDIRECT_URI=<exact registered callback URI>
DATEV_TOKEN_ENCRYPTION_PASSWORD=<environment secret of at least 32 characters>
DATEV_TOKEN_ENCRYPTION_SALT=<stable random hexadecimal salt of at least 16 bytes>
The requested scopes are openid, datev:master-data:master-clients:read, and datev:master-data:org-info:read.
Do not add document-write permissions to this validation.
Hosted deployments read DATEV_ENVIRONMENT and environment-specific 1Password field references from GitHub environment variables. Dev permits mock or sandbox; production permits mock or production. The deployment workflow resolves those references through its existing 1Password service account only for sandbox or production; unset credentials are accepted only while the explicit mode is mock. Hosted rendering always derives DATEV_REDIRECT_URI from the validated environment-specific OPERATOR_ORIGIN. In mock mode, Compose omits the four credential/encryption values instead of injecting empty strings while still passing that canonical callback.
The runtime renderer rejects absent or weak external encryption material and any separately supplied callback that conflicts with the derived value.
Secrets belong in the environment secret store and never in repository files, shell history, screenshots, or issue comments.
Only the backend receives the rendered DATEV values; PostgreSQL receives an explicit database-only environment.
OpenTelemetry URL sanitization redacts OAuth/OIDC code, state, token, error, error_description, error_uri, nonce, and code_challenge query values before traces are exported by hosted deployments.
Adapter Mode Transition
The application does not persist adapter-mode provenance and does not support an in-place transition between mock, sandbox, and production.
Every DATEV_ENVIRONMENT change therefore requires a destructive reset of the complete Clarula database. This removes all Clarula firms, OAuth tokens, imported mandates, routing identities, targets, confirmations, and authorization attempts; EER remains untouched.
Run the transition as one supervised maintenance operation. Record the old mode, target mode, cutover time, and operator, and prevent concurrent deployments or backups. On production, acquire the shared operation lock from /opt/apps/invoicex and keep that shell open through the database reset:
cd /opt/apps/invoicex
mkdir .production-operation-lock || {
echo "A production deployment, backup, or transition is already active" >&2
exit 1
}
trap 'rmdir .production-operation-lock' EXIT HUP INT TERM
-
While the backend still uses the old mode, revoke every real provider grant. For
sandboxorproduction, disconnect each firm through the admin UI. If local ciphertext cannot be used for revocation, revoke the grant through DATEV before continuing. Confirm that no external connection remains:docker compose exec -T postgres psql -X -v ON_ERROR_STOP=1 -U invoicex -d clarula -Atc \ "SELECT count(*) FROM firm_datev_connections;"The result must be
0. The deterministic mock has no external grant, so this revocation step may be skipped when leavingmock. -
Stop the backend. From
/opt/apps/invoicexon a hosted VM, orlocal-developmentin a local checkout, drop and recreate the complete Clarula database and verify that it contains no application tables:docker compose stop backend docker compose exec -T postgres psql -X -v ON_ERROR_STOP=1 -U invoicex -d postgres \ -c "DROP DATABASE IF EXISTS clarula WITH (FORCE);" docker compose run --rm product-db-init test "$(docker compose exec -T postgres psql -X -v ON_ERROR_STOP=1 -U invoicex -d clarula -Atc \ "SELECT count(*) FROM pg_tables WHERE schemaname = 'public';")" = "0" -
On production, release the operation lock only after the reset. Keep the backend stopped until the deployment workflow starts it:
trap - EXIT HUP INT TERM rmdir .production-operation-lock -
Change
DATEV_ENVIRONMENT, credentials, and encryption material through the environment’s normal secret and deployment workflow. Confirm that the registered callback matches the URI derived from that environment’s fixed operator origin. Do not start the old runtime against the empty database. -
Deploy the backend normally. Flyway creates the Clarula schema from
V1, after which the health check must beUPand the firm list must be empty. -
Recreate and reconnect every intended Clarula firm in the new mode before importing mandates.
A Clarula dump created before the recorded cutover must never be restored into the new mode. It may be used only for a complete rollback with its matching runtime configuration; otherwise restore the other product data selectively and recreate Clarula empty. A rollback to another adapter mode repeats the same revocation and reset procedure.
Any local or ephemeral database that applied the unreleased earlier checksum of Clarula migration V1 must also be recreated once, even when its adapter mode does not change.
Validation Flow
-
Log into the operator workspace on the environment’s dedicated admin origin.
-
Create or open the target Clarula firm under
/clarula/firmsand select DATEV & Mandate. -
Start Mit DATEV verbinden.
-
Let an authorized sandbox firm user authenticate and grant access directly at DATEV.
-
Verify that the callback shows the expected DATEV firm reference, granted scopes, and expiry without displaying tokens.
-
Trigger Mandate importieren.
-
Verify that imported names, addresses, strong identifiers, completeness, provenance, and demo targets are read-only.
-
Confirm one complete and one incomplete mandate.
-
Restart the backend and repeat the import to prove encrypted token persistence, refresh rotation, idempotent mandate upsert, and confirmation preservation.
-
Disconnect and verify that both provider tokens are revoked before the local connection disappears.
Record only pass/fail, environment, immutable application revision, DATEV request correlation identifiers, mandate counts, and sanitized internal error codes. Do not record tokens or complete provider payloads.
Production Switch
Production later uses DATEV_ENVIRONMENT=production, production app credentials, a production callback URI, and separate encryption material.
The Clarula Firm domain model, isolated database, child-context route, and DatevApi contract do not change.
Before switching, follow the complete clean-state procedure above; revoking existing external grants is required but does not replace the full database reset.
Production vs. Dev vs. Local Comparison
| Aspect | Production | Dev | Local Development |
|---|---|---|---|
Host topology |
Terraform-managed Hetzner VPS |
Separate Terraform-managed replaceable VPS |
Developer Docker Compose project |
Static frontends |
Three immutable static Nuxt/Nginx OCI images behind Caddy: two product apps and one operator app |
Same frontend-container topology as production |
Product dev servers plus the operator app’s separate development entry point |
Frontend runtime |
Product-owned Nginx; no Node, Nuxt, or Hugo process |
Product-owned Nginx; no Node, Nuxt, or Hugo process |
Node is development-only for hot reload |
Shared backend |
Independent |
Independent |
Mounted bootJar, Spring |
Route ownership |
Caddy owns shared backend/security routes; product Nginx owns public static routes; |
Same, with |
Product origins proxy product APIs and explicit backend exceptions; |
Database |
One PostgreSQL 18 cluster on an attached persistent volume; separate |
Same logical database/role isolation on a separate attached persistent volume |
Same logical database/role isolation on a named volume with non-secret local passwords |
Caddy |
Immutable environment image, ACME state on attached volume, edge rate limits |
Immutable environment image, separate ACME state, same rate limits |
Immutable local image, plain HTTP, no edge throttling |
Mailgun |
Shared Mailgun account and domain, separate |
Mailpit |
|
Observability |
New Relic production namespace + Metabase |
New Relic dev namespace + Metabase |
Optional local OTel collector + optional Metabase |
Delivery |
Manual full-runtime promotion; exact tested frontend image digests promoted from dev |
Path-selected automatic component delivery from |
|
Version state |
Seven independent |
Same with |
Local images, source mounts, and named dependency volumes |
Secrets |
|
|
Git-ignored local application config and explicit non-secret Compose defaults, including the local operator origin and allow-list |
Cross-cutting Concepts
This chapter documents concepts and patterns that apply across multiple building blocks. Each concept describes how the system works, not what its components are.
Domain Model
Neither product maintains an accounting ledger or long-lived invoice master data.
EER owns a bounded semantic conversion model, while Conversion and InvoiceEditSession persist only artifacts, evidence, draft snapshots, and lifecycle state needed for delivery, claims, diagnosis, correction, and expiry.
Clarula owns a separate firm, mandate, and DATEV-routing domain and cannot import EER domain code.
ADR-34 establishes the EER-owned, format-neutral invoice model. The unchanged source PDF remains authoritative for issuer-visible content; source evidence, extracted observations, editable drafts, and commonly validated invoice semantics remain distinct. ZUGFeRD and future EER target artifacts never become canonical semantic state. The semantic LLM DTO maps directly into this model without a second monetary interpretation layer. Active correction sessions persist application-owned draft snapshots; completed sessions additionally retain their exact immutable result artifacts until expiry.
EER Format-Neutral Invoice Processing Model
The model is a non-JPA domain model owned by :eer package invoice.conversion:
-
SourceEvidencepreserves immutable PDF identity, complete page evidence, bounded analysis text, and truncation state. -
ObservedInvoicecarries semantic claims linked to evidence. Each invoice concept has one value; there are no parallel source, net, gross, printed, or calculated fields. -
InvoiceDraftcarries editable semantics plus extraction and correction provenance. Repeated semantic elements use stable UUIDs. -
InvoiceChangebinds each corrected value fingerprint to source evidence, advances one contiguous draft revision, and supersedes ambiguity only for the effective value. -
ValidatedInvoicerepresents common semantic consistency, not target readiness or legal validity.
DocumentAnalysisResult represents invoice candidates with findings, safe non-invoice classifications, accepted but automatically unreadable documents, permanent source rejection, and transient processing failure.
EER target adapters apply independent mapping and conformance rules.
Every correction session persists a versioned, integrity-checked draft snapshot; the one-time destructive cutover deletes all pre-model sessions rather than reconstructing missing source evidence. Snapshot compatibility is relational metadata checked before JSON binding. Obsolete uncorrected drafts can be reconstructed from the stored PDF outside a database transaction and installed only by a lifecycle-, TTL-, hash-, schema-, revision-, and version-guarded compare-and-swap, while completed downloads no longer require a draft reader.
Core Entities
The system uses persistent entities for EER identity, partner attribution, edit-session state, and the independent Clarula firm/DATEV model.
EER entities extend BaseEntity; Clarula entities compile in a separate module and extend ClarulaBaseEntity.
Both conventions provide UUID primary keys, optimistic locking via @Version, and automatic audit timestamps without creating a shared persistence type.
The diagrams are separate because there is no cross-product entity relationship, foreign key, shared persistence superclass, or persistent identifier mapping.
In particular, an EER Partner and a Clarula Firm are independent records even when they represent the same real-world tax firm.
This separation follows ADR-31 and ADR-32.
EER Entities
- Account
-
Represents a registered user. Owns the plan, trial dates, conversion counter, and optional partner reference.
Account.partnerrecords partner origin or confirmed affiliation metadata; it is not by itself a partner-managed tariff.Account.partnerAffiliationConfirmedAtrecords when a partner confirmed tariff eligibility during or before the trial. Onlyplan = PARTNERmakes the account partner-managed. Partner-origin registrations therefore start like direct registrations onPlan.FREE_TRIALwith a regular 30-day trial window while keeping the partner reference for attribution and future eligibility. Package:identity.accounts - PendingRegistration
-
Stores DOI requests before an account exists. For partner-widget registrations it additionally keeps the optional full name or company name for the requested Kanzlei-Zuordnung, plus the state of the one-time partner confirmation token until the request is confirmed, expires, or is ignored. Package:
identity.registration - Partner
-
Represents a registered widget partner with display name, a non-empty set of exact allowed origins, redirect URL, active state, and affiliation notification address. Origins are persisted in
partner_allowed_originsand can be shared only through explicit mappings. Package:widget.partner - Conversion
-
Persists bounded processing artifacts and delivery state for email, website, and widget conversions. It may retain original and generated PDFs, CII XML, extracted fields, validation evidence, and an expiring anonymous claim token. Optional
AccountandPartnerrelationships record ownership and widget source without turning the record into an accounting invoice aggregate. Package:conversion.persistence - WidgetIdentitySession
-
Represents a persistent, partner-scoped capability for sending later widget conversion results to one account email. Only a SHA-256 token hash is persisted. Sessions are bound to the exact
PendingRegistrationand technically expiring before DOI, then account-bound and indefinite by default after activation;revokedAt,expiresAt, andlastUsedAtsupport revocation and policy checks. The entity never grants application login or browser download access. Package:widget.auth - LoginToken
-
A single-use magic link token with expiration. References one Account. Package:
identity.authentication - InvoiceEditSession
-
Stores the original PDF, validation state, schema-versioned
InvoiceDraftsnapshot, fachliche revision, and integrity hash. The schema version is relational metadata and is evaluated before snapshot deserialization. An obsolete active revision-zero snapshot may be rebuilt from the PDF; corrected or future-version snapshots fail closed. On successful completion the session also stores the exact generated ZUGFeRD PDF, verification PDF, and XML. These immutable delivery artifacts are never editable state and allow downloads without historical draft deserialization or regeneration. The draft-only cutover deletes older XML-backed sessions because their generated XML cannot provide trustworthy source evidence. Also carries email threading metadata so response emails appear in the same thread. Anonymous widget edit sessions additionally keep the widget claim token so corrected results can update the original anonymousConversionand continue through the standard claim-and-email flow. Package:invoice.editing.session
Clarula Entities
- Firm
-
Represents one Clarula tenant with a stable Clarula-local identifier, display name, and active lifecycle. It has no EER
Partnerreference and possession of its UUID grants no authority. Package:com.clarula.clarula.firmin:clarula - FirmDatevConnection
-
Stores one firm-scoped DATEV authorization as encrypted access and single-use refresh tokens plus granted scopes, provider subject, expiry, status, and the verified DATEV business-partner reference. It never grants Clarula login and contains no mandate data. Package:
com.clarula.clarula.datevin:clarula - DatevFirmImportSnapshot and DatevImportedMandateEntity
-
Represent the current minimized, read-only DATEV import for one Clarula firm. The snapshot records source firm, version, and import time;
DatevMandateRoutingIdentityEntityretains normalized routing evidence, whileDatevMandateDemoTargetstores the simulated target associated with each mandate. Provider responses outside this allow-list are not persisted. Package:com.clarula.clarula.datev.mandatesin:clarula - DatevMandateConfirmation
-
Records the Clarula admin and time that an available imported mandate was confirmed. It is separate from imported fields, survives same-source re-import, and cannot transfer across a source-firm change. Package:
com.clarula.clarula.datev.mandatesin:clarula
For field-level details, see the entity source files — they are the single source of truth.
BaseEntity Convention
Every EER JPA entity extends BaseEntity; every Clarula JPA entity extends ClarulaBaseEntity.
Keeping two equivalent product-owned base classes prevents a persistence dependency through :core while enforcing the same four properties in each persistence layer:
-
UUID primary key — generated in application code with
UUID.randomUUID(), never sequential -
Optimistic locking —
@Versionprevents lost updates without database locks -
Audit timestamps —
createdAtandmodifiedAtset automatically -
Proxy-safe equals/hashCode — equality requires the same Hibernate class and UUID; hash codes use the Hibernate class
Aggregate roots use companion factories where creation must enforce lifecycle defaults; provider snapshot entities may be constructed directly inside their owning services. JPA’s no-arg construction remains an internal persistence concern. See the Persistence section for a code example.
Sealed Result Types
The system models expected outcomes as sealed classes.
Kotlin’s exhaustive when expressions force callers to handle every case at compile time.
Two primary result types drive the processing pipelines:
EmailProcessingResult-
Models all outcomes of the email-based pipeline — from successful conversion through silent rejection to retryable infrastructure failures.
DocumentAnalysisResultandConversionResult-
DocumentAnalysisResultis the target-neutral semantic outcome: candidate with immutable draft and findings, non-invoice, unreadable document, rejected source, or processing failure. The ZUGFeRD application adapter maps that outcome toConversionResultfor email, website, and widget delivery.ConversionResulttherefore carries target-specific XML/PDF artifacts plus the draft needed for diagnosis; it is not the shared semantic model.
This pattern provides compile-time safety: adding a new outcome variant causes build failures everywhere that when expression does not handle it yet.
Business Events
BusinessEvent is the abstract base for all domain-significant occurrences.
Each event carries a timestamp, correlation ID (from MDC or generated), and optional user email.
The event type derives automatically from the class name.
Events flow through Spring’s ApplicationEventPublisher to three independent sinks:
This fan-out design keeps event producers decoupled from consumers.
Adding a new sink (e.g., webhook notifications) requires only a new @EventListener — no changes to existing code.
Persistence
Schema Ownership
Each product owns an independent Flyway history in a separate logical PostgreSQL database.
Hibernate validates each product persistence unit against its own database and never modifies either schema (ddl-auto: validate).
A Clarula migration cannot resolve an EER table, and the database roles are denied connection to the sibling database.
Failure of either required Flyway chain or either Hibernate validation fails startup of the shared backend process.
Database runtime: one PostgreSQL 18 cluster with logical databases invoicex (EER), clarula, and metabase.
Schema Layout
The product databases have distinct schemas, histories, ownership roots, and foreign-key graphs:
accounts is the central EER identity entity.
Both login_tokens and invoice_edit_sessions reference it via foreign key with ON DELETE CASCADE.
firms is the independent Clarula tenant root; all current Clarula DATEV state reaches one firm exclusively through Clarula-local foreign keys.
There is no cross-database foreign key, join, view, foreign data wrapper, or shared identifier mapping between partners and firms.
The EER analytics schema is write-only by design.
Application features never read from analytics.business_events — it serves observability and reporting only.
An ArchUnit test (AnalyticsBoundaryArchTest) enforces this boundary.
| Table | Purpose |
|---|---|
|
User identity: email, plan, trial dates, conversion counter |
|
Passwordless magic link tokens with expiration and single-use tracking |
|
Original PDF, integrity-checked |
|
Clarula-owned tenant identity, display name, and active lifecycle |
|
Encrypted firm-scoped DATEV authorization, verified source binding, scopes, expiry, and reconnect state |
|
Clarula-local current minimized read-only mandate import plus provenance and completeness evidence |
|
Clarula-owned confirmation separate from imported DATEV values |
|
Append-only event log with JSON payloads (max 64 KB each) |
For column-level details, see the product-owned Flyway migrations — they are the single source of truth.
Database Roles and Provisioning
Compose starts an idempotent product-db-init job before the backend.
The operational PostgreSQL superuser creates the eer and clarula login roles, creates the Clarula database, migrates ownership of existing EER relations, routines, enum/domain types, and schemas from the historical superuser to eer, and revokes public database connection privileges.
Among application roles, only eer receives CONNECT on invoicex and only clarula receives CONNECT on clarula; the operational superuser retains explicit cluster access.
The backend receives both credential sets because it remains one process, but each set is bound only inside its owning application context.
The operational superuser remains available to provisioning, Metabase, and backup automation and is not configured as either product DataSource.
EER migrations live at eer/src/main/resources/db/migration and Clarula migrations at clarula/src/main/resources/db/clarula/migration.
Each eager sibling context creates and runs only its local Flyway bean; the persistence-free root and shared-services bridge expose none.
Deleting historical EER migrations is prohibited; the removed unshipped DATEV V121 never becomes part of the EER chain.
The two flyway_schema_history tables and version sequences remain independent.
Optimistic Locking Retries
BaseEntity uses @Version to detect concurrent updates.
Spring Framework 7 retry support is enabled through @EnableResilientMethods.
Retry-safe transaction entry points use the composed @RetryableTransactional annotation when a concurrent update can be resolved by reading the latest state and repeating the operation.
It combines Spring’s @Retryable with @Transactional(propagation = REQUIRES_NEW), limits retries to one for recognized Spring, JPA, or Hibernate optimistic-locking exceptions, and uses no retry delay.
The retry advice surrounds the transaction advice, so every attempt gets a fresh transaction and persistence context.
Retry must not be applied globally or around irreversible side effects such as sending email or calling an external API.
Entity Conventions
EER entities extend BaseEntity; Clarula entities extend the compile-time-independent ClarulaBaseEntity with the same UUID, version, and audit semantics.
Domain-created entities normally use companion object factory methods:
@Entity
class LoginToken : BaseEntity() {
lateinit var token: String
// ...
companion object {
fun create(account: Account, token: String, expiresAt: Instant): LoginToken =
LoginToken().apply {
this.account = account
this.token = token
this.expiresAt = expiresAt
}
}
}
This pattern keeps entity construction logic centralized and supports JPA’s no-arg constructor requirement without exposing it to callers.
Repository-level atomic inserts are reserved for database-enforced claims such as single-use token consumption with ON CONFLICT DO NOTHING.
Security
Authentication Flow
EER uses passwordless authentication via magic links. No passwords exist in the system.
Customer API operations use /api/v1/auth/magic-link, /api/v1/auth/verify, /api/v1/auth/session, and /api/v1/auth/logout; the pre-existing operations retain /auth/ compatibility aliases.
Magic-link emails use eer.product.origin, successful verification redirects to /portal, and token errors redirect to product-owned /auth/error/ routes.
Backend-rendered customer login, portal, error, and plan routes remain available during migration.
Security Layers
| Layer | Implementation |
|---|---|
Authentication |
Customer JWTs and remember tokens are stored in host-only, HTTP-only, Secure, SameSite=Lax cookies; neither cookie has a |
Token management |
Firm-scoped DATEV OAuth access and single-use refresh tokens are encrypted at rest in the Clarula database, bound to the verified DATEV firm reference, and never logged or rendered. Connection replacement, refresh, import persistence, and disconnect are serialized with a pessimistic Clarula |
Rate limiting |
Two layers: Caddy reverse proxy with the |
Webhook security |
Mailgun inbound webhooks validated via HMAC signature verification. |
CORS |
First-party contact, registration, and free-upload APIs accept configured einfache-eRechnung and Clarula origins on both canonical and legacy paths. Authenticated product APIs are same-origin. The standard widget API resolves the request origin against cached active |
API authorization failures |
Canonical |
Sensitive response policy |
All |
Frontend operational reports |
|
Email unsubscribe |
Trial reminder body links open product-origin |
Link-scanner safety |
Partner affiliation links under |
Container security |
Non-root Docker user in production images. |
DATEV Connection Boundary
A DATEV connection belongs to one active Clarula Firm; it never authenticates a Clarula customer or administrator and never references an EER Partner.
The Clarula administrator only initiates assisted onboarding.
A firm representative enters credentials and grants consent directly at DATEV.
| Aspect | Enforcement |
|---|---|
Authorization attempt |
Authorization Code with PKCE S256, high-entropy |
Operator mutations |
Connect, import, confirm, and disconnect JSON operations require the authenticated operator cookies, exact |
Token storage |
Access and rotating refresh tokens use authenticated encryption before PostgreSQL persistence. External adapter modes require environment-provided client credentials and encryption material. Raw credentials, codes, tokens, callback URLs, and provider bodies never enter UI, logs, or analytics. |
Firm binding |
|
Token lifecycle |
Access tokens refresh on demand. A Clarula-database lock serializes every firm connection mutation and the single-use refresh-token exchange. Terminal or ambiguous refresh failure marks the connection reconnect-required. Reconnect can replace locally unreadable ciphertext for the same source; disconnect discards unreadable ciphertext with an audit warning. Access and refresh ciphertext are handled independently so every readable provider token is revoked before replacement or local deletion. A transaction rollback after token rotation triggers best-effort revocation of the newly issued pair. |
Adapter boundary |
|
Context and database isolation |
|
Partner Widget Identity Boundary
A partner widget identity session is a bearer capability for one operation: send a widget conversion owned by the same partner to the fixed email of the bound account. It is deliberately separate from customer and admin authentication.
| Aspect | Enforcement |
|---|---|
Issuance |
|
Storage |
The browser stores the raw token in partner-origin |
Scope |
|
Revocation and expiry |
|
Capability limits |
The token cannot establish a Spring |
Browser threat model |
Any script executing on the partner origin can read |
Operator Authentication Boundary
The operator API (/api/operator/**) uses a completely separate authentication pipeline from the customer portal. https://admin.clarula.de is the only operator origin; all product-origin /admin and /api/operator requests return 404 at Caddy.
| Aspect | Implementation |
|---|---|
Operator identity |
Allow-list in |
Login entry point |
The static |
Session cookies |
|
API and CSRF protection |
Caddy forwards only |
Static boundary |
Remaining declared operator documents and assets reach the unprivileged operator Nginx container. Caddy removes |
Response and indexing policy |
Operator documents, APIs, auth responses, and callback results use |
Audit logging |
Login, denial, logout, refresh/revocation, EER partner mutation, Clarula firm lifecycle, DATEV connection/import, and mandate confirmation events carry the operator identity, product, stable resource identifier, outcome, and correlation identifier without tokens or sensitive payloads. |
Browser Product API Compatibility
Canonical Contract
All JSON and multipart operations called by product frontends or embeddable widgets have canonical paths below /api/v1/.
The version identifies the first major browser contract, not an implementation release.
Compatible changes within v1 are additive.
A breaking change requires a new major path and a migration period in which both majors remain available.
The product-v1 contract includes authentication, customer session, portal summary, registration, contact, plan, trial-email subscription, free-upload, diagnosis, correction, frontend operational reporting, and standard-widget operations.
It excludes backend-rendered HTML, admin routes, webhooks, actuator endpoints, and the runtime OpenAPI endpoints themselves.
The diagnosis and correction APIs use the existing edit-session token as a capability. The einfache-eRechnung Nginx container serves only the two scoped static client shells for /diagnosis/{token} and /edit/{token}; Caddy keeps the API routes backend-owned and no customer-facing backend HTML route remains.
Partner-affiliation confirmation remains a backend route until its owning UI flow migrates.
Registration and magic-link confirmations remain backend-handled token operations, but their canonical v1 handlers redirect to product-owned result routes.
| Canonical path | Temporary compatibility alias |
|---|---|
|
Matching |
|
|
|
|
|
|
|
None. This anonymous, cookie-free endpoint is used only by generated product frontends. |
|
None. Legacy app-host diagnosis links redirect to the static product route. |
|
None. Legacy app-host correction links redirect to the static product route. |
|
None. The product frontend owns |
|
|
|
|
|
|
The widget script URL is an embed contract, not an API alias.
/widget/v1/embed.js remains stable while its internal requests use canonical API paths.
The pre-production DATEV spike operations under /api/v1/datev-widget/** were retired as an approved reset before implementation of the Clarula partner demo.
Contract Generation
Springdoc builds the product group from Spring MVC handlers marked as product API controllers and filters it to /api/v1/**.
The Gradle generation task boots a test application with Testcontainers, requests the grouped document, sorts every object key, and writes a deterministic JSON document.
It does not call a deployed or production backend.
cd applications/backend
./gradlew generateProductV1OpenApi # build/openapi/product-v1.json
./gradlew updateProductV1OpenApi # update openapi/product-v1.json
./gradlew checkProductV1OpenApi # detect contract drift
./gradlew checkProductV1ApiCompatibility
checkProductV1ApiCompatibility uses OpenAPI Diff and fails on incompatible v1 changes.
It accepts OPENAPI_BASELINE_FILE for an explicit baseline, otherwise compares with the merge-base selected by GITHUB_BASE_REF or the parent commit.
The first commit that introduces the contract has no earlier baseline and therefore compares the generated document with itself.
A v1 change is incompatible when it removes a path, operation, accepted input, response, or schema value; narrows an existing type; or makes an existing optional request field mandatory. New optional fields, new response fields, and new operations are additive. Compatibility aliases remain outside the product-v1 document so they cannot become the source contract accidentally.
Generated TypeScript Client
applications/frontend/packages/api-client/ generates OpenAPI path and schema types from the committed contract and wraps them with openapi-fetch.
Both Nuxt applications depend on this package, and workspace type-checking compiles it with each product.
Generation and drift checks use only repository files:
cd applications/frontend
npm run generate:api-client
npm run check:api-client
npm run typecheck
Generated files are committed so product builds do not depend on a running backend. A backend contract change is complete only when the OpenAPI document and generated TypeScript source are updated together. The generated correction schema contains the complete current editor field graph, including repeatable line items, parties, delivery, references, allowances, charges, tax breakdowns, payment data, and attachment metadata. Frontend code must import these generated transport types instead of declaring parallel diagnosis or correction DTOs.
Failure Contract
Framework, security, and unexpected failures use application/problem+json with RFC 9457 fields.
The documented Problem schema adds a stable code; Caddy emits the same representation with rate_limit_exceeded when it rejects a request at the edge.
Expected domain outcomes that already use typed response variants, such as an invalid PDF, invalid unsubscribe token, or expired widget claim, retain their documented status-specific JSON bodies during the compatibility window.
Edit-capability operations distinguish malformed (400, edit_token_invalid), missing (404, edit_session_not_found), expired (410, edit_session_expired), completed (409, edit_session_used), and unconfirmed editor entry (409, editor_confirmation_required) states.
Invalid correction submissions use 422 correction_validation_failed and extend the problem document with the effective semantic form and structured rule, field, ambiguity, and evidence information.
Result downloads return 409 correction_not_completed until the persisted completion marker exists.
Unauthenticated and unauthorized requests below /api/v1/ return JSON 401 and 403 responses without redirecting to an HTML login page.
Canonical API responses and legacy token handoffs set Cache-Control: no-store, X-Content-Type-Options: nosniff, and Referrer-Policy: no-referrer so account data and URL tokens do not enter browser caches, content-sniffing contexts, or referrer headers.
Problem instances use matched route templates for edit capabilities and therefore never echo the token.
Legacy protected routes retain their redirect behavior until their consumers migrate.
The operational frontend-failure operation accepts only the generated FrontendFailureReportRequest schema.
API reports must pair a registered route template with its stable operation ID; navigation reports omit the operation ID.
Unknown fields, concrete capability paths, query strings, invalid statuses, and unregistered API pairs fail with 400 invalid_frontend_failure_report or the generic malformed-request problem before any operational event is emitted.
Error Handling
Strategy
EER combines two error handling approaches:
-
Fail-fast for unexpected conditions — throw exceptions. An NPE or missing configuration should crash immediately, not produce silent corruption.
-
Sealed result types for expected outcomes —
EmailProcessingResultandConversionResultmodel every possible pipeline outcome as an explicit type. The compiler enforces exhaustive handling.
Error Humanization
Technical error messages from third-party libraries are not user-facing. Two components translate them into plain German:
MustangErrorHumanizer-
Converts Mustang Project exceptions (arithmetic mismatches, validation failures) into actionable German messages. For example, an
ArithmeticExceptionabout "Payable total in XML is 3609.10, but calculated total is 134.29" becomes:
"Der Rechnungsbetrag (3.609,10 EUR) stimmt nicht mit der berechneten Summe (134,29 EUR) ueberein." EN16931ErrorMessages-
Maps 120+ Schematron BR-codes (BR-01 through BR-IP-10) to German translations. Uses plain language: "Die Rechnungsnummer fehlt" instead of "[BR-02]-An Invoice shall have an Invoice number." Falls back to a simplified English message when no German translation exists.
Browser API Failures
Canonical /api/v1/** framework, security, unexpected, and edge rate-limit failures use the RFC 9457 application/problem+json representation.
The response contains type, title, status, detail, and instance; backend failures and Caddy’s 429 rate_limit_exceeded response also expose a stable code for UI decisions.
Authentication and authorization failures return JSON 401 and 403 responses instead of redirecting to the backend login page.
Expected domain outcomes with established typed payloads remain status-specific response variants during the compatibility window. For example, unsubscribe and resubscribe return unsubscribed, resubscribed, or invalid result states without turning an invalid link into an infrastructure failure.
The product-v1 OpenAPI document includes the shared problem schema and failure responses for every operation.
Canonical API responses use Cache-Control: no-store, X-Content-Type-Options: nosniff, and Referrer-Policy: no-referrer.
Problem instance values omit query strings; edit-capability instances additionally use the matched route template, such as /api/v1/corrections/{token}, instead of echoing the path token.
Error Communication
Error emails include actionable advice, not just status codes. Each error type maps to a specific email template:
| Error Type | User Communication |
|---|---|
Validation errors (fixable) |
Error list + diagnosis link with source-invoice-first guidance. The browser editor stays behind an explicit confirmation step. |
Validation errors (structural) |
Error list with specific field names and per-rule diagnosis content from |
Extraction failure |
Explanation + request to check the original PDF |
Technical failure |
Apology + request to retry later |
Unregistered sender |
Silent rejection (no email sent, HTTP 406) |
The static diagnosis and correction client shells use the same token-backed InvoiceEditSession through canonical JSON/PDF APIs; no customer-facing Thymeleaf transport remains.
The v1 APIs return stable problem codes for malformed, missing, expired, completed, and unconfirmed capabilities.
Preview responses always contain the effective semantic form plus structured validation feedback; invalid submissions return the same information as a 422 correction_validation_failed problem.
A successful submission persists completedAt; editor and diagnosis operations then return edit_session_used, while result and PDF-download operations remain available until session cleanup.
For email-origin validation failures, both /edit/{token} and /api/v1/corrections/{token} reject editor access until the user explicitly confirms that the visible PDF already contains the correct information.
Email Architecture
Dual Provider Pattern
The EmailSender interface abstracts away the email transport.
AbstractEmailSender centralizes composition logic (subjects, templates, attachments, inline resources), and two provider-specific subclasses handle transport:
-
Local development —
SmtpEmailSendersends via SMTP to a local Mailpit instance for visual inspection. -
Production —
MailgunEmailSendersends via the Mailgun REST API.
Automated-Response Loop Prevention
Public support, invoice ingestion, and internal staff notifications use separate recipient addresses. The internal notification recipient is configured per environment, has no application autoresponder, and must not be routed to an invoice webhook. Dev and production additionally use distinct invoice inboxes.
All application-generated messages carry Auto-Submitted and X-Auto-Response-Suppress headers.
Invoice ingestion acknowledges but does not answer messages marked as automated, messages from delivery-system accounts such as mailer-daemon, or messages from configured protected system mailboxes.
The protected list includes both production and dev inboxes so one environment cannot start an automatic response chain with the other even when provider headers are lost.
These application checks complement rather than replace exact-recipient Mailgun routes.
Email Threading
Response emails appear in the same thread as the user’s original email.
EmailThreadingInfo carries the original Message-ID and Subject, which map to In-Reply-To and References headers on outgoing messages.
Reply subjects follow the pattern Re: Original Subject — short suffix (e.g., "Re: Invoice #42 — E-Rechnung ist fertig").
Templates
All email content renders through Thymeleaf templates.
GermanFormatters provides locale-specific number and date formatting for template expressions (German decimal separators, date formats).
For the partner-widget channel, the outbound email set now includes two additional privacy-sensitive messages:
-
the DOI confirmation email for a new widget user after they requested Kanzlei-Zuordnung, and
-
the partner affiliation request email sent only after DOI confirmation, and
-
the customer confirmation email sent after the Kanzlei approved the Zuordnung.
The partner email is intentionally minimal: it includes only full name or company name, email address, request context, and the one-time confirmation link. It never includes invoice files, extracted invoice data, conversion results, usage history, subscription details, or account links.
The follow-up customer email confirms the approved Kanzlei by name, states that no further action is required, and repeats the privacy boundary: only name or company name plus email address were shared.
Trial Reminder Unsubscribe Links
Trial reminder emails deliberately carry two links for the same signed preference token:
-
The visible email link targets product-origin
/email/unsubscribe, where the static Nuxt page calls the canonical JSON result operation. Its resubscribe action uses/api/v1/email/resubscribe. -
The RFC 8058
List-Unsubscribeheader targets product-origin/api/v1/email/unsubscribe. Caddy proxies that path to Spring, so mailbox providers can send the required one-clickPOSTwithout JavaScript.
The backend also retains GET /email/unsubscribe, GET /email/resubscribe, and POST /email/unsubscribe for previously sent messages.
Token-bearing responses are non-cacheable, suppress referrers, and never write raw tokens to logs or analytics events.
Observability
Three-Signal Architecture
The OpenTelemetry Java agent (2.25.0) auto-instruments the Spring Boot application. All three signal types — traces, metrics, and logs — export to New Relic EU.
Business Event Pipeline
Business events flow through Spring’s ApplicationEventPublisher to three independent sinks.
Each sink runs in its own error boundary: one sink failing does not affect the others.
| Sink | Behavior |
|---|---|
|
Saves the event as a JSON payload to PostgreSQL. Uses |
|
Adds an event to the current OTel span with typed attributes (event type, correlation ID, user email). |
|
Emits an |
Context Propagation
Every business event carries a correlationId (from SLF4J MDC or auto-generated) and optionally a userEmail (also from MDC).
This ensures trace correlation across synchronous processing, async event handling, and log output.
Operational Diagnostics and Product Analytics
Operational diagnostics and product analytics have separate purposes, data contracts, and consent behavior.
| Signal | Purpose | Boundary |
|---|---|---|
Frontend operational diagnostics |
Detect broken static releases, client navigation, and product API calls. |
Anonymous, allow-listed technical dimensions only. Reports go to OpenTelemetry and application logs. They never enter |
Product analytics |
Measure visits, campaigns, and product interactions. |
einfache-eRechnung retains its existing Plausible and consent-controlled Google Ads behavior on public pages. Auth, portal, email-token, diagnosis, and correction routes load no product analytics. Clarula loads no product analytics. |
Business events |
Record server-side domain outcomes such as invoice processing and trial lifecycle events. |
Published by backend business capabilities through the existing event pipeline. A frontend failure is not a business event. |
Frontend Release and Failure Diagnostics
Every generated product document exposes product and frontend-release meta elements.
The same values are available at the fixed static path /.well-known/frontend-release.json:
{
"product": "einfache-erechnung",
"release": "4f7d9e29d6e14eb89f96a0a1f37dc2f4"
}
Support and deployment smoke checks can therefore identify a static artifact even when its client bundle cannot start.
The edge must serve this mutable pointer and Nuxt’s /_nuxt/builds/latest.json with Cache-Control: no-store; content-addressed Nuxt assets keep their immutable cache policy.
Hydrated routes report navigation failures and failed generated-client operations to same-origin POST /api/v1/operational/frontend-failures.
The request uses credentials: omit, fetch referrer policy no-referrer, and keepalive.
The client drops unknown routes, deduplicates identical reports for 60 seconds, and sends at most 20 reports per document from a map capped at 50 keys.
Transport failure is ignored so diagnostics cannot cause another product failure.
The payload contains only these fields:
| Field | Constraint |
|---|---|
|
|
|
Immutable build identifier, 7 to 128 allow-listed characters; |
|
|
|
|
|
A declared page template or generated OpenAPI schema path. Capability values remain placeholders such as |
|
Stable generated OpenAPI operation identifier for API failures; absent for navigation failures. |
Client code never forwards a concrete URL, path parameter, query string, request or response body, exception, stack, filename, email address, or invoice content.
The backend rejects unknown fields, unknown API operation/template pairs, concrete sensitive routes, invalid statuses, and oversized dimensions.
It records one frontend.failure span event, one frontend.failures counter increment, and a minimized log entry.
The product request sends no cookies. JwtCookieFilter and MdcLoggingFilter deliberately exclude the endpoint so a nonconforming caller cannot attach customer identity by supplying credentials.
Testing
Three-Tier Strategy
| Tier | Scope | Tools | Location |
|---|---|---|---|
Unit |
Pure logic, no framework |
JUnit 5, MockK, AssertJ |
|
Integration |
Spring context + database |
Spring Boot Test, Testcontainers (PostgreSQL 18) |
Product tests in |
E2E |
Full website + app stack in browser |
Playwright (Docker-based) |
|
Integration Test Infrastructure
EER integration and LLM fixtures start only the EER PostgreSQL 18 Testcontainer and product context, so product behavior can be tested without the composition root or Clarula.
Composed-runtime fixtures in :app start separate worker-scoped EER and Clarula containers and bind different JDBC URLs.
Each sibling context runs only its local Flyway chain, and either eager startup failure must fail the process.
MultiContextRuntimeBoundaryIntegrationTest starts the production composition on a random port and proves default EER and explicit Clarula dispatch, authenticated route exclusivity, the persistence-free root and bridge, sibling bean invisibility, separate migration histories, exactly one conventional local transaction manager per product, unqualified transactional rollback, product-local writes, scheduler/async ownership, health detail, eager failure, and both Hikari pools closing with the root process.
DatevAdminIntegrationTest exercises real HTTP through the root security filter and Clarula sibling servlet, including firm lifecycle, DATEV callback session state, action tokens, encrypted token persistence, import, confirmation, firm scoping, concurrent refresh, and disconnect.
Compile-time dependency verification and forbidden-source-import checks enforce app → core/eer/clarula and eer/clarula → core, with no product-to-product or product-to-:app imports.
Visual Regression Testing
Playwright screenshot tests run entirely in Docker for pixel-identical rendering across developer machines and CI.
The DevDataSeeder creates deterministic EER data in the local profile so screenshots produce stable baselines. The Clarula DATEV visual fixture creates its independent firm through /admin/clarula/firms; it no longer discovers an EER partner row.
PDF.js iframes are masked because they render non-deterministically.
Test Fixtures
EER test utilities live in eer/src/testFixtures; composition-specific two-database fixtures remain in app/src/integrationTest so product tests do not depend on the BootJar project.
Deployment Database Contract
deployment/tests/product-database-isolation-test.sh starts the same PostgreSQL provisioning job used by Compose, simulates historical EER-owned objects, and verifies that the eer role can migrate them after ownership transfer.
It then proves both positive and negative access: each product role connects to its own logical database and PostgreSQL refuses both sibling-database connections.
Compose model validation and runtime-environment rendering tests require the separate Clarula credential in every hosted environment.
Configuration
Profile Strategy
| Profile | Purpose |
|---|---|
|
Root server/security/health defaults plus product-local persistence and provider defaults |
|
Developer machine: permissive CORS, SMTP email via Mailpit, |
|
Deployed application: restrictive CORS, Mailgun email, secure cookies, strict rate limits |
Typed Configuration
Grouped configuration uses @ConfigurationProperties data classes with startup validation.
A few isolated legacy URL and email values still use @Value; new grouped settings do not extend that pattern.
The EER sibling scans EER property classes; the compile-time-independent Clarula sibling explicitly enables only ClarulaDataSourceProperties and DatevProperties.
The root scans only composition configuration and imports product-neutral :core configuration for shared stateless services and operator JWT parsing.
Root runtime-defaults.yml owns server, management, operator, and release settings. The composition imports it together with eer-defaults.yml and clarula-defaults.yml from the owning product modules; the EER test bootstrap uses the same import manifest without requiring the app or Clarula modules.
This pattern provides type safety, IDE autocompletion, and startup-time validation — a misconfigured property fails the application on boot, not at runtime.
Browser Origins
eer.product.origin is the product/customer origin used for magic links, DOI confirmations, portal registration links, plan links, and trial-email preference links.
It must be an absolute HTTP(S) origin without a path, query, or fragment.
INVOICEX_PRODUCT_ORIGIN configures it and falls back to the existing INVOICEX_WEBSITE_URL during deployment migration.
backend.origin and eer.base-url retain the stable INVOICEX_BASE_URL product-origin input for EER correction/diagnosis links, partner-affiliation confirmations, and the default widget embed URL. OPERATOR_ORIGIN configures operator links, cookies, JSON APIs, and mock authorization on the dedicated operator origin: exactly https://admin.clarula.de in production, https://admin.dev.clarula.de in Dev, and http://admin.localhost locally. Hosted rendering rejects a missing, cross-environment, or noncanonical operator origin. Product origins no longer forward /admin or /api/operator paths.
Partner redirect origins retain their own configuration. The former app host is never emitted by current application configuration and remains only as bounded compatibility for already-installed widgets.
CLARULA_PARTNER_DEMO_ALLOWED_ORIGIN and CLARULA_PARTNER_DEMO_REDIRECT_BASE_URL supply historical placeholder names required by immutable migration V115; migration V120 removes that retired demo partner again. They can be removed only after a tested B120-or-later Flyway baseline initializes new databases and every supported database and restorable backup has already passed V115.
The product-local eer.cors.allowed-origins binding retains the deployed INVOICEX_CORS_ALLOWED_ORIGINS environment name; the internal prefix rename does not change the runtime contract.
Static Frontend Release Identity
Each Nuxt build resolves one immutable release identifier at build time.
FRONTEND_RELEASE_ID is the preferred input and should be the immutable deployed artifact ID or full source revision.
GIT_SHA remains a compatible fallback for build environments that already provide it.
Configured values must identify an immutable artifact and use 7 to 128 ASCII letters, digits, dots, underscores, or hyphens; unknown, blank values, and unsafe characters fail the build. Deployment configuration must not supply a mutable branch or ref name.
When neither variable exists, the build derives source-<sha256> deterministically from the product app, shared frontend packages, generation scripts, and workspace manifests.
This content digest keeps local and test generation reproducible and prevents an unidentified production artifact.
The resolved value is compiled into every HTML document and /.well-known/frontend-release.json; it is not runtime configuration and cannot change after artifact generation.
The static metadata path and Nuxt’s /_nuxt/builds/latest.json are release pointers rather than immutable assets.
Each product’s Nginx configuration returns Cache-Control: no-store for both while retaining long-lived immutable caching for content-addressed Nuxt assets.
Secrets Management
Environment variables inject secrets at deployment time. No secrets appear in configuration files or version control.
Key secrets: JWT_SIGNING_KEY, MISTRAL_API_KEY, MAILGUN_API_KEY, MAILGUN_WEBHOOK_SIGNING_KEY, EER_DB_PASSWORD, CLARULA_DB_PASSWORD, DATEV_CLIENT_SECRET, and DATEV_TOKEN_ENCRYPTION_PASSWORD.
Secret handling differs by environment today:
-
Local development uses the git-ignored
application-local.ymlfor personal overrides such asMISTRAL_API_KEY. -
Dev deployment loads secrets from 1Password at GitHub Actions runtime. The only long-lived bootstrap secret left in the GitHub
devenvironment isOP_SERVICE_ACCOUNT_TOKEN. -
Prod deployment loads secrets from 1Password at GitHub Actions runtime. The only long-lived bootstrap secret left in the GitHub
prodenvironment isOP_SERVICE_ACCOUNT_TOKEN.
Both hosted vaults provide distinct product-role passwords at eer-postgres/password and clarula-postgres/password; deployment renders them as EER_DB_PASSWORD/DATABASE_PASSWORD and CLARULA_DB_PASSWORD without exposing either to the PostgreSQL container’s long-running environment.
Product Database Configuration
The EER sibling binds spring.datasource. from the stable DATABASE_URL, DATABASE_USER, and DATABASE_PASSWORD variables and creates a Hikari pool named eer.
Deployed configuration uses the non-superuser eer role and keeps the compatible invoicex logical database name.
The Clarula sibling independently binds clarula.datasource. from CLARULA_DB_URL, CLARULA_DB_USER, and CLARULA_DB_PASSWORD and creates a Hikari pool named clarula.
The production profile has no default values for either Clarula URL or credential, so a missing secret fails startup.
The root and shared-services bridge bind neither product DataSource and expose no Flyway, entity manager, or transaction manager.
CLARULA_DB_MAXIMUM_POOL_SIZE and CLARULA_DB_MINIMUM_IDLE tune only the Clarula pool.
The default maximum is 10 and the default minimum idle count is zero.
DATEV Adapter Configuration
clarula.datev.environment selects exactly one implementation mode: mock, sandbox, or production.
This is not a feature flag; the admin capability is always available.
mock is deterministic and requires no DATEV credentials.
The two external modes share one HTTP adapter and select environment-specific OIDC and master-data:master-clients endpoints.
They fail startup when client credentials are missing or when the non-default token-encryption password is shorter than 32 characters or its hexadecimal salt is shorter than 16 bytes.
Hosted workflows select the mode with the environment-specific DATEV_ENVIRONMENT variable and resolve configured *_OP_REF field references from 1Password only for external modes.
DATEV_REDIRECT_URI must match the DATEV app registration exactly. Hosted deployment derives it from the validated OPERATOR_ORIGIN plus /api/operator/v1/clarula/datev/callback; a separately supplied conflicting value is rejected. Production therefore uses https://admin.clarula.de/api/operator/v1/clarula/datev/callback, and Dev sandbox uses https://admin.dev.clarula.de/api/operator/v1/clarula/datev/callback.
Sandbox and production use separate client credentials and encryption material.
The adapter mode is runtime configuration and is not persisted with connections or imports. Changing DATEV_ENVIRONMENT requires revoking real provider grants, stopping the backend, and recreating the complete Clarula database first; application code does not support or clean up mixed-mode state.
Admin Configuration
The AdminProperties class (operator.admin.emails) defines which email addresses may access the admin area.
This is the single source of truth for admin identity — no database table, no role column on Account.
operator:
admin:
emails:
- founder1@example.com
- founder2@example.com
The stable INVOICEX_ADMIN_EMAILS environment name populates operator.admin.emails. Operator authentication and both products' operator controllers are always registered; the allow-list determines who can authenticate.
Hosted deployment requires the environment-owned INVOICEX_ADMIN_EMAILS GitHub Environment variable, validates it as a nonempty comma-separated list, writes it only to the mode-0600 runtime environment file, and passes it explicitly to the backend. The renderer and smoke suite never print the addresses. Adding or removing an admin requires a config change and restart.
This is intentional friction for a team of three (see ADR-13).
LLM Integration
Architecture
All productive PDF-to-ZUGFeRD callers depend on invoice.conversion.PdfToZugferdConversionService.
Email processing, free website upload, and widget upload traverse the same target-neutral analysis pipeline before the ZUGFeRD adapter runs.
ADR-18 defines the specialized extraction strategy; ADR-34 defines the semantic handoff.
The implemented stages are:
-
Native source evidence —
XbergPdfStructuredTextExtractorproducesSourceEvidencefor supported native-text PDFs: SHA-256 and byte size, complete page text, bounded analysis text, and truncation metadata. Scanned or image-only PDFs fail closed per ADR-17. -
Specialized three-stage LLM interpretation —
ParallelInvoiceExtractorcoordinates six Mistral Structured Outputs calls:-
DocumentHeaderExtractor— invoice number, document code, dates, currency, and references. -
PartiesExtractor— seller, buyer, and special parties with identity, address, tax, and contact values. -
LineItemsExtractor— semantic invoice lines, including billed quantity, unit, VAT-exclusive prices and amounts, VAT, adjustments, and context-dependent line inclusion. -
PaymentTermsExtractor— payment means, account details, references, mandate values, and settlement wording. -
TotalsAndTaxExtractor— one coherent semantic totals model and VAT breakdown, using semantic line items as context. -
NotesExtractor— source free text not represented by the five prior slices. Header, parties, line items, and payment terms run concurrently viaasyncandawaitAll. Totals and tax then runs serially, followed by notes. A stage-1 failure cancels siblings and skips later stages.
-
-
Evidence-bound semantic mapping — the extractor returns
RawExtractedInvoice.ExtractedInvoiceObservationMappermaps it once toObservedInvoice, creating stable semantic element IDs, application enums, section provenance, and value fingerprints. It does not reinterpret PDF layout or create alternative monetary values. -
Application draft and common validation —
ObservedInvoice.toDraft()retains the semantic interpretation unchanged. Common validation checks required semantics, unresolved ambiguity, and deterministic monetary relationships.DocumentAnalysisResultdistinguishes invoice candidates, non-invoices, unreadable documents, rejected sources, and processing failures. -
Target adapter — commonly valid semantics reach the ZUGFeRD EN16931 adapter. It performs target readiness, Mustang mapping, CII rendering, comparison with the semantic invoice values, and XSD/Schematron validation. PDF embedding starts only after these gates pass and only when the PDF still matches
SourceEvidence.
The runtime sequence lives in Invoice Extraction Algorithm.
Native PDF Evidence
Xberg 1.0.0-rc.29 produces two no-OCR renderings per page. A plain-text rendering comes first so labels and address blocks omitted by layout detection remain visible; layout-aware Markdown follows so table row and column associations remain available. The application labels both as renderings of the same page, and prompts must not duplicate facts or invoice lines that occur in both. Xberg’s separate reading-order post-processing option stays disabled because it did not change Markdown output across the 34-PDF benchmark corpus.
The RT-DETR and TATR layout models are revision- and checksum-pinned in both immutable backend base images, and XBERG_CACHE_DIR points Xberg to those preloaded files.
CI runs a Coolblue extraction in the final backend image and reruns the native extraction regressions in the builder image, both without network access.
This prevents request-time model downloads while preserving native-text-only, no-OCR behavior.
Design Decisions
- LLM-owned semantic interpretation
-
ADR-18 uses six focused extractors. Context-dependent decisions such as line recognition, implicit quantity, price kind, included rows, gross-to-net interpretation, and settlement meaning belong in prompts. Kotlin does not reproduce those decisions with layout heuristics.
- Raw DTO as a mandatory boundary
-
RawExtractedInvoiceprevents provider DTOs from becoming application or target state. Mapping toObservedInvoicebinds the semantic values to immutable evidence before workflows consume them. - One semantic value per concept
-
Line items return
unitPrice,preDiscountUnitPrice, andlineTotalAmount; totals return BT-106 through BT-115 semantics directly. There are no parallel source, net, gross, or calculated variants. The original wording remains inSourceEvidence, not in duplicate invoice fields. - Prompt-side interpretation with format-neutral mapping
-
Prompts request normalized dates, country codes, decimals, recognized document and tax categories, and complete line and total semantics. The observation mapper translates extractor codes into application enums. ZUGFeRD-specific defaults are not introduced into the shared core.
- PDF as immutable evidence
-
Complete page text and source identity remain immutable. A value occurrence somewhere in the PDF is insufficient for
EXACT_FRAGMENT; exact provenance requires reliable field offsets from the extractor. Current semantic claims are document-scoped andINTERPRETED. User recognition corrections retain the original claim and add a source-bound, value-fingerprinted revision event. - No Kotlin unit-price inference
-
Deterministic code never creates an extraction-time unit price through
lineAmount / (quantity ?: 1). Amount-only services, credits, explicit quantities, included rows, and VAT-inclusive prices require contextual prompt interpretation. If the meaning remains unresolved, the semantic field is null and validation fails closed. - No silent total reconciliation
-
The application does not replace contradictory invoice totals with sums calculated from lines. Deterministic code may check arithmetic and compare rendered target values with the semantic model. Contradictions route to diagnosis, source correction, or firm review.
- Immutable analysis gates
-
Facts derivable from
SourceEvidence, such as analysis-text truncation, are reconstructed on initial conversion, correction preview, submission, and completed artifact regeneration. A user edit cannot clear a source-analysis risk. - Typed non-invoice and infrastructure handling
-
An explicit extractor marker or semantically empty extraction can yield
NonInvoice. Provider failures becomeInvoiceExtractionExceptionwith retryability. Artifact-generation failures remain technical failures; they are not routed to an editor that cannot repair them. - Provider-aware outbound rate limiting
-
All six calls share the Mistral budget through
infrastructure.ratelimit.RateLimitedClient. The wrapper paces requests, queues callers instead of rejecting normal load, and performs the single reset-aware retry on HTTP 429 usingRetry-After. Mistral and Mailgun have separate service budgets. - Provider-agnostic extractor interface
-
Spring AI
ChatClientand Structured Outputs isolate extractor code from the configured model provider. A provider change still requires per-extractor schema and benchmark verification. - Target-owned Mustang use
-
Mustang is used only after common validation inside
formats.zugferd. The adapter mapsValidatedInvoice, renders CII XML, compares rendered monetary values with the semantic model, and validates target conformance. XML parsers remain for target-output presentation and characterized output tests, not for correction state or a shared semantic handoff. - Mock extraction for development
-
A mock
ParallelInvoiceExtractorreturns a deterministic Reifen-style semantic invoice in local and visual-test profiles. Its line values and totals are internally coherent so readiness and rendered-value checks exercise the production boundary.
Invoice Semantic Interpretation and Validation
ADR-34 separates immutable source evidence, LLM-owned semantic interpretation, application-owned invoice state, deterministic validation, and target adaptation. The application does not keep parallel source, net, gross, or calculated variants of the same invoice field.
Active Boundary
The active sequence is:
-
XbergPdfStructuredTextExtractorcreates immutableSourceEvidencefrom the PDF. -
ParallelInvoiceExtractorreturns one semanticRawExtractedInvoiceassembled from six specialized Structured Outputs calls. -
ExtractedInvoiceObservationMappermaps the provider DTO and evidence toObservedInvoicewithout reinterpreting layout semantics. -
ObservedInvoice.toDraft()creates revision zero with the same semantic values. -
Common validation checks required values, ambiguity, and deterministic monetary relationships.
-
Target adapters apply target-specific readiness, mapping, rendering, and conformance checks without mutating the draft.
Architecture tests prohibit direct DTO-to-target mapping and keep provider, target, UI, and persistence types outside the invoice model.
Semantic Interpretation Ownership
The LLM extractors interpret the complete invoice context.
This includes deciding which blocks are invoice lines, whether a price is VAT-exclusive or VAT-inclusive, the meaning of amount-only rows, implicit quantity and unit, credit-note signs, included transparency rows, VAT categories, settlement state, and document totals.
The line extractor returns VAT-exclusive unitPrice, VAT-exclusive preDiscountUnitPrice, and lineTotalAmount as invoice semantics.
The totals extractor returns one coherent set of EN 16931 totals and tax buckets.
The provider contract and application model contain one value per business concept:
-
InvoiceLine.quantityandunitCoderepresent the billed quantity and semantic unit. -
InvoiceLinePrice.netUnitPricerepresents BT-146. -
InvoiceLinePrice.preDiscountNetUnitPriceanddiscountAmountrepresent BT-148 and BT-147. -
InvoiceLine.netAmountrepresents BT-131. -
InvoiceTotalsrepresents BT-106 through BT-115 directly.
The raw PDF wording remains available only through SourceEvidence.
Without reliable extractor-provided offsets, claims use document-scoped evidence and INTERPRETED provenance; substring matches never become EXACT_FRAGMENT evidence.
Unknown or contradictory semantics remain null, UNCLASSIFIED, or explicit findings rather than parallel candidate values.
Deterministic Code Boundary
Kotlin code may:
-
map provider fields to application types;
-
assign stable UUIDs to repeated semantic elements;
-
calculate fingerprints and correction provenance;
-
verify exact relationships such as pre-discount price minus discount equals net unit price;
-
check totals and rendered target values;
-
map validated semantics to a target format.
Kotlin code must not:
-
decide table or layout meaning;
-
infer an extraction-time unit price from line amount and quantity;
-
keep separate VAT-inclusive and VAT-exclusive variants of one semantic price;
-
replace contradictory invoice totals with calculated totals;
-
insert target-specific defaults into the EER semantic model.
Gross-to-net conversion is therefore prompt-owned semantic interpretation. Deterministic validation may verify the resulting values but does not reconstruct a competing interpretation.
User Revisions
The correction form maps directly to InvoiceDraft, never through generated XML.
-
Nullable semantic facts remain nullable; rendering a form does not turn absence into zero or one.
-
Existing repeated elements retain opaque draft UUIDs. Added elements receive new UUIDs; removals are recorded as semantic field changes.
-
When a user changes a price basis and leaves the line amount untouched, the form mapper may recalculate that line amount from the explicit corrected inputs. This is deterministic correction arithmetic, not source interpretation.
-
Every changed semantic field receives one source-evidence-bound
InvoiceChangewith revision, timestamp, corrected-value fingerprint, and evidence reference. -
Replaying an unchanged preview creates neither a revision nor a correction event.
Target Adaptation
ZUGFeRD-only requirements belong to formats.zugferd readiness and mapping.
The adapter maps BT-148 and BT-147 as a price-level allowance, maps line and document adjustments, renders CII XML, compares rendered monetary values with the semantic model, and runs XSD/Schematron validation.
A missing, contradictory, or unsupported target fact produces NotReady; it is never silently defaulted or discarded to make XML validate.
Other output adapters must implement their own readiness from the same ValidatedInvoice rather than consuming ZUGFeRD XML.
EN16931 BT Field Coverage Inventory
This document records the current EER support state for every official EN16931 Business Term (BT) from the Factur-X / ZUGFeRD 2.5 EN16931 workbook. It is an IST-state reference, not a branch-local execution sheet.
Scope And Column Definitions
The table uses these columns:
-
BT: official EN16931 business term identifier from the workbook, including scheme-related subfields such asBT-29-1. -
Business Term: workbook label. -
Card.: official cardinality. -
Upload Preview: field is surfaced in the current free-upload / widget preview response (InvoicePreviewFieldsMapper). -
LLM Extraction: field is part of the structured parallel extraction contract (ExtractedInvoiceplus JSON schemas). -
Expert UI: field is surfaced somewhere in the expert correction UI. This includes intentionally read-only fields; seeNotewhere that distinction matters. -
UI Gap: current expert-UI coverage rating.No= current UI coverage is sufficient for the present product scope,Partial= only partly surfaced or truthfully handled,Yes= still missing. -
Verification Report: field is rendered in the current verification report PDF/HTML. -
Generated XML: field is emitted by the current XML generation paths.Derivedmeans Mustang computes the value from other source fields instead of taking it from a dedicated BT input. -
Note: caveats such as read-only behavior, first-item-only handling, draft-backed round trips, target-adapter limitations, or intentional exclusions.
Status values:
-
Yes: supported in the current stage. -
Partial: only partly supported, ambiguous, defaulted, repeated-item-limited, or only supported in one path. -
No: not supported in the current stage. -
Derived: not sourced directly; computed from other supported fields.
Method sources:
-
Official BT list:
docs/research/ZF25_EN/Schema/3_Factur-X_1.09_EN16931/Factur-X_1.09_EN16931.xlsx -
LLM contract:
eer/src/main/kotlin/*/ExtractedInvoice.ktandeer/src/main/resources/prompts/parallel/schemas/.schema.json -
Application invoice model:
eer/src/main/kotlin/**/invoice/conversion/model/InvoiceModel.kt -
Extraction handoff:
eer/src/main/kotlin/**/invoice/conversion/analysis/pdf/llm/ExtractedInvoiceObservationMapper.kt -
ZUGFeRD target adapter:
eer/src/main/kotlin/**/formats/zugferd/generation/ZugferdEn16931Adapter.kt -
Draft correction mapper:
eer/src/main/kotlin/**/invoice/editing/ui/form/InvoiceDraftFormMapper.kt -
Edit UI:
applications/frontend/apps/einfache-erechnung/app/components/correction/** -
Verification report:
InvoiceTemplateMapper.ktandtemplates/pdf/verification-report.html
|
|
| BT | Business Term | Card. | Upload Preview | LLM Extraction | Expert UI | UI Gap | Verification Report | Generated XML | Note |
|---|---|---|---|---|---|---|---|---|---|
BT-1 |
Invoice number |
1..1 |
Yes |
Yes |
Yes |
No |
Yes |
Yes |
|
BT-2 |
Invoice issue date |
1..1 |
Yes |
Yes |
Yes |
No |
Yes |
Yes |
|
BT-3 |
Invoice type code |
1..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-5 |
Invoice currency code |
1..1 |
No |
Yes |
Yes |
No |
Partial |
Yes |
Verification report hardcodes currency to EUR. |
BT-6 |
VAT accounting currency code |
0..1 |
No |
No |
Yes |
No |
No |
No |
Expert UI now keeps this field visible as read-only. No current extractor or writer currently handles VAT accounting currency. |
BT-7 |
Value added tax point date |
0..1 |
No |
No |
Yes |
No |
No |
No |
Expert UI now keeps this field visible as read-only. No current extractor or writer currently handles VAT point dates. |
BT-8 |
Value added tax point date code |
0..1 |
No |
No |
Yes |
No |
No |
No |
Expert UI now keeps this field visible as read-only. No current extractor or writer currently handles VAT point date codes. |
BT-9 |
Payment due date |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
|
BT-10 |
Buyer reference |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-11 |
Project reference |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
The ZUGFeRD adapter maps BT-11 as the project ID and repeats it as the mandatory CII technical project name. |
BT-12 |
Contract reference |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
Initial conversion and draft correction both preserve contract references. |
BT-13 |
Purchase order reference |
0..1 |
No |
Partial |
Yes |
No |
No |
Partial |
LLM schema keeps seller vs buyer order references, but exact BT-13 vs BT-14 classification depends on invoice labeling.; Current writers support purchase-order style references, but BT-13 vs BT-14 classification still depends on source labeling. |
BT-14 |
Sales order reference |
0..1 |
No |
Partial |
Yes |
No |
No |
Yes |
LLM schema keeps seller vs buyer order references, but exact BT-13 vs BT-14 classification still depends on invoice labeling.; Both XML writer paths now persist seller order references. |
BT-15 |
Receiving advice reference |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
The ZUGFeRD adapter augments Mustang output with the EN 16931 CII receiving-advice reference and validates the result. |
BT-16 |
Despatch advice reference |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-17 |
Tender or lot reference |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-18 |
Invoiced object identifier |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-18-1 |
Scheme identifier |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-19 |
Buyer accounting reference |
0..1 |
No |
No |
Yes |
No |
No |
No |
Editor exposes buyer accounting reference, but Mustang 2.23.0 exposes no header-level buyer-accounting-reference write path, so current XML writers cannot persist it truthfully. |
BT-20 |
Payment terms |
0..1 |
No |
Yes |
Yes |
No |
Partial |
Yes |
Verification report hardcodes payment terms to a default sentence.; Edit-save now has explicit emitted-XML and parse-back verification for payment terms in the direct-debit settlement path. |
BT-21 |
Invoice note subject code |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
Expert UI still surfaces only the first note subject code, but the verification report now renders every document note as its own repeated group. |
BT-22 |
Invoice note |
1..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
Initial conversion and draft correction both preserve invoice notes. The verification report renders every document note as its own repeated group. |
BT-23 |
Business process type |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-24 |
Specification identifier |
1..1 |
No |
Yes |
Yes |
No |
No |
No |
Expert UI surfaces this target-context field read-only when available. Current target writers still do not emit an editable value. |
BT-25 |
Preceding Invoice reference |
1..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
|
BT-26 |
Preceding Invoice issue date |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
|
BT-27 |
Seller name |
1..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
|
BT-28 |
Seller trading name |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-29 |
Seller identifier |
0..n |
No |
Partial |
Yes |
No |
Partial |
Partial |
LLM contract, expert UI, and verification report currently surface only one scheme-backed seller identifier. Additional seller identifiers are still outside the current contract and XML generation remains partial overall. |
BT-29-1 |
Seller identifier identification scheme identifier |
0..1 |
No |
Partial |
Yes |
No |
Partial |
Partial |
LLM contract, expert UI, and verification report currently surface only one scheme-backed seller identifier. Additional seller identifiers are still outside the current contract and XML generation remains partial overall. |
BT-30 |
Seller legal registration identifier |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-30-1 |
Scheme identifier |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-31 |
Seller VAT identifier |
0..1 |
Partial |
Yes |
Yes |
No |
Yes |
Yes |
Upload preview exposes one seller tax identifier field and does not distinguish VAT vs tax number. |
BT-32 |
Seller tax registration identifier |
0..1 |
Partial |
Yes |
Yes |
No |
Yes |
Yes |
Upload preview exposes one seller tax identifier field and does not distinguish VAT vs tax number. |
BT-33 |
Seller additional legal information |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-34 |
Seller electronic address |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-34-1 |
Scheme identifier |
1..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-35 |
Seller address line 1 |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
|
BT-36 |
Seller address line 2 |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-37 |
Seller city |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
|
BT-38 |
Seller post code |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
|
BT-39 |
Seller country subdivision |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
The ZUGFeRD adapter augments the seller postal address with |
BT-40 |
Seller country code |
1..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
|
BT-41 |
Seller contact point |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-42 |
Seller contact telephone number |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-43 |
Seller contact email address |
0..1 |
No |
Yes |
Yes |
No |
Partial |
Yes |
Verification report renders the party email field, not a dedicated contact email field. |
BT-44 |
Buyer name |
1..1 |
Yes |
Yes |
Yes |
No |
Yes |
Yes |
|
BT-45 |
Buyer trading name |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-46 |
Buyer identifier |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-46-1 |
Scheme identifier |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-47 |
Buyer legal registration identifier |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-47-1 |
Scheme identifier |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-48 |
Buyer VAT identifier |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
|
BT-49 |
Buyer electronic address |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-49-1 |
Scheme identifier |
1..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-50 |
Buyer address line 1 |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
|
BT-51 |
Buyer address line 2 |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-52 |
Buyer city |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
|
BT-53 |
Buyer post code |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
|
BT-54 |
Buyer country subdivision |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
The ZUGFeRD adapter augments the buyer postal address with |
BT-55 |
Buyer country code |
1..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
|
BT-56 |
Buyer contact point |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-57 |
Buyer contact telephone number |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-58 |
Buyer contact email address |
0..1 |
No |
Yes |
Yes |
No |
Partial |
Yes |
Verification report renders the party email field, not a dedicated contact email field. |
BT-59 |
Payee name |
1..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-60 |
Payee identifier |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-60-1 |
Scheme identifier |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-61 |
Payee legal registration identifier |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
Expert UI surfaces this field from the application draft. Draft round trips preserve the identifier before target generation. |
BT-61-1 |
Scheme identifier |
0..1 |
No |
Yes |
Yes |
No |
No |
Partial |
Expert UI surfaces this field from the application draft. The target adapter intentionally omits the scheme because Mustang-generated |
BT-62 |
Seller tax representative name |
1..1 |
No |
Yes |
Yes |
No |
No |
Yes |
The ZUGFeRD adapter maps the application-owned tax-representative group after Mustang rendering and validates the resulting XML. |
BT-63 |
Seller tax representative VAT identifier |
1..1 |
No |
Yes |
Yes |
No |
No |
Yes |
The ZUGFeRD adapter maps the VAT identifier in the application-owned tax-representative group. |
BT-64 |
Tax representative address line 1 |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
The ZUGFeRD adapter maps the tax-representative postal address. |
BT-65 |
Tax representative address line 2 |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
The ZUGFeRD adapter maps the tax-representative postal address. |
BT-66 |
Tax representative city |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
The ZUGFeRD adapter maps the tax-representative postal address. |
BT-67 |
Tax representative post code |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
The ZUGFeRD adapter maps the tax-representative postal address. |
BT-68 |
Tax representative country subdivision |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
The ZUGFeRD adapter maps the tax-representative postal address. |
BT-69 |
Tax representative country code |
1..1 |
No |
Yes |
Yes |
No |
No |
Yes |
The ZUGFeRD adapter requires and maps the tax-representative country code. |
BT-70 |
Deliver to party name |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-71 |
Deliver to location identifier |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-71-1 |
Scheme identifier |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-72 |
Actual delivery date |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
If XML validation reports that no explicit delivery/service date signal survived as BT-72, invoice period, or line period, the current conversion path may fall back to the invoice issue date. |
BT-73 |
Invoicing period start date |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
Initial conversion and draft correction both preserve invoice period start dates. |
BT-74 |
Invoicing period end date |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
Initial conversion and draft correction both preserve invoice period end dates. |
BT-75 |
Deliver to address line 1 |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-76 |
Deliver to address line 2 |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-77 |
Deliver to city |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-78 |
Deliver to post code |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-79 |
Deliver to country subdivision |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
The ZUGFeRD adapter augments the ship-to postal address with |
BT-80 |
Deliver to country code |
1..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-81 |
Payment means type code |
1..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-82 |
Payment means text |
0..1 |
No |
Yes |
Yes |
No |
No |
Partial |
The application draft preserves BT-82 absence without injecting default text and carries explicit values to the target adapter. Characterized parity-writer behavior keeps overall target coverage partial. |
BT-83 |
Remittance information |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
Initial conversion and draft correction both preserve payment reference / remittance information. |
BT-84 |
Payment account identifier |
1..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
|
BT-85 |
Payment account name |
0..1 |
No |
Yes |
Yes |
No |
Partial |
Yes |
Verification report uses seller name as account name instead of the dedicated payment account name field. |
BT-86 |
Payment service provider identifier |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
|
BT-87 |
Payment card primary account number |
1..1 |
No |
Yes |
Yes |
No |
No |
Yes |
The ZUGFeRD adapter emits only the visible PAN digits and fails closed if more than the PCI-safe first six plus last four digits are exposed. |
BT-88 |
Payment card holder name |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
The ZUGFeRD adapter maps an explicit card-holder name together with BT-87. |
BT-89 |
Mandate reference identifier |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
Edit-save now has explicit emitted-XML and parse-back verification for direct-debit mandate references. |
BT-90 |
Bank assigned creditor identifier |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
Edit-save now has explicit emitted-XML and parse-back verification for direct-debit creditor references. |
BT-91 |
Debited account identifier |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-92 |
Document level allowance amount |
1..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
Initial conversion and draft correction both preserve document allowance amounts. The verification report renders each document allowance as its own repeated group. |
BT-93 |
Document level allowance base amount |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
The semantic LLM contract and draft preserve BT-93; the ZUGFeRD adapter maps it to |
BT-94 |
Document level allowance percentage |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
The semantic LLM contract and draft preserve BT-94; the ZUGFeRD adapter maps it to |
BT-95 |
Document level allowance VAT category code |
1..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
Initial conversion and draft correction both preserve document allowance VAT categories. The verification report renders each document allowance as its own repeated group. |
BT-96 |
Document level allowance VAT rate |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
Initial conversion and draft correction both preserve document allowance VAT rates. The verification report renders each document allowance as its own repeated group. |
BT-97 |
Document level allowance reason |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
Initial conversion and draft correction both preserve document allowance reasons. The verification report renders each document allowance as its own repeated group. |
BT-98 |
Document level allowance reason code |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
The semantic draft preserves explicit reason codes and the ZUGFeRD adapter maps them through Mustang. |
BT-99 |
Document level charge amount |
1..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
Initial conversion and draft correction both preserve document charge amounts. The verification report renders each document charge as its own repeated group. |
BT-100 |
Document level charge base amount |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
The semantic LLM contract and draft preserve BT-100; the ZUGFeRD adapter maps it to |
BT-101 |
Document level charge percentage |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
The semantic LLM contract and draft preserve BT-101; the ZUGFeRD adapter maps it to |
BT-102 |
Document level charge VAT category code |
1..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
Initial conversion and draft correction both preserve document charge VAT categories. The verification report renders each document charge as its own repeated group. |
BT-103 |
Document level charge VAT rate |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
Initial conversion and draft correction both preserve document charge VAT rates. The verification report renders each document charge as its own repeated group. |
BT-104 |
Document level charge reason |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
Initial conversion and draft correction both preserve document charge reasons. The verification report renders each document charge as its own repeated group. |
BT-105 |
Document level charge reason code |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
The semantic draft preserves explicit reason codes and the ZUGFeRD adapter maps them through Mustang. |
BT-106 |
Sum of Invoice line net amount |
1..1 |
No |
Yes |
Yes |
No |
Yes |
Derived |
The LLM returns semantic BT-106. Mustang renders the value from line semantics, and readiness requires the rendered result to match the application model. |
BT-107 |
Sum of allowances on document level |
0..1 |
No |
Yes |
Yes |
No |
Partial |
Derived |
The LLM returns semantic BT-107. Mustang renders it from document allowances, and readiness compares the result with the application model. |
BT-108 |
Sum of charges on document level |
0..1 |
No |
Yes |
Yes |
No |
Partial |
Derived |
The LLM returns semantic BT-108. Mustang renders it from document charges, and readiness compares the result with the application model. |
BT-109 |
Invoice total amount without VAT |
1..1 |
No |
Yes |
Yes |
No |
Yes |
Derived |
The LLM returns semantic BT-109. Mustang renders it from lines and document adjustments, and readiness compares the result with the application model. |
BT-110 |
Invoice total VAT amount |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Derived |
The LLM returns semantic BT-110 and VAT buckets. Mustang renders the target value, and readiness compares it with the application model. |
BT-111 |
Invoice total VAT amount in accounting currency |
0..1 |
No |
No |
Yes |
No |
No |
No |
No current writer populates VAT totals in accounting currency. |
BT-112 |
Invoice total amount with VAT |
1..1 |
No |
Yes |
Yes |
No |
Yes |
Derived |
The LLM returns semantic BT-112. Mustang renders it from tax basis and VAT, and readiness compares the result with the application model. |
BT-113 |
Paid amount |
0..1 |
No |
Yes |
Yes |
No |
Partial |
Yes |
The LLM interprets settlement state and returns semantic BT-113; initial conversion and draft correction preserve it. |
BT-114 |
Rounding amount |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
The LLM returns explicit semantic rounding and the target adapter maps it without deriving a replacement. |
BT-115 |
Amount due for payment |
1..1 |
Partial |
Yes |
Yes |
No |
Yes |
Derived |
The LLM returns semantic BT-115. Mustang renders the payable amount from the semantic totals, and readiness compares the result with the application model. |
BT-116 |
VAT category taxable amount |
1..1 |
No |
Yes |
Yes |
No |
Yes |
Derived |
The LLM returns semantic VAT buckets. Mustang renders the target bucket, and readiness compares it with the application model. |
BT-117 |
VAT category tax amount |
1..1 |
No |
Yes |
Yes |
No |
Yes |
Derived |
The LLM returns semantic VAT buckets. Mustang renders the target bucket, and readiness compares it with the application model. |
BT-118 |
VAT category code |
1..1 |
No |
Yes |
Yes |
No |
Yes |
Derived |
The LLM returns semantic VAT categories; the target adapter maps them and verifies the rendered buckets. |
BT-119 |
VAT category rate |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Derived |
The LLM returns semantic VAT rates; the target adapter maps them and verifies the rendered buckets. |
BT-120 |
VAT exemption reason text |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
Initial XML writer now mirrors tax-breakdown exemption reasons onto matching exempt line items so Mustang emits BT-120 in generated XML. The verification report renders each tax breakdown as its own repeated group. |
BT-121 |
VAT exemption reason code |
0..1 |
No |
Yes |
Yes |
No |
Yes |
No |
Expert UI now keeps this field visible as read-only. Mustang 2.23.0 does not emit header-level |
BT-122 |
Supporting document reference |
1..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
The ZUGFeRD adapter maps supporting-document metadata as CII type 916 references. The verification report renders each document as its own repeated group. |
BT-123 |
Supporting document description |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
The ZUGFeRD adapter maps the supporting-document description. The verification report renders each document as its own repeated group. |
BT-124 |
External document location |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
The ZUGFeRD adapter maps an explicit external location as |
BT-125 |
Attached document |
0..1 |
No |
No |
Yes |
No |
Yes |
No |
Expert UI surfaces attachment-backed entries read-only from the application draft. Attachment payload editing and target generation remain intentionally out of scope. The verification report renders each attachment as its own repeated group. |
BT-125-1 |
Attached document Mime code |
1..1 |
No |
No |
Yes |
No |
Yes |
No |
Expert UI surfaces attachment MIME types read-only from the application draft. Attachment payload editing and target generation remain intentionally out of scope. The verification report renders each attachment as its own repeated group. |
BT-125-2 |
Attached document Filename |
1..1 |
No |
No |
Yes |
No |
Yes |
No |
Expert UI surfaces attachment filenames read-only from the application draft. Attachment payload editing and target generation remain intentionally out of scope. The verification report renders each attachment as its own repeated group. |
BT-126 |
Invoice line identifier |
1..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
Initial conversion and draft correction both preserve explicit line IDs through stable application element identifiers. |
BT-127 |
Invoice line note |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
Edit-save now roundtrips the first line-level |
BT-128 |
Invoice line object identifier |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-128-1 |
Invoice line object identifier scheme identifier |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-129 |
Invoiced quantity |
1..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
|
BT-130 |
Invoiced quantity unit of measure |
1..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
|
BT-131 |
Invoice line net amount |
1..1 |
No |
Yes |
Yes |
No |
Partial |
Derived |
The LLM returns semantic BT-131 directly. Mustang renders it from the line semantics, and readiness requires the rendered amount to match the application model. |
BT-132 |
Referenced purchase order line reference |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-133 |
Invoice line Buyer accounting reference |
0..1 |
No |
Yes |
Yes |
No |
No |
No |
Expert UI now keeps this field visible as read-only. Mustang 2.23.0 exposes no public setter, so current writer paths still cannot emit it truthfully. |
BT-134 |
Invoice line period start date |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-135 |
Invoice line period end date |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-136 |
Invoice line allowance amount |
1..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
The verification report renders each line-level allowance as its own nested repeated group. |
BT-137 |
Invoice line allowance base amount |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
The semantic LLM contract and draft preserve BT-137; the ZUGFeRD adapter maps it to |
BT-138 |
Invoice line allowance percentage |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
The semantic LLM contract and draft preserve BT-138; the ZUGFeRD adapter maps it to |
BT-139 |
Invoice line allowance reason |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
The verification report renders each line-level allowance as its own nested repeated group. |
BT-140 |
Invoice line allowance reason code |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
Expert UI surfaces this field and draft-backed target generation preserves it. The verification report renders each line-level allowance as its own nested repeated group. |
BT-141 |
Invoice line charge amount |
1..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
The verification report renders each line-level charge as its own nested repeated group. |
BT-142 |
Invoice line charge base amount |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
The semantic LLM contract and draft preserve BT-142; the ZUGFeRD adapter maps it to |
BT-143 |
Invoice line charge percentage |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
The semantic LLM contract and draft preserve BT-143; the ZUGFeRD adapter maps it to |
BT-144 |
Invoice line charge reason |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
The verification report renders each line-level charge as its own nested repeated group. |
BT-145 |
Invoice line charge reason code |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
Expert UI surfaces this field and draft-backed target generation preserves it. The verification report renders each line-level charge as its own nested repeated group. |
BT-146 |
Item net price |
1..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
|
BT-147 |
Item price discount |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
The LLM returns semantic BT-147. The adapter maps it as the price-level allowance paired with BT-148 and validates the net-price relationship. |
BT-147-01 |
Indicator for price allowance |
0..1 |
No |
No |
Yes |
No |
No |
Derived |
The target adapter emits the price-allowance indicator when semantic BT-147 and BT-148 are present. |
BT-147-02 |
Indicator for price allowance, value |
1..1 |
No |
No |
Yes |
No |
No |
Derived |
The target adapter derives the indicator value from the semantic price discount. |
BT-148 |
Item gross price |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
BT-148 is the VAT-exclusive price before discount. The adapter emits |
BT-149 |
Item price base quantity |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
The LLM and draft carry semantic price-base quantity; the adapter maps it through Mustang |
BT-149-1 |
Item price base quantity |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
The semantic LLM contract captures price-base quantity and the current adapter emits it. |
BT-150 |
Item price base quantity unit of measure code |
0..1 |
No |
Yes |
Yes |
No |
No |
Partial |
The adapter emits the price-base unit when it equals BT-130; a distinct BT-150 unit fails readiness because Mustang exposes no separate writer API. |
BT-150-1 |
Item price base quantity unit of measure code |
0..1 |
No |
Yes |
Yes |
No |
No |
Partial |
The semantic LLM contract captures a dedicated price-base unit. The current adapter supports it only when it equals BT-130 and otherwise fails closed. |
BT-151 |
Invoiced item VAT category code |
1..1 |
No |
Yes |
Yes |
No |
No |
Yes |
Extraction and draft correction preserve explicit line-level VAT categories, including non-rate-derived categories such as |
BT-152 |
Invoiced item VAT rate |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
|
BT-153 |
Item name |
1..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
|
BT-154 |
Item description |
0..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
|
BT-155 |
Item Seller’s identifier |
0..1 |
No |
Yes |
Yes |
No |
No |
Partial |
The characterized writer emits seller item identifiers without a forbidden schemeID; active target-adapter support remains partial. |
BT-156 |
Item Buyer’s identifier |
0..1 |
No |
Yes |
Yes |
No |
No |
Partial |
The characterized writer emits buyer item identifiers without a forbidden schemeID; active target-adapter support remains partial. |
BT-157 |
Item standard identifier |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-157-1 |
Scheme identifier |
1..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-158 |
Item classification identifier |
0..n |
No |
Partial |
Yes |
Partial |
Partial |
Partial |
LLM contract, expert UI, and verification report currently support only one classification entry; additional classifications are still not surfaced truthfully. |
BT-158-1 |
Scheme identifier |
1..1 |
No |
Partial |
Yes |
Partial |
Partial |
Partial |
LLM contract, expert UI, and verification report currently support only one classification entry; additional classifications are still not surfaced truthfully. |
BT-158-2 |
Scheme version identifer |
0..1 |
No |
Partial |
Yes |
Partial |
Partial |
Partial |
LLM contract, expert UI, and verification report currently support only one classification entry; additional classifications are still not surfaced truthfully. |
BT-159 |
Item country of origin |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-160 |
Item attribute name |
1..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
Expert UI surfaces item attributes read-only and preserves them through draft correction. The verification report renders each item attribute as its own nested repeated group. |
BT-161 |
Item attribute value |
1..1 |
No |
Yes |
Yes |
No |
Yes |
Yes |
Expert UI surfaces item attributes read-only and preserves them through draft correction. The verification report renders each item attribute as its own nested repeated group. |
BT-162 |
Seller address line 3 |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-163 |
Buyer address line 3 |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
|
BT-164 |
Tax representative address line 3 |
0..1 |
No |
Yes |
Yes |
No |
No |
No |
Expert UI surfaces this application-draft field read-only, but current writer paths still cannot emit it truthfully. |
BT-165 |
Deliver to address line 3 |
0..1 |
No |
Yes |
Yes |
No |
No |
Yes |
Reading Guide
-
Expert UIanswers only whether a field is surfaced somewhere in the expert editor at all. UseNoteto distinguish editable fields from intentionally read-only ones. -
Large clusters of
Expert UI = YesplusUI Gap = NoplusGenerated XML = No/Partialmark the main gap between what the editor can show and what the current write-back model can persist truthfully. -
Large clusters of
UI Gap = Yes/Partialmark fields where the expert UI is still missing or only partly exposed even if downstream mapper support may exist. -
Large clusters of
LLM Extraction = YesplusGenerated XML = Partialmark information that the initial conversion pipeline can extract, but at least one XML path still drops or weakens. -
DerivedinGenerated XMLis expected for many totals and tax-breakdown rows because Mustang computes them from line items and document-level adjustments.
Analytics Reports
Reports as Code
All analytics report definitions live in the repository as SQL views, managed via Flyway migrations. Metabase visualizes these views but owns no query logic. This makes reports version-controlled, code-reviewable, and visible to AI agents. See ADR-9: Analytics Reports as Code via SQL Views for the full decision rationale.
Naming Convention
| Prefix | Purpose | Example |
|---|---|---|
|
User-facing report consumed by a Metabase question |
|
|
Reusable building block consumed by other views |
|
Documentation Standard
Every view must include a SQL comment block and a COMMENT ON VIEW statement.
The comment block sits at the top of the migration file.
COMMENT ON VIEW makes the description visible in Metabase’s data reference.
-- Business Question: How many invoices reach each processing stage per day?
-- Source Events: InvoiceReceivedEvent, InvoiceProcessedEvent
-- Grain: One row per event type per day
-- Caveats: Counts exclude test accounts (email LIKE '%@test.%')
CREATE VIEW analytics.report_daily_processing AS
SELECT ...;
COMMENT ON VIEW analytics.report_daily_processing IS
'Daily invoice processing counts by event type. Excludes test accounts.';
Read-Side Boundary
AnalyticsBoundaryArchTest enforces that application code never reads from analytics.*.
Views are exclusively for Metabase consumption.
The write path is unchanged: the business.tracking package writes events via PersistBusinessEvents.
This separation means schema changes to business events force a view migration in the same PR — broken reports surface at build time, not when someone opens a dashboard.
InvoiceProcessedEvent is emitted centrally inside PdfToZugferdConversionService.convert(…) once the shared conversion pipeline returns an accepted outcome (success or validation_failure). The event includes the submission origin (email, website, or widget) and widget partner metadata when present, so cross-channel invoice-volume reports read one event type instead of combining controller-specific upload events. A schema-upgrade reconstruction uses the separate internal reanalysis origin so its additional paid, nondeterministic pipeline run is visible without being misattributed as a new channel submission.
InvoiceProcessingFailedEvent complements that success-path event for conversions that do not reach an accepted outcome. PdfToZugferdConversionService.convert(…) emits it for shared failure reasons such as invalid_pdf, not_an_invoice, and technical_error, again with the applicable submission or reanalysis origin and optional widget partner metadata. Timeout handling stays at the entry-point boundary because the conversion service does not observe wrapper-level timeout exceptions. This lets upload monitoring read one success event type plus one failure event type across all channels and distinguish internal reanalysis.
For widget uploads, the accepted upload event remains anonymous at first. The event therefore carries a stable conversionId, not a guessed user identity. That ID is the existing Conversion.id, created before the widget upload enters PdfToZugferdConversionService.convert(…) and passed through the tracking context. Later widget events reuse that same conversionId once an account is known:
-
WidgetClaimSucceededEventidentifies which account and email claimed the upload and marks that the standard result email was sent. -
WidgetClaimFailedEventcaptures claim or result-email delivery failures such as expired, missing, or already-claimed conversions.
This gives analytics two complementary views:
-
funnel and partner activity from the anonymous upload to result-email delivery
-
attribution reports that join the later known user back to the original upload via
conversionId
Business events in the diagnosis and validation-email flow stay intentionally sparse. They may include rule-code aggregates and counters, but they must not persist recovery tokens, full URLs, raw validator messages, invoice content, invoice numbers, party names, tax IDs, addresses, or bank data inside analytics.business_events.
Adding a New Report
-
Create a Flyway migration in
applications/backend/eer/src/main/resources/db/migration/. Name the viewanalytics.report_<name>(oranalytics.metric_<name>for a building block). -
Add a SQL comment block and
COMMENT ON VIEWto the migration (see Documentation Standard). -
Point a Metabase question to the view:
SELECT * FROM analytics.report_<name>. Metabase owns only the visualization layer (chart type, axes, filters). -
Never write SQL directly in Metabase. If a report needs a change, update the view in a new Flyway migration and review it in a pull request.
Architecture Decisions
This chapter documents the significant architecture decisions for the Clarula monorepo and its products. Each decision shaped the system’s structure, technology choices, or operational approach in ways that a new team member would question without context.
Decisions are recorded as lightweight ADRs (Architecture Decision Records). Product-level decisions with business rationale live in separate Product Decision Records (PDRs).
Decision Overview
| ID | Decision | Status | Date |
|---|---|---|---|
ADR-1 |
Implemented |
2025-Q4 |
|
ADR-2 |
Superseded by ADR-18 |
2025-Q4 |
|
ADR-3 |
Implemented |
2026-02-25 |
|
ADR-4 |
Active |
2025-Q3 |
|
ADR-5 |
Partially superseded by ADR-30 and ADR-31 |
2025-Q3 |
|
ADR-6 |
Active; refined by ADR-30 and ADR-34 |
2025-Q3 |
|
ADR-7 |
Implemented |
2026-03-15 |
|
ADR-8 |
Superseded by ADR-18 |
2026-04-18 |
|
ADR-9 |
Accepted |
2026-04-18 |
|
ADR-10 |
Accepted |
2026-04-18 |
|
ADR-11 |
Accepted |
2026-04-18 |
|
ADR-12 |
Accepted |
2026-04-18 |
|
ADR-13 |
Partially superseded by ADR-33 |
2026-04-18 |
|
ADR-14 |
Accepted |
2026-04-20 |
|
ADR-15 |
Accepted |
2026-04-24 |
|
ADR-16 |
Superseded by ADR-34 |
2026-05-01 |
|
ADR-17 |
Native Document Interpreters with Xberg, PDFBox, and Apache POI |
Accepted; refined by ADR-34 |
2026-05-12 |
ADR-18 |
LLM-Only Semantic Extraction with Specialized Parallel Calls |
Partially superseded by ADR-22 and ADR-34 |
2026-05-16 |
ADR-19 |
Accepted |
2026-05-21 |
|
ADR-20 |
Accepted |
2026-05-22 |
|
ADR-21 |
Accepted |
2026-05-24 |
|
ADR-22 |
Partially superseded by ADR-34 |
2026-05-26 |
|
ADR-23 |
Amended |
2026-07-10 |
|
ADR-24 |
Partially superseded by ADR-25, ADR-26, and ADR-30 |
2026-07-09 |
|
ADR-25 |
Partially superseded by ADR-26 and ADR-33 |
2026-07-12 |
|
ADR-26 |
Partially superseded by ADR-27 and ADR-28 |
2026-07-13 |
|
ADR-27 |
Partially superseded by ADR-28 |
2026-07-13 |
|
ADR-28 |
Partially superseded by ADR-33 |
2026-07-13 |
|
ADR-29 |
Partially superseded by ADR-32 and ADR-33 |
2026-07-14 |
|
ADR-30 |
Partially superseded by ADR-33 |
2026-07-15 |
|
ADR-31 |
Accepted |
2026-07-15 |
|
ADR-32 |
Partially superseded by ADR-33 |
2026-07-15 |
|
ADR-33 |
Accepted |
2026-07-16 |
|
ADR-34 |
Accepted |
2026-07-16 |
ADR-1: Hybrid ZUGFeRD Generation (XML + PDF Attachment)
- Status
-
Implemented (2025-Q4). Later extended with optional Ghostscript-based PDF/A-3 conversion.
Context
ZUGFeRD 2.5 e-invoices embed structured XML inside a PDF/A-3 document.
Mustang Project — the only actively maintained open-source ZUGFeRD library on the JVM — provides ZUGFeRDExporterFromA3 to handle this.
However, Mustang’s PDF/A-3 conversion re-renders the entire PDF, which destroys font ToUnicode mappings in many real-world invoices.
German umlauts (ä, ö, ü) become placeholder characters in the output.
This is not a niche problem. Most small-business invoices use system fonts or embedded fonts whose Unicode mappings do not survive re-rendering.
Decision Drivers
-
Original PDF must remain visually intact (users check the output)
-
ZUGFeRD XML must pass EN 16931 validation
-
No commercial PDF library licenses (budget constraint)
Considered Options
| Option | Pros | Cons |
|---|---|---|
Mustang end-to-end |
Single library handles everything. Strict PDF/A-3 output. |
Re-renders PDFs. Corrupts fonts with German umlauts. Visual output differs from the original. |
Hybrid: Mustang (XML) + PDFBox (attach) |
Preserves original PDF byte-for-byte. No font corruption. Faster (no re-rendering). |
Output is not strict PDF/A-3 until an optional Ghostscript step runs. Two libraries instead of one. |
Commercial PDF library (iText) |
Full PDF/A-3 pipeline with font preservation. |
License cost incompatible with budget. Vendor dependency for a core function. |
Decision
Two-step hybrid approach:
-
Mustang Project generates and validates the EN 16931-compliant XML.
-
PDFBox attaches the XML as an embedded file to the original PDF and sets the required ZUGFeRD metadata entries.
The original PDF passes through unchanged. Ghostscript handles PDF/A-3 color profile conversion as a separate, optional step.
Consequences
- Positive
-
-
Original fonts, layout, and visual appearance remain intact.
-
Processing is faster — no PDF re-rendering.
-
Code is simpler — Mustang handles only what it does well (XML generation and validation).
-
- Negative
-
-
Without the Ghostscript step, the output is technically not strict PDF/A-3. All tested ZUGFeRD readers (Quba, ZUGFeRD Community viewer, DATEV) accept it regardless.
-
Two libraries to maintain instead of one.
-
Related
-
ADR-34: EER-Owned Format-Neutral Invoice Model as Conversion Core — keeps this generation approach inside the EER ZUGFeRD edge adapter
ADR-2: OCR + LLM Extraction over Rule-Based Mapping
- Status
-
Superseded by ADR-18: LLM-Only Semantic Extraction with Specialized Parallel Calls (2026-05-16). Previously superseded in part by ADR-17: Native Document Interpreters with Xberg, PDFBox, and Apache POI for supported native-text documents and by ADR-8: LLM Returns JSON, Code Renders XML and Computes Totals for typed JSON extraction. Historical OCR:
mistral-ocr-2512. Chat:mistral-small-2603.
Context
InvoiceX must extract structured invoice data (seller, buyer, line items, tax, totals) from arbitrary PDF layouts. Traditional approaches use OCR to extract text, then apply rule-based field mapping or template matching. These approaches break when PDF layouts vary — and every invoice tool produces different layouts.
Decision Drivers
-
Must handle any PDF layout without template maintenance
-
German invoices legally contain all required fields (§ 14 UStG) — the data is always present
-
A small team cannot maintain a growing library of layout-specific extraction rules
-
Extraction quality must be good enough that most invoices need no user correction
Considered Options
| Option | Pros | Cons |
|---|---|---|
OCR + rule-based extraction |
Deterministic output. No per-invoice API cost. Works offline. |
Requires layout-specific templates. Breaks on unknown layouts. High maintenance effort. |
OCR + ML model (custom-trained) |
Adapts to new layouts after training. No API dependency. |
Needs training data. High upfront investment. The team cannot maintain an ML pipeline alongside the core product. |
OCR + LLM extraction (two-step) |
Works with any PDF layout. No template maintenance. OCR handles visual interpretation; LLM handles semantic extraction. Reads full document context. |
Non-deterministic LLM output. Per-invoice API cost. Depends on external API. Potential hallucination. |
Decision
Use a two-step extraction pipeline with Mistral AI for the first production generation:
-
OCR step:
MistralDocumentAiOcrClientsends the PDF to Mistral’s OCR API (POST /v1/ocr, modelmistral-ocr-2512). The OCR model returns page-structured markdown text with headers, content, and footers per page. -
LLM step:
MistralXmlInvoiceLLMClientfeeds the OCR text plus a ZUGFeRD schema prompt to Mistral’s chat model via Spring AIChatClient. The LLM generates the CII XML.
This ADR captures the original baseline. The current supported native-text PDF path has since moved to local Xberg evidence plus typed JSON chat passes; see ADR-17: Native Document Interpreters with Xberg, PDFBox, and Apache POI and ADR-8: LLM Returns JSON, Code Renders XML and Computes Totals.
Mustang Project validates every LLM output against EN 16931 rules. Failed validations trigger the edit-and-retry flow.
Consequences
- Positive
-
-
Historically worked with all PDF types — native text, scanned, any layout.
-
Zero template maintenance. Adding a new invoice format requires no code changes.
-
Separating OCR from LLM lets each model focus on its strength: visual interpretation vs. semantic extraction.
-
The OCR step produces human-readable markdown, useful for debugging extraction issues.
-
- Negative
-
-
Non-deterministic: the same PDF may produce slightly different XML on repeated runs.
-
Per-invoice API cost (two API calls: OCR + chat, ~0.01 EUR total per extraction).
-
External API dependency: Mistral downtime blocks invoice processing.
-
LLM may hallucinate values not present in the PDF. Validation catches structural errors, but semantic errors (wrong amount) require user review.
-
Superseded in part by ADR-17: Native Document Interpreters with Xberg, PDFBox, and Apache POI and ADR-8: LLM Returns JSON, Code Renders XML and Computes Totals for supported native-text documents: provider OCR is no longer the preferred default path there, and the LLM no longer emits XML directly.
-
Related
-
Solution Strategy: Native Document Evidence with Structured LLM Handoffs
-
ADR-3: Migration from OpenAI to Mistral AI (LLM provider choice)
-
ADR-17: Native Document Interpreters with Xberg, PDFBox, and Apache POI (native document interpreters for supported native-text documents)
ADR-3: Migration from OpenAI to Mistral AI
- Status
-
Implemented (2026-02-25). Documented in PDR: LLM Provider Migration.
Context
InvoiceX initially used OpenAI for LLM-based invoice extraction. Customers raised concerns about US data processing during early user interviews. Invoice PDFs contain sensitive business data: VAT IDs, addresses, bank details, revenue figures.
The EU Data Privacy Framework allows US data transfers, but its legal foundation remains fragile — two predecessors (Safe Harbor, Privacy Shield) were invalidated by the CJEU. The US CLOUD Act further compounds the risk: US providers must hand over data stored anywhere when served with a warrant.
Decision Drivers
-
Customer trust: users must feel confident sending invoice data to the service
-
GDPR compliance without relying on a legally fragile adequacy decision
-
Cost (OpenAI GPT-4o-mini was 3x more expensive than Mistral equivalents)
-
Spring AI must support the target provider
Considered Options
| Option | Pros | Cons |
|---|---|---|
Stay with OpenAI |
Largest model ecosystem. Best tooling. Most documentation. |
US-hosted. CLOUD Act exposure. Customer trust concerns. Higher cost. |
Migrate to Mistral AI |
EU company (France). EU-only data centers. 67% cheaper. Spring AI starter available. |
Smaller model ecosystem. Fewer model size options. Less community tooling. |
Self-hosted open-source model |
Full data control. No API dependency. No per-invoice cost. |
Requires GPU infrastructure. High ops overhead. The team cannot justify maintaining ML infrastructure alongside the core product. |
Decision
Migrate to Mistral AI (mistral-small-2603 model).
Spring AI’s provider abstraction made this a configuration change: swap the starter dependency and API key.
Consequences
- Positive
-
-
100% EU data processing. No data leaves EU jurisdiction.
-
67% cost reduction per extraction compared to GPT-4o-mini.
-
Removes CLOUD Act exposure and customer trust concerns.
-
Migration required only a dependency and configuration change thanks to Spring AI.
-
- Negative
-
-
Smaller model ecosystem. Fewer model variants for future use cases.
-
Less community tooling and fewer examples compared to OpenAI.
-
ADR-4: Email-Based Interface as Primary Channel
- Status
-
Active. Web trial upload added as secondary channel.
Context
InvoiceX targets small business owners and sole proprietors with established invoicing processes. These users create invoices in Word, Excel, or trade-specific software, save as PDF, and email them to customers. They are not tech-savvy and resist adopting new software.
Decision Drivers
-
Zero learning curve for the target audience
-
Must work from any device and any email client
-
Must integrate into the existing workflow (create PDF → email it)
-
No app installation or browser-specific requirements
Considered Options
| Option | Pros | Cons |
|---|---|---|
Web upload interface (primary) |
Full UX control. Rich feedback. Interactive validation. |
Requires users to open a browser, navigate to a site, and upload a file. Extra steps break the existing workflow. |
Email-based interface (primary) |
Fits the existing workflow. User changes one thing: the recipient address. Works from any device, any email client. Zero learning curve. |
Limited UX control. Async workflow (user waits for response email). Error handling requires email + web handoff. |
Desktop application |
Full offline capability. Rich UI. |
Requires installation. Users resist new software. Platform-specific maintenance. Does not fit the target audience. |
Decision
Email is the primary interface. Users send their PDF invoice to an InvoiceX email address and receive the e-invoice back via email.
The web UI handles only tasks that require a form-based interface:
-
Correcting extraction errors (edit session)
-
Account management (pricing tier, email preferences)
-
Trial upload for first-time visitors from the marketing website
Consequences
- Positive
-
-
Zero learning curve. Users already know how to send email.
-
Works on every device with an email client — phones, tablets, desktop, webmail.
-
Integrates into the existing workflow: create PDF → send email → done.
-
- Negative
-
-
Limited UX control: cannot show progress indicators, interactive validation, or rich formatting in email.
-
Async workflow: user sends email, then waits for a response. No instant feedback.
-
Error recovery requires a handoff from email to web (magic link to edit session).
-
ADR-5: Monolith-First Architecture
- Status
-
Partially superseded by ADR-30: One BootJar with Product-Isolated Spring Child Contexts for internal build and runtime boundaries and by ADR-31: Separate Logical PostgreSQL Databases and Roles for database topology; one backend process and deployment unit remain active.
Context
InvoiceX is maintained by a small team. The team handles architecture, implementation, operations, and customer support. Microservices would add inter-service communication, distributed tracing, deployment orchestration, and failure modes that a small team cannot justify operating.
Decision Drivers
-
Small team: cannot operate distributed systems
-
Budget: single VPS, no managed container orchestration
-
Speed: fast iteration without coordination overhead
-
Must remain splittable if team size or scale demands it later
Considered Options
| Option | Pros | Cons |
|---|---|---|
Microservices |
Independent deployment. Independent scaling. Technology diversity per service. |
Distributed system complexity. Network failure modes. Deployment orchestration. Distributed tracing required. Small team cannot justify the operational overhead. |
Monolith |
Single deployment unit. Shared database. Simple debugging. Fast local development. One process to monitor. |
Vertical scaling only. Coupling between capabilities if boundaries erode. All-or-nothing deployment. |
Modular monolith (formal modules) |
Monolith benefits with enforced module boundaries. |
Additional build complexity. Over-engineered for current team size. |
Decision
The core application (backend) runs as a single Spring Boot application with one process, one database, and one deployment unit for all business logic.
Static companions run alongside it: the two Nuxt-generated product frontends under applications/frontend/ plus partner demo pages under demos/partner/. They are stateless and minimal in scope — they don’t share the backend’s database or contain business logic. This is not a microservices architecture; it is a monolith with static companions. ADR-25: Static Nuxt Product Frontends defines their frontend and deployment boundary.
Registration and double opt-in (DOI) are handled self-hosted within the backend, using Mailgun for email delivery.
A previous Go microservice (subscribe-api) and the external Brevo dependency were removed in April 2026 (#319). They are no longer part of the system and only mentioned here for historical context.
|
Package-by-feature structure (ADR-6: Package-by-Feature Organization) keeps capabilities decoupled within the backend. Splitting into separate services remains possible by extracting packages into independent applications.
Consequences
- Positive
-
-
Simple deployment:
docker compose upruns the entire application. -
Shared database: no distributed transactions, no eventual consistency.
-
Fast debugging: one log stream, one process, local breakpoints.
-
Low infrastructure cost: runs on a single Hetzner VPS.
-
Fewer external dependencies: no third-party DOI service required.
-
- Negative
-
-
Scaling is vertical only: larger VPS, not more instances. Sufficient for current load.
-
All capabilities deploy together. A bug in email delivery blocks invoice processing deployment.
-
Internal boundaries depend on developer discipline, not compile-time enforcement.
-
Related
-
ADR-6: Package-by-Feature Organization (package structure that enables future splitting)
-
ADR-30: One BootJar with Product-Isolated Spring Child Contexts (one BootJar with compile-time product modules and sibling Spring application contexts)
-
ADR-31: Separate Logical PostgreSQL Databases and Roles (separate logical product databases replace the shared-database clause)
ADR-6: Package-by-Feature Organization
- Status
-
Active. Refined by ADR-30: One BootJar with Product-Isolated Spring Child Contexts for product-module ownership and by ADR-34: EER-Owned Format-Neutral Invoice Model as Conversion Core for EER invoice semantics and target adapters.
Context
The codebase needs a structure that communicates business purpose and supports future modularization.
Traditional Java/Kotlin projects organize by technical layer (controllers/, services/, repositories/), which scatters related business logic across the directory tree.
Decision Drivers
-
Architecture should scream the domain, not the framework
-
Related code should live together (cohesion)
-
Must support extracting a capability into a separate service later
-
A new developer should understand the business from the package tree
Considered Options
| Option | Pros | Cons |
|---|---|---|
Package-by-layer ( |
Familiar to most Java developers. Easy to find all controllers or all repositories. |
Scatters related business logic. A change to "email ingestion" touches files in three different packages. Does not communicate business purpose. |
Package-by-feature ( |
Groups related code. Architecture communicates domain. Supports future extraction into services. High cohesion within each package. |
Cross-cutting code (security, persistence config) needs a shared |
Decision
Top-level packages named by business capability:
applications/backend/
├── app/ # composition root, servlet ownership
├── core/ # product-neutral stateless contracts
├── eer/
│ └── com.clarula.backend/{email,invoice,formats,identity,widget,...}
└── clarula/
└── com.clarula.clarula/{firm,datev,...}
Each package owns its controllers, services, repositories, and domain types.
No cross-package database access.
infrastructure is the only horizontal package.
Subsequent Refinement
ADR-30: One BootJar with Product-Isolated Spring Child Contexts places EER and Clarula in sibling Gradle modules and application contexts. Package-by-feature remains mandatory inside each product; :core contains only genuinely product-neutral stateless capabilities and no product domain.
ADR-34: EER-Owned Format-Neutral Invoice Model as Conversion Core assigns EER invoice semantics, source evidence, analysis orchestration, common validation, and target ports to :eer package invoice.conversion.
The PDF/Xberg/LLM implementation is an EER input adapter behind that boundary, while formats.zugferd owns only target readiness, mapping, validation, and output generation.
invoice.editing owns EER draft persistence and correction mapping.
Clarula keeps its independent firm, mandate, routing, and DATEV capabilities in :clarula and cannot import EER invoice-domain code.
This does not introduce a generic top-level domain, shared, or technical-layer package.
Consequences
- Positive
-
-
A new developer sees
formats.zugferdandemail.ingestionbeforeJpaConfig. -
Related code lives together: controller, service, repository, and domain types in one package.
-
Each package can be extracted into a separate service by moving it and adding an API boundary.
-
- Negative
-
-
Cross-cutting concerns (security filters, JPA configuration, global exception handling) must live in the
infrastructurepackage. -
Developers unfamiliar with package-by-feature may initially look for a
controllers/directory.
-
Related
-
ADR-5: Monolith-First Architecture (monolith structure that this organization supports)
-
ADR-30: One BootJar with Product-Isolated Spring Child Contexts (product modules and child application contexts)
-
ADR-34: EER-Owned Format-Neutral Invoice Model as Conversion Core (EER invoice-model ownership and adapter dependency direction)
ADR-7: Time-Based Monetization over Volume-Based
- Status
-
Implemented (2026-03-15). Documented in PDR: Tiering v2 — Time-Based Access.
Context
The initial pricing model used volume-based limits: Free (10 invoices/month), Standard (50/month), Pro (250/month). Two problems emerged during early validation:
-
High-volume users were penalized. A small business owner processing 40+ invoices per month hit the Free tier limit quickly and faced a steep jump to Standard. The pricing penalized exactly the users who needed the product most.
-
Low-volume users never converted. A freelancer sending 3 invoices per month stayed in the Free tier indefinitely. Volume limits created no urgency to convert.
Decision Drivers
-
Fair pricing across all business sizes
-
Create urgency for conversion (trial expiry > soft usage limits)
-
Simple communication: one price, unlimited use
-
Predictable costs for the user — no per-invoice anxiety
Considered Options
| Option | Pros | Cons |
|---|---|---|
Volume-based limits (status quo) |
Pay-per-use fairness. Low-volume users pay less. |
Penalizes high-volume users. Low-volume users never convert. Complex tier communication ("How many invoices do I process per month?"). |
Time-based access (trial + subscription) |
Fair for all business sizes. Simple communication. Trial creates urgency. Predictable cost. |
No revenue from users who churn after trial. High-volume users get more value per euro (acceptable: they are the best advocates). |
Per-invoice pricing |
Perfect pay-per-use alignment. |
Unpredictable costs for users. Creates "should I convert this one?" friction. Complex billing infrastructure. |
Decision
Switch to time-based access:
-
30-day free trial with full functionality (no volume limits during trial).
-
Flat-rate subscription at 9.90 EUR/month (Standard) after trial expiry.
-
All tiers produce identical, EN 16931-compliant e-invoices.
Consequences
- Positive
-
-
Fair pricing: a small business owner with 40 invoices/month pays the same as a sole proprietor with 5.
-
Simple communication: "30 days free, then 9.90 EUR/month — unlimited invoices."
-
Trial expiry creates natural conversion urgency.
-
No per-invoice cost anxiety for users.
-
- Negative
-
-
Low-volume users who only need 2-3 invoices per month may churn after the trial.
-
High-volume users extract more value per euro — acceptable because they generate word-of-mouth.
-
ADR-8: LLM Returns JSON, Code Renders XML and Computes Totals
- Status
-
Superseded by ADR-18: LLM-Only Semantic Extraction with Specialized Parallel Calls (2026-05-16). Previously implemented 2026-04-18 and superseded the XML-direct extraction approach in ADR-2: OCR + LLM Extraction over Rule-Based Mapping.
Context
The current extraction pipeline (ADR-2: OCR + LLM Extraction over Rule-Based Mapping) originally instructed the LLM to produce ZUGFeRD CII XML directly from OCR text.
The prompt has grown to ~200 lines covering 13 critical XML validity rules, three numbered steps for net/gross detection and conversion, payment-means mapping, tax-ID search, Kleinunternehmer handling, mandatory-field rules for delivery and due dates, and an embedded XML skeleton with [PLACEHOLDER] markers.
The LLM must in one pass: classify the document, identify line items, classify net vs. gross prices, compute per-line and document totals, and emit syntactically valid XML.
A production failure on a Hetzner Cloud invoice surfaced two problems that point to deeper structural issues, not isolated prompt bugs:
-
The LLM picked the wrong table on a multi-table invoice page (a service summary table instead of the per-product line items) and emitted line totals from the wrong source. The extracted source text for the same PDF is correct, so the failure is in semantic extraction, not text recognition.
-
Per-line and document totals occasionally fail arithmetic consistency checks even when individual values are extracted correctly. The LLM is performing arithmetic in natural language while simultaneously generating XML.
Attempts to fix the first problem by adding more disambiguation rules to the prompt have so far not produced a verified fix, and each addition increases the risk of regressions on invoice types that already work (Tennis, Brutto-Lines, Reifen, Kleinunternehmer). The current architecture couples three concerns inside one LLM call: semantic extraction, arithmetic, and XML serialization. Mistral’s own prompting guidance and Anthropic’s long-context guidance both recommend against asking an LLM to perform arithmetic, and recommend separating extraction from formatting.
Decision Drivers
-
Extraction must be reliable enough that the Hetzner-class bug cannot recur from a prompt regression
-
Arithmetic errors must be impossible by construction, not caught by validation after the fact
-
Prompt complexity must stay low enough for a single developer to reason about and modify safely
-
The change should not increase per-invoice API cost or latency materially
-
The XML output must remain valid against EN 16931 and the ZUGFeRD 2.5 schema
Considered Options
| Option | Pros | Cons |
|---|---|---|
Keep current architecture, fix prompt iteratively |
No code changes. No risk to other extraction paths. Familiar to the team. |
Each prompt fix risks regressing other invoice types. Arithmetic bugs remain possible by construction. Prompt complexity already exceeds the threshold where Mistral’s own guidance predicts failures. |
LLM returns JSON, code renders XML and computes totals |
LLM has one job (extract structured facts). Arithmetic happens in Kotlin and is unit-testable. XML serialization is deterministic and schema-valid by construction. Prompt becomes substantially shorter (no XML skeleton, no |
Requires a new JSON schema (one-time effort) and a Kotlin JSON→CII renderer. Existing tests must be ported. Possible regression on invoice types currently passing. |
Two-stage LLM (stage 1 extracts JSON, stage 2 generates XML from JSON) |
Keeps LLM in both extraction and formatting roles. Easy to introduce incrementally. |
Doubles API cost and latency. Stage 2 still has the arithmetic problem unless stage 1 already computes totals. Adds a moving part without removing the original failure mode. |
Decision
Refactor the LLM boundary so that every model response is typed JSON, and move XML rendering and arithmetic into Kotlin:
-
Local evidence step:
XbergPdfStructuredTextExtractorderivesStructuredDocumentEvidencefrom the source PDF and keeps both the legacydocumentContexttext view and richer layout cues local to the application. -
Structural LLM step:
MistralJsonInvoiceLLMClientperforms a first typed chat call that mapsStructuredDocumentEvidencetoDocumentSemantics. -
Invoice JSON step: the same client performs a second typed chat call that combines
documentContextwith those structural hints and returns anExtractedInvoiceJSON object with seller, buyer, line items (description, quantity, unit price, tax rate, net/gross flag), payment terms, and tax breakdown lines — but no computed totals. -
Arithmetic step: A pure Kotlin
InvoiceTotalsCalculatorcomputes per-line totals, tax base sums, tax amounts, and grand total from the extracted line items. This is unit-testable without API calls. -
Rendering step:
MustangInvoiceMapperandExtractedInvoiceZugferdConversionServicerender valid CII XML from theExtractedInvoiceplus the calculated totals, followed by XML normalization and PDF embedding. -
Validation:
MustangXmlValidationServicecontinues to validate the rendered XML against EN 16931, unchanged.
The prompt shrinks to instructions for extraction only, with 3–5 few-shot examples covering the failure modes that currently require defensive rules (per-product tables vs. service summaries, brutto-line receipts, Kleinunternehmer cases).
Consequences
- Positive
-
-
Arithmetic bugs become structurally impossible: the LLM never produces a total.
-
The prompt loses the entire embedded XML skeleton, the
[PLACEHOLDER]substitution rules, the 13 XML validity rules, and the arithmetic verification step. The remaining instructions are pure extraction guidance, well within the range where Mistral’s prompting guidance predicts reliable behavior. -
JSON schema enforcement happens server-side via Structured Outputs, removing an entire class of "LLM produced invalid XML" failures.
-
The Kotlin renderer and calculator are unit-testable in milliseconds without LLM API calls. Test coverage of edge cases becomes cheap.
-
Few-shot examples become tractable: 3–5 JSON examples are far more compact than 3–5 XML examples and sit better within the prompt context window.
-
The extra
DocumentSemanticspass gives the final extraction prompt layout-aware section hints without reintroducing XML generation into the model.
-
- Negative
-
-
One-time effort: define
ExtractedInvoiceandDocumentSemantics, writeInvoiceTotalsCalculator, wireMustangInvoiceMapperinto the deterministic tail, and port existing prompt knowledge into the new typed stages. -
Risk of regression on currently-passing invoice types (Tennis, Brutto-Lines, Reifen, Kleinunternehmer). All
*LlmTestcases must pass against the new pipeline before rollout. -
Loss of any "free" XML structure the LLM was implicitly handling — e.g. uncommon BG-25 line item attributes — must be handled explicitly in the renderer.
-
Adds Kotlin code that must be maintained against future ZUGFeRD/EN 16931 schema revisions. Currently the LLM absorbs schema knowledge from the prompt; future schema updates require renderer changes instead of prompt changes.
-
- Neutral
-
-
The dedicated OCR provider call disappears on the supported native-text PDF path.
-
The production semantic flow now uses two bounded chat calls instead of one large XML-producing call: first
DocumentSemantics, thenExtractedInvoice.
-
Implemented Shape
The accepted direction is now implemented in the following production shape:
-
Typed evidence:
StructuredDocumentEvidenceandDocumentSemanticsnarrow the LLM responsibilities beforeExtractedInvoiceis produced. -
Calculator:
InvoiceTotalsCalculatorcomputes totals deterministically, including reconciliation against printed monetary anchors. -
Renderer:
MustangInvoiceMapper,ZugferdXmlNormalizer,MustangXmlValidationService, andZUGFeRDGeneratorform the deterministic rendering and validation tail. -
Prompt + Few-Shots: The active extraction prompt focuses on typed invoice facts, not XML syntax or arithmetic.
-
Regression:
testUnit,testIntegration,quickCheck, andtestLlmremain the verification path for changes to the semantic extraction flow.
Related
-
ADR-2: OCR + LLM Extraction over Rule-Based Mapping (OCR + LLM Extraction over Rule-Based Mapping) — superseded in part: provider OCR is no longer the preferred native-text PDF path, and the LLM-produces-XML decision is replaced.
-
ADR-3: Migration from OpenAI to Mistral AI (Migration from OpenAI to Mistral AI) — unaffected; Mistral Structured Outputs is the enabling capability.
ADR-9: Analytics Reports as Code via SQL Views
- Status
-
Accepted (2026-04-18).
Context
We track business events in a dedicated analytics schema in Postgres (see BusinessEventEntity, business.tracking package).
Reports and dashboards are built in Metabase, which connects directly to Postgres and stores its question and dashboard definitions in its own application database.
This split has a structural problem: the SQL queries that define our reports live only inside Metabase. They are not in the repository, not reviewable in pull requests, not testable, and not visible to AI coding agents. Concretely:
-
When a
BusinessEventpayload changes, we have no way to detect which Metabase question breaks until someone opens the dashboard. -
Refactoring an event type or renaming a payload field requires manually clicking through Metabase questions to find references.
-
Agents cannot answer "what reports do we have?" or "which event drives the conversion funnel?" without a human exporting Metabase state.
-
Onboarding a new team member to our analytics requires a Metabase tour, not a
git logand a directory listing.
The business.tracking.persistence package is already write-only by architecture rule (enforced by AnalyticsBoundaryArchTest).
Application code does not read from the analytics schema.
This means the read path is fully owned by Metabase today, which is exactly the part that is invisible to the repository.
We have ~9 event types in production (InvoiceReceivedEvent, InvoiceProcessedEvent, InvoiceProcessingFailedEvent, etc.) and a growing number of Metabase questions whose query logic is not under version control.
Decision Drivers
-
Report definitions must be reviewable in pull requests, the same way code is reviewed.
-
AI agents must be able to read, modify, and propose changes to report logic without manual export.
-
When a business event schema changes, the impact on reports must be visible at the same place the event is changed.
-
Metabase remains the visualization tool — we do not want to replace it.
-
The solution must not introduce a new tool or runtime dependency unless clearly justified (YAGNI).
-
Operational overhead must stay low: we are a single-VPS deployment with limited DBA time.
Considered Options
| Option | Pros | Cons |
|---|---|---|
Keep status quo (queries in Metabase only) |
No work. No risk. |
Reports stay invisible to repo and agents. Schema-change impact remains undetectable until dashboards break. Documented problem persists. |
SQL Views in the repository, Metabase only visualizes (proposed) |
Report definitions live next to event definitions. Versioned, reviewable, testable. Metabase questions become trivial |
Schema changes to views are heavier than to plain SQL: views must be `DROP CASCADE`d and recreated. Materialized views need a refresh strategy. Risk that Metabase users still write ad-hoc SQL against raw tables and bypass the curated layer. |
Materialized views only (always materialize) |
Best query performance. Decouples Metabase load from event write load. |
Refresh strategy required for every report (cron, trigger, or manual). |
dbt (data build tool) |
Industry standard for analytics-as-code. Lineage graph, tests, auto-generated docs. |
New tool, new runtime, new deployment artifact. Overkill for our current report count and team size. Re-evaluate when we have >50 reports or a dedicated data role. |
Export Metabase questions to repo via API (sync script) |
Keeps Metabase as authoring tool. Repo is auto-updated mirror. |
Metabase remains source of truth — review happens after the fact. Generated SQL is harder for agents to reason about than hand-written views. Sync drift is a permanent risk. |
Metabase Serialization (official YAML export/import) |
Officially supported. Round-trippable. |
Binds reports to Metabase’s YAML format, which mixes SQL with visualization config. Harder for agents to parse than plain SQL. Vendor lock-in on the report definition layer. |
Decision
We will define analytics reports as SQL views in the analytics schema, managed via Flyway migrations in the same repository as the event definitions that feed them.
Metabase questions become trivial reads against these views (SELECT * FROM analytics.report_xyz) and own only the visualization layer (chart type, axes, filters, colours).
Specifically:
-
Plain views by default. A new view is a regular
CREATE VIEW analytics.report_<name>in a Flyway migration. No materialization unless a measured performance problem justifies it. -
Materialized views by exception. When a report’s query latency on the live Metabase dashboard exceeds an acceptable threshold (initial heuristic: >2 seconds), it is converted to a materialized view in a follow-up migration, with a documented refresh strategy (cron via
pg_cronor a Spring scheduled job). -
Naming convention. Views are prefixed
report_for user-facing reports andmetric_for reusable building blocks consumed by other views. Example:analytics.report_conversion_funnel,analytics.metric_daily_active_accounts. -
Location. Migrations live in
applications/backend/src/main/resources/db/migration/alongside existing Flyway scripts. View DDL is the same migration mechanism as the rest of the schema. -
Read-side boundary stays.
AnalyticsBoundaryArchTestcontinues to forbid application code from readinganalytics.*. Views are exclusively for Metabase. -
Documentation. Each view starts with a SQL comment block describing: the business question it answers, the events it consumes, the grain (one row per …), and any known caveats.
COMMENT ON VIEWis used so the description is also visible in Metabase’s data reference.
The migration of existing Metabase questions and the closing of analytics gaps is tracked in a separate GitHub epic.
Consequences
- Positive
-
-
Report logic is versioned, code-reviewed, and visible to agents.
-
Renaming or changing a
BusinessEventpayload field forces a view migration in the same PR — broken reports become a build-time concern, not a "next time someone opens the dashboard" concern. -
Onboarding to analytics becomes "read `db/migration/V*report*.sql`" instead of "tour Metabase".
-
Metabase questions become uniform and trivial, reducing the maintenance surface inside Metabase.
-
COMMENT ON VIEWplus the migration history give us free, durable documentation of our analytics surface.
-
- Negative
-
-
View schema changes are heavier than plain query edits: dependent views require
CASCADEdrops and explicit recreation in the migration. -
Materialized views, when introduced, need a refresh mechanism we currently do not operate. First materialized view will pay the cost of choosing and setting up that mechanism.
-
Discipline required: nothing in Postgres prevents a Metabase user from writing an ad-hoc query against
analytics.business_eventsand bypassing the view layer. Convention must be documented and enforced socially or via Metabase user permissions. -
JSON payloads in
business_events.payload_jsonmust be projected into typed columns inside the views. Each event type effectively gets its own typed unwrap view, adding boilerplate.
-
- Neutral
-
-
No change to event persistence.
business.trackingcontinues to write events as today, but shared conversion outcome events are now emitted centrally fromPdfToZugferdConversionService.convert(…)instead of being split across channel-specific callers. -
No new runtime dependency. Postgres and Flyway already in use.
-
Metabase remains in use for visualization, dashboards, and access control.
-
Related
-
ADR-6: Package-by-Feature Organization (Package-by-Feature Organization) —
business.trackingowns events; this ADR extends ownership to the read views derived from those events. -
AnalyticsBoundaryArchTest— enforces that application code does not read the analytics schema; this ADR confirms the read path belongs to Metabase via the curated views.
ADR-10: Cross-Domain Redirect via Server-Side Partner Registry
- Status
-
Accepted (2026-04-18). Drives implementation of PDR: Partner Embeddable Widget for Tax Advisors.
Context
The partner widget runs on third-party domains (e.g. kanzlei-mueller.de) but must convert anonymous uploads into authenticated sessions through the canonical einfache-erechnung.de product origin.
After magic-link confirmation, the user must land back on the partner page that started the flow — both for UX continuity and so the tax advisor sees their branded experience preserved.
The naive solution — letting the widget pass an arbitrary redirect_url query parameter to our backend — creates an open redirect vulnerability.
Any third party could craft a magic-link URL that redirects to a phishing site after authentication.
Decision Drivers
-
Security: No open redirects. We must control the allow-list of redirect targets.
-
Simplicity: Tax advisors should not need to configure redirect URLs per page.
-
Operability: Adding or revoking a partner must be a single admin action.
-
Auditability: We must know which partner originated every conversion.
Considered Options
| Option | Pros | Cons |
|---|---|---|
Server-side |
Closed allow-list. Single source of truth. Audit trail per partner. Revocation is one DB update. |
Requires admin UI. Each new partner needs onboarding. |
Signed |
No registry needed. Self-contained tokens. |
Key rotation is painful. Lost keys cannot be revoked without breaking active sessions. Still need allow-list of base domains. |
Hardcoded partner allow-list in config |
Trivial to implement. No DB. |
Every new partner requires a deployment. Conflicts with admin self-service goal. |
Decision
Introduce a Partner entity with a stable, opaque partner_id (UUID).
The widget embeds partner_id in every API call.
The backend resolves partner_id to the partner’s configured redirect_base_url and appends a fixed path suffix (/erechnung-erstellt).
-
Widget API contracts carry
partner_idas a header (X-Partner-Id). -
Magic-link tokens persist the originating
partner_idso the post-confirmation redirect resolves server-side from the registry, not from a user-controlled URL. -
Unknown or revoked
partner_idreturns HTTP 404 — never falls back to the default flow.
Consequences
- Positive
-
-
No open redirect vulnerability. Redirect targets are server-controlled.
-
Revoking a partner takes one admin action and breaks all in-flight flows immediately.
-
Per-partner conversion analytics are trivial (just join on
partner_id). -
Partner onboarding is decoupled from deployments.
-
- Negative
-
-
Requires an admin UI to manage partners (covered by the same Epic).
-
Adds one indirection (
partner_id→redirect_base_url) on every widget request — negligible at expected volume. -
Widgets cannot redirect to arbitrary deep links within a partner site. Acceptable: the post-conversion landing page is part of the partner’s onboarding contract.
-
ADR-11: Embeddable Widget as Vanilla JS Web Component
- Status
-
Accepted (2026-04-18). Amended 2026-07-14 to serve new embeds from the canonical product origin while retaining the former app origin only for already-installed widgets. Drives implementation of PDR: Partner Embeddable Widget for Tax Advisors.
Context
Tax advisors will embed our widget on their websites with a single <script> tag.
Their sites run on heterogeneous stacks: WordPress, Wix, Jimdo, custom HTML, sometimes legacy IE-era CMSes.
Many of these sites already load jQuery, Bootstrap, or other UI frameworks that may conflict with global CSS or JS.
The widget must:
-
Load fast (sub-100 KB ideally).
-
Not pollute the host page’s global scope.
-
Not be broken by the host page’s CSS resets.
-
Be embeddable with a one-liner the partner copies from the admin UI.
Decision Drivers
-
Embedding simplicity: One
<script>tag, zero configuration beyondpartner_id. -
Style isolation: Host CSS must not bleed into the widget, and vice versa.
-
No framework lock-in for partners: Cannot require the partner site to load React/Vue.
-
Maintainability: The widget evolves independently from the partner sites.
Considered Options
| Option | Pros | Cons |
|---|---|---|
Vanilla JS Web Component with Shadow DOM |
Native browser API. Shadow DOM isolates styles bidirectionally. Tiny bundle. No runtime dependency. Works with any host stack. |
Requires modern browsers (acceptable: tax advisors and their clients are on evergreen browsers). |
React/Preact bundle |
Familiar component model. |
Bundle size 40-150 KB just for the framework. CSS isolation requires extra work (CSS-in-JS, scoped class names). Risk of version conflicts if host page also loads React. |
Iframe embed |
Perfect isolation. Trivial to implement. |
Cross-origin file uploads via iframe are awkward (postMessage choreography). Drag-and-drop UX is degraded. Resizing iframe to content is fragile. |
Decision
Build the widget as a vanilla JavaScript custom element (<einfache-erechnung-widget>) using Shadow DOM for style isolation.
Source lives in applications/backend/src/main/widget/ (TypeScript).
A Gradle task buildWidget runs esbuild via npm and emits the bundle to src/main/resources/static/widget/v1/embed.js.
The artifact ships in the same bootJar as the rest of the application — one deployment, one version (consistent with ADR-5 Monolith-First).
The embed snippet is:
<script src="https://einfache-erechnung.de/widget/v1/embed.js" async></script>
<einfache-erechnung-widget partner-id="..." accent-color="#005FCC"></einfache-erechnung-widget>
Versioning is path-based (/widget/v1/).
Breaking changes require /widget/v2/ and a partner migration window.
Partner sites that already use https://app.einfache-erechnung.de/widget/v1/embed.js remain supported during a coordinated migration window. That former origin exposes only /widget/v1/, /api/v1/widget/, and the /api/widget/** compatibility alias; it no longer owns admin, webhooks, customer pages, or general APIs. New snippets must never use it.
Consequences
- Positive
-
-
Bundle size stays small (target < 30 KB gzipped). Native APIs do the heavy lifting.
-
Shadow DOM guarantees no CSS conflicts in either direction.
-
Widget works on any modern host stack without dependencies.
-
TypeScript source is colocated with the backend that serves it — one repo, one build, one deploy.
-
- Negative
-
-
Adds npm/node as a build-time dependency (only for the widget bundle, not for the application runtime).
-
Custom elements need polyfills for IE11 — explicitly out of scope. Tax advisors confirm their target users are on modern browsers.
-
Path-based versioning means we must support old
/widget/v1/embed.jsindefinitely (or coordinate partner migration).
-
ADR-12: Anonymous Conversion Persistence with TTL
- Status
-
Superseded in part by ADR-23: Widget Result Delivery by Standard Success Email (2026-05-26). Accepted 2026-04-18. Drives implementation of PDR: Partner Embeddable Widget for Tax Advisors.
Context
The widget lets a visitor upload a PDF, see the e-invoice generated, and only then asks for an email address to download.
Between upload and authentication, the conversion exists without an owning Account.
After magic-link confirmation, the result must attach to the new (or existing) account so the user can download it.
This anonymous-then-authenticated handoff is unique to the widget. The existing email channel always has an owner from the first byte (the sender address).
Decision Drivers
-
Privacy: Hold anonymous PDFs no longer than necessary. GDPR data minimization.
-
Reliability: Must survive a backend restart between upload and download.
-
Simplicity: Should reuse existing
Conversioninfrastructure where possible. -
Operability: Cleanup must not require manual intervention.
Considered Options
| Option | Pros | Cons |
|---|---|---|
Persist |
Same entity as the rest of the system. Survives restarts. Cleanup is a scheduled job. Token-based claim is simple. |
Adds a nullable foreign key on |
In-memory cache (Caffeine) with TTL |
Trivial. Auto-evicts. |
Loses data on restart. Loses data on second instance (when we eventually scale out). |
Separate |
Clean domain separation. No nullable FK. |
Duplicated schema. Double the persistence code. Two tables to back up. |
Decision
Allow Conversion.account_id to be nullable.
Add claim_token (random 256-bit) and claim_token_expires_at (upload time + 1 hour) columns.
A scheduled task runs every 15 minutes and deletes expired anonymous conversions (where account_id IS NULL AND claim_token_expires_at < NOW()).
When the user submits an email, the original decision issued a magic-link that carried claim_token (alongside partner_id from ADR-10).
On confirmation, the backend originally:
-
Resolves or creates the
Accountfor the email. -
Sets
Conversion.account_idto that account. -
Keeps
claim_tokenandclaim_token_expires_atso the widget can still resolve the conversion by claim token. -
Logs the user in and redirects per ADR-10 with a short-lived download token.
ADR-23: Widget Result Delivery by Standard Success Email supersedes the confirmation and delivery part: the widget uses the claim token to trigger the standard success email and does not issue download tokens. Its separate partner-scoped identity token may remember the confirmed destination account for later email deliveries, but never grants direct download or portal access.
Consequences
- Positive
-
-
Reuses existing
Conversiontable and processing pipeline. -
Survives restarts and (later) scale-out.
-
Cleanup is automatic, observable via metrics on the scheduled task.
-
GDPR-friendly: anonymous PDFs disappear within 1 hour 15 minutes worst case.
-
- Negative
-
-
Conversion.account_idbecomes nullable — existing queries must be reviewed (ArchUnit-style invariant: no user-facing query may return rows withaccount_id IS NULL). -
If a user takes longer than 1 hour to click the magic link, the conversion is gone. Acceptable: they can re-upload.
-
The cleanup task is a new operational concern (alerting on failure required).
-
ADR-13: Admin Identity via Configuration, Not as a Customer Role
- Status
-
Partially superseded by ADR-33: Dedicated Static Operator Frontend on
admin.clarula.defor operator origin, routes, and cookie path; identity decision accepted (2026-04-18). Drives implementation of PDR: Partner Embeddable Widget for Tax Advisors.
Context
Managing partners requires an authenticated UI under /admin/** that only the InvoiceX team can access.
At the time of writing there are three admins: the three founders.
This count will not exceed a handful in the foreseeable future.
The temptation is to model admins as Account rows with a role = ADMIN flag.
That conflates two different domains: customers (paying users of the e-invoice service) and operators (us, running the platform).
A bug in customer-facing code could elevate or expose admin privileges.
Decision Drivers
-
Security: Admin and customer roles must not share a privilege-escalation surface.
-
Simplicity / YAGNI: Three admins do not justify a full role/permission system.
-
Auditability: It must be obvious who can administer the system without querying the database.
-
Reversibility: If we ever need DB-backed admins (10+ admins, granular permissions), we should not have painted ourselves into a corner.
Considered Options
| Option | Pros | Cons |
|---|---|---|
Allow-list in configuration ( |
Admins are explicit in deployment config. No DB schema. No accidental privilege escalation via customer flow. Trivial to audit. |
Adding/removing admins requires a config change and restart. Acceptable at our scale. |
|
One auth pipeline. Self-service admin creation possible later. |
Customer code paths can reach admin checks. Higher attack surface. Premature for three users. |
Separate |
Clean domain separation in DB. |
Duplicates magic-link infrastructure. More code, no current benefit. |
Decision
Admins are a configuration-driven allow-list:
operator:
admin:
emails:
- founder1@example.com
- founder2@example.com
- founder3@example.com
Authentication reuses the existing magic-link infrastructure but:
-
Operator authentication and both products' admin controllers are always registered; there is no admin feature flag. The configured email allow-list is the access boundary.
-
The admin login lives at
/admin/login(separate controller, separate template). -
Caddy exposes all
/admin/**paths, including Clarula callbacks, only on the canonical einfache-eRechnung operator origin and returns404on other public origins. This keeps host-only admin cookies and OAuth session state on one host. -
The magic link verifies that the email is on the allow-list before issuing a session.
-
Successful verification atomically stores a hash of the magic-link token’s unique identifier on the new remember session. Alternate encodings or repeated verification cannot create another session.
-
Successful admin auth issues a separate short-lived cookie (
admin_session, scoped to path/admin) with its own JWT signing claim. -
Successful admin auth also issues a persistent opaque remember-session cookie (
admin_remember_session, scoped to path/admin). Only token hashes are stored server-side inadmin_remember_sessions, bound to the admin email, and the current allow-list is checked again before refresh. -
The admin remember token stays stable and is reissued on refresh to renew its browser lifetime. Stability ensures that logout or re-authentication can revoke the same server-side session even if another client used the token first.
-
The product-neutral
OperatorAuthFiltervalidates the JWT globally for/admin/**; only non-Clarula EER admin routes may mint a fresh JWT through the EER-owned remember-session bridge. -
Customer cookies and customer remember sessions grant no admin privileges and vice versa.
When the team grows past ~10 admins or we need granular permissions, this ADR is superseded by a DB-backed Admin entity. Migration is straightforward: read the same emails from a table instead of config.
Consequences
- Positive
-
-
Zero risk of customer-side bug elevating to admin (different cookies, different filters, different domain).
-
Allow-list is visible in deployment config — audit is
grep-able. -
Admin identity remains separate from customer accounts and roles, even though admin remember-session revocation needs a small dedicated persistence table.
-
Magic-link UX is consistent across customer and admin, including persistent browser login and single-use verification.
-
- Negative
-
-
Admin changes require a config update and restart. Friction is intentional.
-
Two parallel auth pipelines must be maintained (customer + admin).
-
Admin and customer remember-token rotation differ intentionally: admin tokens prioritize reliable revocation while customer tokens retain rotation with a short grace period.
-
No admin self-service. Acceptable: founders add each other manually.
-
ADR-14: Dev-First Deployment Flow with Manual Production Promotion
- Status
-
Accepted (2026-04-20).
Context
InvoiceX now has a permanent dev environment at dev.einfache-erechnung.de that runs the same Docker Compose topology and Spring production profile as the customer-facing production system.
Earlier, changes on main flowed straight toward production.
That kept delivery simple, but it also meant the main integration branch was one automation step away from shipping directly to customers.
For a small team with no dedicated ops role, that gives very little room to observe deployment behavior before customer impact.
We need a release policy that keeps feedback fast while protecting the production experience.
Decision Drivers
-
Customer protection: production quality must not depend on every merge being release-ready for customers.
-
Operational simplicity: avoid adding a complex release-branch or staging-process bureaucracy.
-
Fast feedback: deployed code should still be exercised automatically in a production-like environment.
-
Traceability: it must remain obvious which commit is running in dev and which commit was promoted to prod.
Considered Options
| Option | Pros | Cons |
|---|---|---|
Auto-deploy |
Fastest path from merge to customer-visible release. Minimal human steps. |
No buffer between integration and customer impact. Operational mistakes or bad merges hit customers immediately. |
Auto-deploy |
Keeps feedback fast in a production-like environment. Adds a deliberate gate before customer impact. Works with the new permanent dev environment. |
Adds one manual release step. Dev is not a stable long-lived QA snapshot because later merges replace it. |
Introduce release branches or a separate staging branch |
Stronger separation between integration and release candidates. |
More branch and merge overhead than the current team size justifies. Easy to forget, harder to keep simple. |
Decision
We will use a dev-first deployment flow:
-
Every qualifying merge to
mainauto-deploys to the permanent dev environment. -
Production deploys are manual-only.
-
The
productionGitHub Environment restricts allowed deployment refs tomainorprod-*tags. -
Successful production deploys create a timestamped
prod-*tag for release traceability.
Dev is the automatic proving ground for integrated changes. Prod is an explicit promotion decision.
Consequences
- Positive
-
-
Customers are protected from every merge becoming an implicit release.
-
The team still gets automatic deployment feedback on every integrated change.
-
Dev runs closer to prod than a local-only workflow because it uses the deployed containers, Caddy, Mailgun routing, and production Spring profile.
-
Release intent becomes clearer: shipping to prod is a conscious act.
-
- Negative
-
-
Releasing now requires a human to dispatch the prod workflow.
-
Dev cannot be treated as a stable hand-tested environment because merges to
mainoverwrite it. -
Two deployment workflows must stay aligned enough that dev remains a trustworthy proving ground for prod.
-
ADR-15: Shared Remote Terraform State via Hetzner Object Storage
- Status
-
Accepted (2026-04-24). Source: #426.
Context
deployment/environment/dev/ currently relies on a local terraform.tfstate on one workstation.
That was acceptable for bootstrapping the dev environment quickly, but it ties infrastructure operations to one laptop and gives the team no shared locking, recovery, or handover model.
Local dev-infra commands now use with-hcloud-env.sh and shared 1Password-backed secret resolution instead of ad-hoc local setup.
We need a shared Terraform state approach that fits the same small-team operating model without adding disproportionate process or tooling overhead.
Decision Drivers
-
Remove the single-operator dependency on one local state file
-
Protect state integrity with a shared locking model
-
Keep recovery practical via versioned state history
-
Stay within the current Hetzner-centered infrastructure footprint
-
Align local operator workflow with the wrapper and shared-secret pattern from #425
Considered Options
| Option | Pros | Cons |
|---|---|---|
Keep local state per operator |
No backend setup. Familiar Terraform default. |
Single-laptop dependency. No team-safe locking. Weak recovery and handover. Easy to drift or overwrite state accidentally. |
HCP Terraform / Terraform Cloud |
Mature remote backend, locking, history, and team controls. No S3-compatibility caveat. |
Adds another SaaS dependency, a different operator workflow, and more platform surface than the current team size needs. |
Hetzner Object Storage via Terraform |
Shared remote state within the current vendor footprint. Low cost. Supports versioned recovery. Fits the existing Hetzner-focused ops model. |
S3-compatible support is best-effort, not first-party AWS S3. Locking semantics must be validated with the exact Terraform version and backend config. Bucket and credentials need manual bootstrap outside managed state. |
Decision
We will store Terraform state for Hetzner-managed infrastructure in a shared remote backend on Hetzner Object Storage via Terraform’s s3 backend.
We adopt this pattern first for deployment/environment/dev/ and treat it as the default for future Hetzner infrastructure modules unless a later ADR supersedes it.
The backend must enable bucket versioning for recovery, use_lockfile = true for shared-state locking, and separate Object Storage credentials from HCLOUD_TOKEN.
Local operators must use the shared wrapper-based secret flow, with 1Password as the primary source and .env.local only as an override or fallback.
The state bucket and initial backend credentials are bootstrap infrastructure and stay outside the Terraform state they protect.
Consequences
- Positive
-
-
Terraform operations no longer depend on one engineer’s laptop.
-
Shared locking reduces accidental concurrent state changes.
-
Bucket versioning gives a practical recovery path after state corruption or accidental deletion.
-
The operator workflow stays aligned with ADR-14’s small-team, low-bureaucracy deployment model and the wrapper-based secret flow.
-
- Negative
-
-
The state bucket becomes critical infrastructure and a higher-value target.
-
Credential compromise could expose infrastructure metadata or allow state tampering.
-
Hetzner Object Storage is only S3-compatible, so locking and recovery behavior must be tested and Terraform versions must be pinned deliberately.
-
Bootstrap, credential rotation, and break-glass recovery remain procedural work outside the managed Terraform state.
-
ADR-16: Format-Neutral Invoice Conversion Pipeline
- Status
-
Superseded by ADR-34: EER-Owned Format-Neutral Invoice Model as Conversion Core (2026-07-15). Previously accepted (2026-05-01); retained as the historical decision that introduced the format-neutral pipeline direction.
Context
The current conversion flow is centered in invoice.conversion with formats.zugferd as a supporting adapter package.
It still assumes PDF input for the productive route, but the semantic slice already separates local evidence construction, structural semantics, invoice extraction, fail-closed interpretation, and deterministic output generation.
A benchmark run on 2026-05-01 across 26 PDFs and 78 attempts reached 75.59% populated-field exactness while generated XML still passed EN16931 validation in 100% of runs.
The dominant failures are semantic, not syntactic: payment means, credit-note detection, line-item grouping, references outside expected header regions, and allowance / charge / prepayment interpretation. Future scope broadens both sides of the pipeline: besides PDF, the system should accept Word and Excel, and besides ZUGFeRD it should support XRechnung and the ZUGFeRD / Factur-X family. A ZUGFeRD-specific package and a PDF-specific evidence model would hard-wire the wrong center of gravity.
Source-visible monetary values remain authoritative. Validation may prove internal consistency of generated output, but not faithfulness to the source document.
Decision Drivers
-
Support multiple input and output formats without creating N x M conversion paths
-
Share one semantic invoice-understanding core across all formats
-
Preserve source-visible monetary values as authoritative
-
Localize failures by pipeline stage and fail closed on ambiguity
-
Keep output-specific mapping, validation, and rendering isolated
-
Stay extensible as benchmark corpus and format support grow
Considered Options
| Option | Pros | Cons |
|---|---|---|
Extend |
Lowest migration effort. Reuses the current package and pipeline. |
Keeps ZUGFeRD as the conceptual center. PDF assumptions leak into future formats. Shared semantics stay coupled to one output family. |
Separate pipeline per input/output pair |
Each path can be optimized for one source and one target format. |
Creates N x M duplication. Benchmark learnings fragment across pipelines. Shared semantic fixes must be repeated. |
Format-neutral pipeline with canonical evidence |
One shared semantic core for all inputs and outputs. Input and output remain adapter concerns. Benchmark learnings accumulate in one place. |
Adds new stages, intermediate models, orchestration rules, and migration effort. |
Custom-trained document model pipeline |
Could eventually be optimized for the benchmark corpus. |
High investment in labeling, training, evaluation, and operations. Not aligned with current team size and stage. |
Decision
Adopt a format-neutral invoice.conversion feature package with five high-level stages:
-
Input Interpreters — build
CanonicalEvidencefrom format-specific adapters such aspdf,word, andexcel.CanonicalEvidenceis an abstract evidence tree, not a PDF-only page/block model. -
Semantic Document Parsing — turn
CanonicalEvidenceinto richer format-specific evidence and then into typed semantic observations. The productive PDF slice currently usesXbergPdfStructuredTextExtractorto deriveStructuredDocumentEvidence, a first typed chat pass to deriveDocumentSemantics, and a second typed chat pass to deriveExtractedInvoice.SemanticIndexremains the handoff object owned byinvoice.conversion; the current implementation storesExtractedInvoice,DocumentSemantics, andStructuredDocumentEvidencethere together with the summary fields downstream interpreters need. -
Specialized Interpreters — run concern-specific interpreters against the
SemanticIndex, referenced evidence, and full-document fallback to produceObservedInvoicefragments. Initial concerns are document type, parties/references, line items, payments, and taxation/adjustments. -
Validators — combine provenance checks, source-conflict checks, ambiguity detection, monetary reconciliation, and format-readiness validation. Monetary reconciliation preserves source-visible totals as authoritative and derives only the structured values needed by the target format, especially net amounts, tax basis amounts, and tax amounts for gross-priced source documents. If multiple semantic interpretations remain plausible, or exact reconciliation to authoritative monetary anchors is impossible, the pipeline must fail closed into validation failure / correction flow.
-
Output Generators — generate target formats from validated input only.
output.ciiserves the ZUGFeRD / Factur-X family.output.xrechnungserves XRechnung-specific output.
Each observed field must preserve provenance: source format, source anchor, extractor stage, and ambiguity flags.
The current implemented slice preserves this incrementally by carrying StructuredDocumentEvidence and DocumentSemantics alongside ExtractedInvoice in the semantic handoff, even though not every field is yet normalized into a separate provenance graph.
Deterministic code is responsible for normalization, provenance handling, monetary reconciliation, validation, and output rendering.
LLMs remain responsible for bounded semantic interpretation, not XML or arithmetic.
The orchestrator must enforce bounded retry and adjudication budgets.
Implementation should combine Ports & Adapters at the input/output edges, a Pipeline orchestrator through the core, Strategy-based interpreters per concern/format, and Specification-style validators.
Algorithm Flow
Module Dependencies
Consequences
- Positive
-
-
Shared semantic understanding removes N x M coupling between input and output formats.
-
PDF, Word, and Excel can evolve as input adapters without duplicating semantic rules.
-
ZUGFeRD / Factur-X and XRechnung stay isolated at the output boundary.
-
Fail-closed validation prevents monetarily consistent but semantically wrong outputs from being emitted.
-
New archetypes can be handled by extending semantic parsing, specialized interpreters, and validators instead of rewriting one universal prompt.
-
- Negative
-
-
The pipeline gains new moving parts: canonical evidence, semantic parsing, specialized interpreters, validators, and output generators.
-
End-to-end latency and API cost increase compared to a single-pass chat approach because the productive semantic slice now performs a structural and a final extraction pass.
-
The team must define authoritative-value roles, provenance rules, source-conflict rules, and orchestration budgets.
-
Existing references to
formats.zugferdas the core conversion boundary must be migrated in code and architecture documentation.
-
Related
-
ADR-34: EER-Owned Format-Neutral Invoice Model as Conversion Core — replaces this pipeline shape with an application-owned invoice model and explicit edge adapters
-
No separate PDR — accepted directly in architecture discussion on 2026-05-01
-
Benchmark analysis from 2026-05-01 (
build/reports/llm-benchmark/) -
ADR-2: OCR + LLM Extraction over Rule-Based Mapping — historical OCR + LLM extraction no longer defines the productive native-text PDF path
-
ADR-8: LLM Returns JSON, Code Renders XML and Computes Totals — deterministic structure derivation and rendering remain important downstream concerns
ADR-17: Native Document Interpreters with Xberg, PDFBox, and Apache POI
- Status
-
Accepted (2026-05-12) and implemented (2026-05-15) with local PDF document-text extraction for supported native-text PDFs; migrated from Kreuzberg to MIT-licensed Xberg in 2026-07. Refined by ADR-34: EER-Owned Format-Neutral Invoice Model as Conversion Core (2026-07-16) for input-adapter ownership and on 2026-07-17 to combine complementary plain and layout-aware page renderings; the interpreter technology and fail-closed scope remain active. Source: Issue #483: Replace Mistral OCR with local PDF text extraction.
Context
The former extraction path assumed provider OCR as the first step (ADR-2: OCR + LLM Extraction over Rule-Based Mapping), even though EER does not support scanned PDFs. The implemented replacement must preserve page-structured evidence for prompts and semantic parsing while preparing future Word and Excel inputs. A generic text-only converter loses too much positional meaning, while unclear or restrictive licenses would create unnecessary product risk for a core input capability.
Decision Drivers
-
Preserve layout-aware evidence for prompts and downstream semantic checks
-
Remove OCR cost and dependency from the supported native-text PDF path
-
Keep the input side compatible with future Word and Excel adapters
-
Prefer permissive JVM-native libraries with predictable operational footprint
-
Fail closed for unsupported scanned or image-only documents
Considered Options
| Option | Pros | Cons |
|---|---|---|
Keep provider OCR as the universal document reader |
Handles scanned PDFs and keeps one extraction shape across inputs. |
Over-scoped for EER, adds external latency and cost, fits future Word/Excel poorly, and keeps layout evidence tied to provider output. |
Use a generic cross-format extraction framework or converter |
One facade for many formats. Fast to prototype. |
Either loses too much layout semantics, adds native/runtime complexity, or introduces unclear/restrictive licensing for a core capability. |
Use application-local format interpreters: Xberg now for PDF text extraction, PDFBox for PDF validation/canonical evidence, Apache POI for future Office formats |
Keeps page-structured evidence local, stays JVM-native, and aligns with ADR-16: Format-Neutral Invoice Conversion Pipeline input interpreters without routing supported PDFs through provider OCR. |
Requires multiple adapters and our own heuristics for prompt evidence, reading order, blocks, and tables. Scanned PDFs stay unsupported. |
Decision
Adopt native, format-specific input interpreters as the main evidence source.
-
Supported native-text PDF extraction derives two complementary page renderings locally through
XbergPdfStructuredTextExtractorbefore the semantic chat call: plain text first and layout-aware Markdown second. -
Both Xberg configurations use page extraction with OCR disabled. The Markdown configuration additionally enables layout detection and layout-based rendering. The application combines only
PageContent.content; it does not appendPageContent.tables, because Markdown already renders each detected table once. -
Plain text recovers labels and address blocks that layout detection can omit or fragment. Markdown preserves row and column associations that plain output loses. Prompts treat them as two renderings of the same page and must not duplicate facts or lines.
-
Xberg’s separate reading-order post-processing option remains disabled. Enabling it changed neither native Markdown nor layout-aware Markdown output across the 34-PDF benchmark corpus and added no evidence quality.
-
PdfCanonicalEvidenceInterpreterkeeps Apache PDFBox as the PDF validation and canonical-evidence boundary, and auxiliary plain-text extraction also remains local inside the application process. -
Provider OCR is removed from the supported native-text PDF path. The outbound provider dependency remains only on the semantic chat step.
-
Future Word and Excel input interpreters will use Apache POI when those formats enter implementation scope.
-
Generic conversion tools may be used for benchmarking or research, but not as the primary production dependency for semantic evidence extraction.
-
Image-only or scanned PDFs remain out of scope and must fail closed instead of triggering a new OCR path by default.
Evidence Configuration Characterization
The 2026-07 Xberg 1.0.0-rc.29 characterization ran each candidate against all 34 benchmark PDFs with OCR disabled. All files remained native-text extractions.
| Configuration | Corpus result | Finding |
|---|---|---|
Plain page content alone (former production configuration) |
34/34 extracted |
Preserved labels and address blocks but discarded native table structure and separated some visible labels from their values. Retained only as the first half of the combined evidence. |
Markdown with native table extraction |
34/34 extracted |
Rendered Xberg’s native tables once, but did not recover all visually structured headers and line-item tables in the Coolblue and Der Spiegel fixtures. |
Markdown with layout detection and reading order |
34/34 extracted |
Preserved the required Coolblue and Der Spiegel header associations and reconstructed their line-item tables. This evidence shape was selected, except for reading order. |
Markdown with layout detection, without reading order |
34/34 extracted |
Produced byte-identical output to the preceding candidate across the corpus. Selected as the table-preserving half of the combined evidence; plain text precedes it to recover omitted labels. |
Layout inference requires the RT-DETR and TATR ONNX models.
The immutable backend builder and runtime bases preload revision c6bf493e2f7b0b9a29a5870da9880c14e20ff0a3 from xberg-io/layout-models and verify these content hashes during the image build:
-
RT-DETR
3bf2fb0ee6df87435b7ae47f0f3930ec3dc97ec56fd824acc6d57bc7a6b89ef2 -
TATR
c11f4033da75e9c4d41c403ef356e89caa0a37a7d111b55461e7d5ba856bb6b6
XBERG_CACHE_DIR points to the image-owned, read-only model directory.
CI verifies the model hashes and runs a Coolblue extraction as the unprivileged user in the final backend image with networking disabled.
It also repeats the complete native extraction regression suite offline in the builder image, so a missing model fails before deployment rather than downloading during a conversion request.
A representative offline Linux arm64 builder-container probe used the one-page Coolblue fixture. Plain extraction took 0.70 seconds and reached about 311 MiB RSS. Layout-aware extraction took 1.45 seconds cold and about 0.46 seconds warm; RSS stabilized around 1,000 MiB after the second extraction, with a 1,007 MiB peak across six extractions. The measured steady-state memory increase over plain extraction was about 689 MiB. Production x86_64 measurements may differ, so these values are capacity-planning baselines rather than an SLA.
Consequences
- Positive
-
-
The supported PDF path removes one external OCR API call, reducing latency, cost, and provider coupling.
-
Combined page-structured plain text and layout-aware Markdown become an internal contract EER can tune for prompt design and semantic parsing without appending Xberg’s table collection a second time.
-
Future Word and Excel support fits the same format-neutral pipeline without forcing everything through OCR or markdown.
-
Xberg’s MIT license reduces license risk compared with the former Kreuzberg v4 ELv2 dependency.
-
- Negative
-
-
Local PDF semantics now span more than one interpreter technology (
Xbergfor structured text,PDFBoxfor canonical evidence and auxiliary text,POIfor future Office input). -
Xberg still returns text-oriented page content rather than a perfect semantic structure; EER must own prompt evidence shaping, heuristics, and benchmark quality.
-
Each supported PDF is interpreted in both plain and layout-aware modes. Layout inference still dominates runtime and adds about 190 MiB of immutable model data plus a higher CPU and memory cost than plain extraction alone.
-
ADR-2: OCR + LLM Extraction over Rule-Based Mapping is superseded in part: provider OCR no longer defines the preferred path for supported native-text documents.
-
Scope after ADR-34
ADR-34: EER-Owned Format-Neutral Invoice Model as Conversion Core keeps the native interpreter choice active but moves target-neutral PDF/LLM extraction behind an application-owned analysis port instead of leaving it under the ZUGFeRD output package.
Failing closed means that image-only PDFs do not enter automatic semantic extraction or target handover; the Clarula flow may still accept a technically valid PDF and route it to REVIEW_REQUIRED under its approved product contract.
Related
-
Issue #483: Replace Mistral OCR with local PDF text extraction
-
ADR-2: OCR + LLM Extraction over Rule-Based Mapping — superseded in part for supported native-text documents
-
ADR-34: EER-Owned Format-Neutral Invoice Model as Conversion Core — defines the application-owned invoice model and input boundary that these interpreters feed
-
ADR-16: Format-Neutral Invoice Conversion Pipeline — superseded historical format-neutral pipeline decision
ADR-18: LLM-Only Semantic Extraction with Specialized Parallel Calls
- Status
-
Partially superseded by ADR-22: Diagnosis-First Validation Failure Routing (2026-05-26) for direct edit-screen recovery and by ADR-34: EER-Owned Format-Neutral Invoice Model as Conversion Core (2026-07-15) for the semantic contract, intermediate representation, application handoff, deterministic-code boundary, and direct ZUGFeRD rendering path. The six-call extraction topology, retry, and immutable-evidence decisions accepted on 2026-05-16 remain active; this ADR still supersedes ADR-8: LLM Returns JSON, Code Renders XML and Computes Totals and the remaining LLM-extraction parts of ADR-2: OCR + LLM Extraction over Rule-Based Mapping.
Context
The current extraction pipeline (ADR-8: LLM Returns JSON, Code Renders XML and Computes Totals) splits work between two sequential LLM calls (DocumentSemantics, ExtractedInvoice) and a sizeable amount of deterministic Kotlin: InvoiceTotalsCalculator computes line totals, tax bases and grand totals; ZugferdXmlNormalizer and StandardInvoiceInterpreter patch and reconcile the extracted facts before XML rendering.
Two structural problems have emerged from running this pipeline against real-world German invoices:
-
Deterministic normalization cannot scale to the variance of real PDFs. Date formats, currency notations, country names, payment-term phrasings, tax-rate presentations, and table layouts vary across thousands of invoicing tools. Each new edge case adds a special-case branch to the normalizer. The code grows without converging.
-
Code-side arithmetic conflicts with the product promise. EER is a conversion service: the resulting ZUGFeRD XML must reflect what is printed on the PDF, not what the code recomputes. When our recomputed totals disagree with the PDF, the source of truth is the PDF, not our calculator. Today’s calculator can silently produce a "correct" XML that disagrees with the user’s actual invoice.
The semantic interpretation of arbitrary PDF layouts is inherently a language task. LLMs handle this variance well; deterministic code does not. The sequential two-call design also leaves latency on the table: the document semantics pass blocks the extraction pass even though most fields are independent.
Decision Drivers
-
Faithfulness to the source PDF: extracted values must match what the user sees
-
Bounded prompt complexity: each prompt must stay simple enough to reason about and to keep LLM error rates low
-
Reasonable latency: parallel work where independence allows it
-
Complete diagnostic picture: users must see all findings at once, with recovery routed by ADR-22: Diagnosis-First Validation Failure Routing rather than one error per retry
-
Clear separation between user-correctable validation errors and infrastructure failures
-
Maintainability: a single developer must be able to add a new fact category without touching unrelated code
Considered Options
| Option | Pros | Cons |
|---|---|---|
Keep ADR-8 architecture, harden normalizer |
No structural change. Existing tests stay relevant. |
Normalizer grows unbounded with each new layout variant. Code-computed totals continue to diverge from PDF reality. Sequential calls remain the latency floor. |
Single large LLM call returning the full invoice |
One prompt, one round-trip, simple wiring. |
Prompt complexity becomes the bottleneck. One mistake in any field corrupts the entire response. Few-shot examples for all fact categories blow up the context window. |
Specialized LLM calls with no broad code-side interpretation or reconciliation (this ADR) |
Each prompt stays focused. Failures are isolated to one fact category. Independent structured calls reduce wall-clock latency. The PDF remains immutable evidence. Kotlin is limited to mapping, provenance, deterministic consistency checks, target assembly, and validation. |
Total token cost increases because the full PDF text is sent to every call. Cross-call consistency must be validated explicitly. |
Decision
Replace the sequential two-call extraction with six specialized LLM calls in three stages. Header, parties, line items, and payment terms execute concurrently against the full text from XbergPdfStructuredTextExtractor. Totals and tax then receives the same text plus line-item context, followed by notes with all five structured slices as gap-filling context.
The six calls and their responsibilities:
| Call | Responsibility |
|---|---|
|
Document type (invoice/credit note), invoice number, issue date, delivery date or period, currency, buyer reference, order reference, contract reference. |
|
Seller and buyer: legal name, address, country code, VAT ID, tax number, contact details. |
|
Complete semantic invoice lines: inclusion, description, billed quantity and unit, VAT-exclusive BT-146 and BT-148 prices, BT-147 discount, BT-131 line amount, adjustments, VAT, periods, and references. The prompt interprets VAT-inclusive source layouts and amount-only rows. |
|
One coherent semantic model for BT-106 through BT-115, VAT breakdowns, and document-level adjustments, using semantic line items as context. |
|
IBAN, BIC, due date, payment terms text, SEPA mandate references, discount terms, payment payee (when different from seller). |
|
Free-text document notes (Kleinunternehmer-Hinweise, delivery notes, legal disclaimers, footers) preserved verbatim from the PDF, excluding facts already represented by the five structured slices. |
Each call returns typed JSON via Mistral Structured Outputs. The prompts contain interpretation instructions plus lexical formatting requirements such as ISO dates, ISO country codes, decimal numbers, and recognized tax categories. Context-dependent meaning belongs to the prompts. This includes line recognition, implicit quantity, included rows, amount-only services and credits, net-versus-VAT-inclusive interpretation, and gross-to-net conversion.
The combined provider DTO contains one semantic value per invoice concept. It has no parallel source, net, gross, or calculated monetary fields. Kotlin maps these values to the application-owned model, binds provenance, checks deterministic relationships, and maps validated semantics to targets. It never creates an extraction-time unit price through line amount divided by a defaulted quantity.
After all six calls return, the pipeline maps them to ObservedInvoice and InvoiceDraft, then applies:
-
Structured-output validation for each call’s JSON contract.
-
Common semantic validation of required values, ambiguity, and deterministic relationships.
-
Target readiness and conformance validation, including XSD and EN 16931 Schematron validation for ZUGFeRD.
All validation errors from all three stages are collected into a single result. Under ADR-22: Diagnosis-First Validation Failure Routing, findings go to diagnosis first: source defects require source correction and resubmission, while only recognition or mapping errors may open the editor exception path. If all stages pass, the XML is embedded into the source PDF and returned.
Infrastructure failures (LLM timeouts, provider 5xx after retry, rate-limit exhaustion that the RateLimitedClient cannot drain) are not validation errors. They take the regular exception path with a user-facing "please try again" response. Each LLM call retries once on failure; a second failure aborts the entire conversion.
The first four calls execute concurrently using Kotlin coroutines (async/awaitAll). A stage-1 failure cancels its siblings and skips both later calls. Otherwise totals and tax runs serially with TotalsAndTaxContext, followed by notes with NotesContext. The existing RateLimitedClient handles Mistral budget queuing for all six calls transparently.
Consequences
- Positive
-
-
The PDF remains immutable evidence, while the application model contains one semantic interpretation. A target cannot silently replace or reconcile contradictory values.
-
Each prompt covers one fachliche concern and stays short enough to reason about. Few-shot examples per call become cheap.
-
Failure isolation: a regression in the parties prompt cannot break line-item extraction.
-
Four independent concerns execute concurrently; wall-clock latency is their slowest call plus the context-aware totals and notes calls, rather than six sequential calls.
-
ZugferdXmlNormalizer,StandardInvoiceInterpreter, andInvoiceTotalsCalculatorare removed from the active pipeline. The deterministic surface area for layout-specific bugs collapses. -
Adding a new fact category means adding one specialized class and one prompt, with no risk to the existing calls.
-
Users always see the complete list of diagnosable findings on the first attempt instead of discovering one finding per retry.
-
- Negative
-
-
Token cost per conversion increases: the full PDF text is sent to six calls instead of two. Expected to remain within an acceptable per-conversion cost envelope for the current free and trial workload; will be revisited if cost becomes material.
-
Cross-call consistency must be checked explicitly by common and target validation. Drift surfaces as a diagnosis finding instead of being silently reconciled.
-
Four concurrent stage-1 calls put more pressure on the Mistral budget.
RateLimitedClientwill queue more often during traffic spikes; totals and notes add two serial latency steps. -
The team gives up code-side layout interpretation and calculated replacement totals. If semantic extraction is wrong but internally consistent, target validation may not detect it; benchmark coverage and diagnosis remain essential.
-
Existing
*LlmTestbenchmarks must be rewritten against six specialized extractors instead of one combined extraction.
-
- Neutral
-
-
The
StructuredDocumentEvidence/DocumentSemanticsdistinction from ADR-8: LLM Returns JSON, Code Renders XML and Computes Totals goes away. The Xberg-extracted text is the only intermediate representation. -
Kotlin does not infer semantics from arbitrary source text. It maps, fingerprints, checks deterministic relationships, and validates targets.
-
Scope after ADR-22 and ADR-34
The specialized typed LLM calls, retry behavior, infrastructure-error handling, immutable source evidence, and prohibition on silent document-total reconciliation remain active.
ADR-22: Diagnosis-First Validation Failure Routing supersedes only the original direct-to-editor recovery: all findings are diagnosed first, and the editor may revise a draft only for recognition or mapping errors.
ADR-34: EER-Owned Format-Neutral Invoice Model as Conversion Core introduces SourceEvidence, application-owned invoice states, and a semantic-only provider contract. It supersedes deterministic gross-to-net conversion, parallel monetary fields, normalization pipelines, and ExtractedInvoice as the direct application-to-ZUGFeRD handoff.
Extractor DTOs map once into application-owned observations. Kotlin validates but does not reinterpret PDF layout, infer missing unit prices, or repair contradictory invoice semantics.
Target-specific defaults, calculations, and code mappings remain behind output adapters.
Related
-
ADR-22: Diagnosis-First Validation Failure Routing (Diagnosis-First Validation Failure Routing) — supersedes direct edit-screen recovery.
-
ADR-34: EER-Owned Format-Neutral Invoice Model as Conversion Core (EER-Owned Format-Neutral Invoice Model as Conversion Core)
-
ADR-2: OCR + LLM Extraction over Rule-Based Mapping (OCR + LLM Extraction over Rule-Based Mapping) — fully superseded; the remaining "LLM extracts semantics" principle from ADR-2 is restated and refined here.
-
ADR-8: LLM Returns JSON, Code Renders XML and Computes Totals (LLM Returns JSON, Code Renders XML and Computes Totals) — superseded. The "code computes totals" part is reversed; the "LLM returns typed JSON, code renders XML" part is preserved and extended.
-
ADR-17: Native Document Interpreters with Xberg, PDFBox, and Apache POI (Native Document Interpreters with Xberg, PDFBox, and Apache POI) — the interpreter choice remains active; ADR-34: EER-Owned Format-Neutral Invoice Model as Conversion Core refines its package ownership.
-
Runtime View: Invoice Extraction Algorithm — documents the current three-stage extraction sequence.
-
Cross-cutting Concept: LLM Integration — documents the specialized extraction pattern.
ADR-19: Per-Environment Terraform State Bucket for Production
- Status
-
Accepted (2026-05-21). Refines ADR-15.
Context
ADR-15 established Hetzner Object Storage as the shared remote backend for Terraform state and standardized one bucket, invoicex-tf, with environments separated by S3 key prefix (terraform/dev/…, terraform/prod/…).
That decision targeted "shared across operators," not "shared across environments," and dev was the only consumer at the time.
Standing up the new production environment under deployment/environment/prod/ forced the question that ADR-15 left open.
Hetzner Object Storage access keys are project-scoped and Hetzner does not yet expose bucket-policy controls strong enough to enforce per-prefix isolation cryptographically.
A leaked dev S3 credential would therefore be able to read or tamper with the production state object stored under the prefix in the shared bucket.
The same blast-radius argument was just applied to the production SSH key (invoicex-prod-deploy, separate from the dev Hetzner_invoiceX); the S3 state layer needs the matching treatment.
Production has never run terraform apply against the shared bucket, so the cost of moving prod to its own bucket is creation only — no state migration.
Decision Drivers
-
Limit blast radius if production or dev S3 credentials leak independently
-
Keep the isolation posture between dev and prod consistent across SSH, secrets, and state
-
Avoid state migration cost by deciding before production state exists
-
Stay within the s3-on-Hetzner-Object-Storage pattern and bootstrap-outside-Terraform principle established by ADR-15
Considered Options
| Option | Pros | Cons |
|---|---|---|
Keep one bucket, isolate by S3 key prefix |
Matches ADR-15 as originally written. One bucket to monitor. |
Any S3 credential in the Hetzner project can read or write across prefixes today; isolation depends on operator discipline, not on enforced ACL or policy. Inconsistent with the SSH-key isolation we just adopted. |
Per-environment bucket ( |
Hard name-level isolation. Bootstrap script can apply per-bucket versioning, lifecycle, and access-block. Parallel to the SSH-key isolation already in place. No state migration because prod is unprovisioned. |
Two buckets to monitor instead of one. ADR-15 needs the cross-reference. Dev remains in the shared-bucket pattern until separately retrofitted. |
Per-environment Hetzner project |
Strongest isolation; separates billing, API tokens, and Object Storage credentials at the project boundary. |
Significant operational lift well beyond the current bootstrap. Touches token rotation, DNS-zone ownership, and CI secrets across multiple repos. Defer until the team is larger than one operator. |
Decision
Production Terraform state moves to its own Hetzner Object Storage bucket, invoicex-tf-prod.
Dev keeps invoicex-tf for now; retrofitting dev to invoicex-tf-dev is deferred because it requires terraform init -migrate-state against the live dev state and coordinated updates to dev README and any CI workflows that reference the bucket name.
Bucket creation and post-creation hardening live in deployment/environment/prod/bootstrap-state-bucket.sh, idempotent and driven from 1Password (op://invoicex_prod/terraform-state-s3/{access_key_id,secret_access_key}).
The script enables versioning and sets a lifecycle rule expiring noncurrent state versions after 90 days.
It deliberately does not enable AWS S3 Public Access Block because Hetzner Object Storage currently rejects regular private PutObject calls with that setting enabled; the working dev state bucket also runs without it.
Hardening calls that Hetzner Object Storage does not implement degrade to warnings, not failures, so the script stays usable as Hetzner’s S3 surface evolves.
deployment/environment/prod/backend.hcl is updated to point at the new bucket; everything else in the backend block (key, region, endpoint, use_lockfile) stays unchanged.
The bucket and its credentials remain bootstrap infrastructure outside the Terraform state they protect, per ADR-15.
Consequences
- Positive
-
-
A leaked dev S3 credential can no longer reach the production state object.
-
The state-bucket lifecycle (creation, versioning, lifecycle, access-block) is scripted, reviewable in a PR, and re-runnable.
-
Bootstrap posture for production is now symmetric across SSH, secrets resolution, and remote state.
-
- Negative
-
-
Two state buckets to monitor and operate instead of one.
-
Dev remains in the legacy single-bucket pattern until a follow-up migrates it to
invoicex-tf-dev. -
Isolation today is name-based; until Hetzner Object Storage exposes bucket policies, an attacker with project-level S3 credentials can still cross buckets within the project. The protection is meaningful (no accidental prefix leakage, credential-segmentation by environment in 1Password) but not cryptographic.
-
ADR-20: Defer GitHub Merge Queue until Required CI is Enforced
- Status
-
Accepted (2026-05-22).
Context
GitHub merge queues can protect a busy integration branch from breakage by testing a pull request together with the latest target branch and any queued pull requests before the merge lands. They are most useful when many independently approved pull requests compete for the same protected branch.
InvoiceX currently uses main as the integration branch.
Qualifying pushes to main auto-deploy to the dev environment, while production promotion remains manual per ADR-14.
The default branch ruleset currently prevents deletion and non-fast-forward updates, but it does not yet require pull requests or required status checks.
The relevant GitHub Actions workflows run on pull_request; required checks in a merge queue would also need to run on merge_group, otherwise queued merges would wait for checks that never report.
Some workflows are path-filtered and comparatively expensive, especially full-stack E2E and visual-regression checks. Making those checks required without a deliberate required-check strategy can block documentation-only or unrelated pull requests with permanently pending checks.
Decision Drivers
-
Keep
mainprotected without adding CI complexity before the basic branch policy exists -
Avoid duplicate or unnecessary CI runs for a repository with modest merge throughput
-
Preserve fast Dependabot and feature-branch feedback while preventing broken integrated changes
-
Keep the dev-first deployment flow simple and traceable
Considered Options
| Option | Pros | Cons |
|---|---|---|
Enable merge queue now |
Tests queued changes against the latest |
Requires updating all required workflows for |
Require up-to-date branches without merge queue |
Simple GitHub setting and familiar workflow. |
Forces authors to update branches and re-run checks manually. Slower than merge queue under Dependabot or high PR concurrency. |
Defer merge queue and first establish required branch checks |
Keeps process simple now. Lets us define a small, reliable required-check set before adding queue semantics. Avoids path-filtered pending-check traps. |
|
Decision
We will not enable GitHub merge queue on main yet.
Before enabling it, we will first establish the baseline branch policy for main:
-
require pull requests for changes to
main -
define the required status checks explicitly
-
ensure every required GitHub Actions workflow also runs on
merge_group -
avoid making path-filtered workflows required unless they have a non-skipped reporting strategy
When PR throughput or Dependabot churn makes stale-branch merges a recurring problem, we will revisit merge queue with conservative settings:
-
squash merge method, matching the repository setting
-
build concurrency of
1or2 -
maximum merge group size of
1initially -
required checks limited to the stable core CI set
Consequences
- Positive
-
-
The current GitHub rules stay understandable while the repository has modest merge concurrency.
-
We avoid a queue that fails because required checks do not run on
merge_group. -
We can design required checks deliberately before coupling them to merge queue behavior.
-
- Negative
-
-
Sequential PR merges can still interact badly if the combined result is not tested before the second merge lands.
-
Dependabot batches may still require manual sequencing when several dependency updates are ready at once.
-
- Neutral
-
-
The dev deployment workflow remains push-driven from
main; merge queue adoption would not change the production-promotion model from ADR-14.
-
Compliance
Merge queue must stay disabled for main until the ruleset contains explicit required checks and each required workflow supports the merge_group event.
ADR-21: Partner Affiliation Confirmation via One-Time Email Token
- Status
-
Accepted (2026-05-24). Drives implementation of PDR: Partner Affiliation Verification for Partner-Origin Trials.
Context
Partner widgets are publicly reachable. Users who register through a partner page start a regular 30-day trial, but only real clients of the partner Kanzlei should be moved into the partner tariff after the Kanzlei confirms the requested affiliation.
The tax advisor must confirm the client relationship without receiving invoice data, conversion results, usage history, or account access. The first pilot partner accepts an email-based confirmation flow and needs full name or company name plus email address to identify the mandate.
Decision Drivers
-
Keep the tax advisor a recommender, not an account manager
-
Notify the partner only after explicit user request and confirmed email ownership
-
Minimize disclosed data and avoid partner access to user accounts or invoices
-
Avoid building a partner dashboard before partner-channel demand is proven
-
Make confirmation auditable while keeping partner effort minimal
Considered Options
| Option | Pros | Cons |
|---|---|---|
One-time email token sent to configured partner address |
Low partner effort. Fits existing email architecture. No dashboard required. Keeps confirmation narrowly scoped. |
Email is a bearer channel. Requires high-entropy tokens, expiry, and single use. |
Partner dashboard with authenticated login |
Stronger control and better future client-management foundation. |
Adds partner identity, onboarding, support, and UI scope before demand is proven. Conflicts with v1 non-goal of partner management. |
Manual founder/admin confirmation |
Smallest technical surface. No partner-facing confirmation endpoint. |
Does not scale. Creates operational bottleneck and weak audit trail between user request and partner answer. |
Decision
Use a one-time, expiring email token for partner affiliation confirmation.
The widget collects the user’s full name or company name only after the user explicitly requests Kanzlei-Zuordnung. The system sends the partner notification only after the user confirms email ownership through DOI. The notification goes to the partner-specific affiliation email address configured in the admin portal.
The token is opaque, high entropy, stored hashed server-side, single-use, and scoped only to confirming the affiliation request.
Opening the confirmation link with GET confirms the affiliation directly and renders a success, used, expired, or invalid result page.
When the affiliation is confirmed, the existing account is moved from Plan.FREE_TRIAL to Plan.PARTNER.
The transition clears trialStartDate and trialEndDate, records partnerAffiliationConfirmedAt, and publishes a dedicated PlanAssignedEvent for the partner-tariff assignment.
Partner-origin metadata remains attribution and eligibility metadata. It does not grant the partner access to invoices, conversion results, usage history, billing state, or account settings.
Consequences
- Positive
-
-
Partners can confirm affiliation with one email action and no dashboard onboarding.
-
The disclosed data is limited to full name or company name, email address, and request context.
-
The mechanism fits the existing monolith, partner registry, and email delivery architecture.
-
Confirmed users are switched into the partner tariff immediately without a separate back-office step.
-
- Negative
-
-
Whoever controls the partner mailbox can use the confirmation link until it expires.
-
Wrong or stale partner notification addresses can disclose personal data to the wrong recipient.
-
Automated link opening by mail clients or scanners can trigger the confirmation, because the action happens on
GET. -
Future partner self-service or revocation workflows may require a stronger authenticated partner area.
-
ADR-22: Diagnosis-First Validation Failure Routing
- Status
-
Partially superseded by ADR-34: EER-Owned Format-Neutral Invoice Model as Conversion Core (2026-07-15) for the canonical correction state. Diagnosis-first routing accepted on 2026-05-26 remains active, supersedes the direct edit-screen recovery in ADR-18: LLM-Only Semantic Extraction with Specialized Parallel Calls, and continues to implement PDR: Diagnose-first Correction Flow.
Context
Validation failures currently lead users toward the browser editor. That default suggests that users should repair structured XML data, even when the real problem is a missing or inconsistent source invoice. For hybrid invoices this is risky because the visible PDF and the structured invoice data can diverge.
The product decision establishes source-invoice correction as the default path. The manual editor remains necessary, but only for genuine recognition or mapping errors where the visible PDF is already correct.
Decision Drivers
-
Preserve consistency between visible invoice PDF and structured data
-
Keep the user’s existing invoice tool as the default correction place
-
Provide one shared validation-error entry point for email, website upload, and widget flows
-
Keep the manual editor available without making it the primary CTA
-
Make validation errors actionable without exposing validator rule codes first
-
Support product analytics for diagnosis, retry, and editor-exception behavior
Considered Options
| Option | Pros | Cons |
|---|---|---|
Keep edit-first routing |
Lowest implementation effort. Reuses existing |
Keeps the wrong product default. Encourages PDF/XML divergence. Does not explain source-invoice problems clearly. |
Add diagnosis text inside the editor |
Reuses current route and form. Lets users continue editing when needed. |
The full editor still dominates the page. The source-invoice path remains secondary. |
Add dedicated |
Gives validation failures their own user journey. Keeps source correction primary and editor entry deliberate. Works for email, website, and widget origins. |
Adds another token-based page and route. Requires updating emails, website, widget, and runtime documentation. |
Render diagnosis only inline per channel |
Can optimize each channel’s UI independently. |
Duplicates diagnosis logic. Leaves email without a durable detailed page. Makes analytics and future changes inconsistent. |
Decision
All user-visible validation failures that have an edit session token will route first to a dedicated diagnosis page at /diagnosis/{token}.
Email templates, website upload validation states, and partner widget validation states will link to this diagnosis page instead of linking directly to /edit/{token}.
The diagnosis page is owned by invoice.editing.ui and reuses the InvoiceEditSession token model, persisted source PDF, versioned application draft, validation status, and validation errors.
It presents grouped, user-action-oriented validation errors and makes source-invoice correction the primary path.
The existing /edit/{token} route remains available as the manual recognition-error path, but the diagnosis page must require an explicit user choice before linking to it.
Exact customer-facing copy, examples, and legal boundary wording follow the PDR and remain subject to legal/product copy review before release.
Consequences
- Positive
-
-
Users see validation failures as a guided checklist instead of a full invoice editor.
-
Source-invoice correction becomes the architecture default across email, website upload, and widget flows.
-
The editor remains useful for recognition errors without becoming a hidden invoice-authoring tool.
-
The shared route gives product analytics one consistent place to track diagnosis views, help expansion, retry intent, and editor exceptions.
-
- Negative
-
-
Validation-failure handling now spans more UI surfaces and must keep route contracts consistent.
-
The existing runtime scenario "Validation Failure with Edit-and-Retry" must be rewritten to diagnosis-first behavior.
-
Tests must cover both the diagnosis route and the still-supported editor exception path.
-
Scope after ADR-34
Diagnosis-first navigation, source correction as the default, token capabilities, and the editor exception path remain unchanged. After the correction cutover, every session persists a versioned application-owned invoice draft instead of using generated ZUGFeRD XML as editable state. The cutover deletes existing XML-backed sessions because their generated artifacts cannot reconstruct trustworthy source evidence. XML parsing remains only at target-output boundaries, never in the correction path.
Related
-
ADR-4: Email-Based Interface as Primary Channel — email remains a primary channel and now links to diagnosis for validation failures
-
ADR-6: Package-by-Feature Organization — diagnosis code belongs to the
invoice.editing.uifeature package -
ADR-34: EER-Owned Format-Neutral Invoice Model as Conversion Core — source evidence remains authoritative while correction operates on the application invoice model
-
ADR-16: Format-Neutral Invoice Conversion Pipeline — superseded historical source-fidelity and format-neutral pipeline decision
ADR-23: Widget Result Delivery by Standard Success Email
- Status
-
Accepted (2026-05-26), amended (2026-07-10). Supersedes the direct-download parts of ADR-12: Anonymous Conversion Persistence with TTL.
Context
The partner widget originally planned an upload-first flow where users authenticated with a widget session and then downloaded generated PDFs directly from the partner page. That design required download tokens, polling, and download endpoints in addition to the existing email delivery path.
The product still needs double opt-in for unknown email addresses.
At the same time, existing accounts must not receive a magic login link just because they entered an email in a partner widget.
The system already has a proven result-delivery path: ResponseMailService.sendSuccessMail(…) sends the clean ZUGFeRD PDF, verification PDF, and edit link.
The initial implementation deliberately forgot the entered email after every conversion. Returning users therefore had to enter the same address repeatedly on the same partner page and browser. This avoidable prompt can be removed without restoring browser downloads or granting access to the customer portal.
Decision Drivers
-
Keep DOI for new users before the first result is sent.
-
Avoid logging existing accounts in from a third-party widget email entry.
-
Reuse the established success email and attachment naming behavior.
-
Remove repeated email entry on the same partner page and browser.
-
Keep remembered identity revocable and strictly partner-scoped.
-
Keep anonymous upload persistence and TTL from ADR-12: Anonymous Conversion Persistence with TTL.
Considered Options
| Option | Pros | Cons |
|---|---|---|
Require the email address for every conversion |
No persistent browser identity state. |
Returning users repeat the same step for every invoice. |
Remember a partner-scoped identity and continue delivering by email |
Removes repeated email entry while retaining DOI, standard email delivery, revocation, and a narrow capability boundary. |
A bearer token exists in partner-origin browser storage and can be read by scripts on that origin. |
Restore widget sessions and direct downloads |
Best same-page result UX. |
Adds download authorization, polling, and a substantially larger security surface. |
Decision
The widget delivers results only through the standard success email and does not expose direct result downloads. It may remember a narrowly scoped widget identity for future email deliveries from the same partner:
-
If the email belongs to an existing account,
POST /api/v1/widget/authclaims the conversion, sends the result email, creates an activeWidgetIdentitySession, and returns its opaque token. -
If the email is unknown,
POST /api/v1/widget/authstarts DOI and returns a provisional token. Only its hash is stored, and the provisional session is bound to that exactPendingRegistration. Successful DOI confirmation links only those sessions to the newly created account and removes their technical confirmation expiry. -
The widget stores the token in partner-origin
localStorage, keyed by API origin and partner ID. It stores neither the email address nor invoice data. -
On a later conversion,
POST /api/v1/widget/claim-with-sessionaccepts the token as a Bearer credential and the current partner ID. The backend requiresrequest partner == session partner == conversion partner, resolves the fixed account email, claims the conversion, and sends the standard result email. -
On startup,
GET /api/v1/widget/session-identityresolves active tokens and unexpired provisional DOI tokens to their normalized fixed recipient. The widget displays a compact “Versand an …” row in every state but keeps the email only in memory;localStoragestill contains only the opaque token. The action says “wechseln” where another address can be entered immediately and “nicht mehr merken” where it only clears the remembered identity. Both clear only the current API-origin/partner token. -
Unknown, malformed, revoked, technically expired, inactive-partner, and partner-mismatched sessions return
401 widget_session_invalid. The widget clears only that partner’s stored token and falls back to the email form. -
Active sessions have no product-facing expiry by default. The persisted model retains nullable
expires_at,revoked_at, andlast_used_atfields for technical expiry, revocation, and later account-policy checks.
A widget identity session is not an application login. It cannot create a Spring Security context, mint customer or admin JWTs, access the portal, access correction pages, or download invoice artifacts. Its only authority is to send a widget conversion belonging to the same partner to the email of its fixed account. The widget does not poll for authentication state.
Consequences
- Positive
-
-
Returning users can request later partner-widget results without entering their email again.
-
Result delivery still reuses the same email, attachments, edit links, and DOI rules as other widget deliveries.
-
Tokens are high entropy, stored only as SHA-256 hashes server-side, partner-bound, revocable, and independently auditable.
-
Existing accounts still receive no magic login link from widget email entry.
-
New accounts still pass DOI before a provisional session becomes usable.
-
- Negative
-
-
Partner-origin scripts and XSS can read
localStorage; server-side partner checks and the narrow email-only authority are therefore mandatory. -
On a shared browser, the remembered identity sends later widget results to the previously confirmed mailbox until a user chooses another email or clears browser storage.
-
Session persistence and a dedicated claim endpoint add state and tests compared with asking for the email every time.
-
- Neutral
-
-
The anonymous conversion TTL still bounds how long unclaimed uploads are retained.
-
The correction UI remains outside the widget and uses
claim_tokenfor return-state restoration. After claim, the attached edit session may replace the result only for the same account and redelivers the corrected artifacts by standard email. -
Analytics continue to treat
WidgetClaimSucceededEventas result-email delivery rather than download completion.
-
ADR-24: Clarula Monorepo Structure and Neutral Backend Naming
- Status
-
Partially superseded by ADR-25: Static Nuxt Product Frontends for product frontend paths, ADR-26: Static Frontend OCI Images behind Shared Edge Caddy for frontend runtime packaging, and ADR-30: One BootJar with Product-Isolated Spring Child Contexts for the backend’s internal subproject and context structure; neutral backend location and monorepo naming remain active.
- Date
-
2026-07-09
Context
The repository started as the codebase for einfache-eRechnung.de and therefore used product-specific names such as website/, application/invoice-processor/, and InvoiceX in operational paths.
Clarula is now the umbrella brand and may host multiple products, while the backend remains a single Spring Boot monolith shared by those products.
The old structure made the einfache-eRechnung product look like the default product and made the Clarula site look like an add-on. It also coupled the backend’s repository location and Docker image names to one capability instead of the shared monolith.
Decision
Use a Clarula-oriented monorepo layout with neutral deployment-unit names:
-
applications/backend/for the shared Spring Boot monolith. -
applications/visual-tests/for Docker visual regression tests across product frontends and retained backend pages. -
applications/frontend/apps/clarula/for the complete Clarula product frontend. -
applications/frontend/apps/einfache-erechnung/for the complete einfache-eRechnung.de product frontend. -
demos/partner/for static partner demo pages. -
Docker service and image names use neutral units such as
backend,backend-base,backend-builder-base, andcaddy; ADR-26: Static Frontend OCI Images behind Shared Edge Caddy later packages each static product frontend as its own OCI image. -
Published container images live under the Clarula GHCR project namespace
ghcr.io/clarula/clarula/*.
The backend remains a monolith and keeps the existing package-by-feature structure.
Technical legacy identifiers that affect production state, such as the PostgreSQL database name, 1Password vault names, and INVOICEX_* configuration variables, may remain until a separate operational migration is justified.
Consequences
-
Repository navigation now reflects Clarula as the umbrella context and treats product sites equally.
-
CI, local development, E2E, visual tests, deployment Compose files, and agent guidance must use the new paths.
-
Future products should be added under explicit product/site/demo names instead of becoming implicit top-level defaults.
-
Historical product decisions may keep old names when they describe past decisions, but current instructions should prefer the new layout.
Related
-
ADR-30: One BootJar with Product-Isolated Spring Child Contexts — supersedes only the backend’s internal single-project structure.
ADR-25: Static Nuxt Product Frontends
- Status
-
Partially superseded by ADR-26: Static Frontend OCI Images behind Shared Edge Caddy for hosted delivery topology and ADR-33: Dedicated Static Operator Frontend on
admin.clarula.defor retaining backend-rendered admin presentation; static Nuxt generation and product boundaries remain active. - Date
-
2026-07-12
Context
The repository now supports two products, einfache-eRechnung and Clarula, on one shared Spring Boot backend. Their marketing sites are separate Hugo applications, while customer-facing application pages such as login, portal, diagnosis, and correction are rendered by the backend with Thymeleaf. This splits each product experience across technologies and deployment units and couples product presentation, branding, and frontend releases to the shared backend.
Both products need a continuous experience from marketing content and demos into their complete applications. They must remain independently deployable, while all business logic stays in the shared backend monolith. The team creates and maintains content as repository files with AI agents and does not need a runtime CMS. Runtime server-side rendering is not required.
Decision Drivers
-
Provide one continuous marketing and application experience per product and origin.
-
Keep product presentation and frontend releases independent from the shared backend.
-
Reuse frontend foundations without coupling the two products into one deployment artifact.
-
Preserve static delivery, low operational overhead, and search-engine-readable marketing content.
-
Keep all business logic in the Spring Boot monolith in accordance with ADR-5: Monolith-First Architecture.
-
Establish explicit, generated, and evolvable contracts between frontends and backend.
Considered Options
| Option | Pros | Cons |
|---|---|---|
Keep Hugo marketing sites and backend-rendered Thymeleaf applications |
Lowest migration effort. Marketing pages remain static. |
Splits each product experience across technologies and deployments. Keeps customer-facing presentation and branding coupled to backend releases. |
Keep Hugo for marketing and add separate client-side applications |
Retains the existing content sites. Separates application UI from the backend. |
Creates two frontend stacks per product. Shared navigation, design, demos, and conversion flows require coordination across applications. |
Build one runtime-configured multi-brand frontend |
Maximizes apparent code reuse and produces one frontend application. |
Couples product releases and encourages product-specific conditionals throughout routing, content, and presentation. A defect or deployment affects both products. |
Build one static Nuxt application per product in a shared workspace |
Unifies marketing and application UI per product, supports static content and interactive application routes, and allows selective reuse with independent deployments. |
Introduces Node.js and Nuxt as build-time technologies and requires migration from Hugo and Thymeleaf. Dynamic application routes need explicit static fallback routing. |
Use Nuxt with runtime SSR |
Supports request-time rendering and server-side data loading. |
Adds stateful frontend runtime processes and operational failure modes without a current requirement. |
Decision
Create a shared frontend workspace under applications/frontend/ with two independently buildable applications:
applications/frontend/
├── apps/
│ ├── einfache-erechnung/
│ └── clarula/
└── packages/
└── ... shared technical foundations
Each application uses Nuxt and Vue and owns the complete customer-facing product experience: marketing pages, repository-managed content, demos, registration and authentication UI, and product application routes. Product content, branding, navigation, and product-specific components remain inside the owning application. Shared packages are limited to capabilities used by both products, such as API clients, UI foundations, authentication integration, analytics integration, and test support.
Nuxt generates static HTML, JavaScript, and assets at build time. Production does not run a Node.js or Nuxt server. Marketing, content, and legal pages are pre-rendered as complete HTML; interactive application routes use client-side Vue code and may require JavaScript. Content remains versioned in the repository, without a CMS.
Caddy serves each product’s static output directly under its product origin:
-
https://einfache-erechnung.deserves the einfache-eRechnung frontend. -
https://clarula.deserves the Clarula frontend.
Caddy proxies backend operations on both origins under /api/v1/ to the shared Spring Boot backend.
All browser-facing backend APIs, including authentication operations, use this prefix.
The backend publishes an OpenAPI contract, and the frontend build generates TypeScript clients from it.
Version v1 denotes the first major contract generation; compatible changes within v1 remain additive, while breaking changes require a new major API path and a migration period.
Webhooks, actuator endpoints, and backend-rendered admin routes are not browser-facing product APIs and do not need this prefix.
The two frontend applications and the backend remain separate, independently versioned deployment artifacts. Independent deployment requires the backend to remain compatible with currently deployed frontend versions during contract migrations.
Customer-facing Thymeleaf views and their product assets will leave the backend as their flows migrate. The backend-rendered admin area remains in Spring Boot and is outside this frontend migration. Embeddable partner widgets also remain outside the Nuxt applications and continue to follow ADR-11: Embeddable Widget as Vanilla JS Web Component. Detailed product identity, JWT audience, and cross-product account rules require a separate decision when product identity requirements are settled.
This decision refines ADR-5: Monolith-First Architecture: the Spring Boot application remains the single business-logic monolith, while its stateless companions become complete product frontends instead of Hugo-only marketing sites.
It supersedes the sites/clarula/ and sites/einfache-erechnung/ target layout from ADR-24: Clarula Monorepo Structure and Neutral Backend Naming; the remaining neutral backend and monorepo naming decisions stay active.
Consequences
- Positive
-
-
Each product presents one continuous experience under one origin, without a visible marketing-to-application domain transition.
-
Marketing content, demos, and application flows share routing, layouts, components, and analytics within the owning product.
-
Frontend releases no longer require a backend release, and one product frontend can deploy without deploying the other.
-
Static output avoids additional frontend runtime processes and keeps marketing pages directly indexable.
-
Same-origin API access reduces CORS and cookie complexity.
-
OpenAPI-generated clients make frontend/backend contracts explicit and reduce manually duplicated transport types.
-
The shared workspace enables reuse without creating one multi-brand deployment artifact.
-
- Negative
-
-
Both Hugo sites and all customer-facing Thymeleaf flows must be migrated.
-
The team must maintain a Node.js/Nuxt build toolchain and Nuxt-specific test infrastructure.
-
Caddy needs explicit fallback rules for dynamic client-side routes while preserving real
404responses for unknown content routes. -
Independent deployments require additive API evolution, compatibility checks, and coordinated removal of obsolete API versions.
-
Direct static hosting through Caddy requires atomic artifact deployment and rollback handling.
-
Some duplication between product applications is intentional to preserve product ownership and deployment independence.
-
- Neutral
-
-
Thymeleaf remains a backend dependency while the admin area remains server-rendered.
-
Product sessions are naturally separated by their origins, but the persistence model for users shared across products remains undecided.
-
Embeddable widgets retain their separate framework-independent architecture and release contract.
-
ADR-26: Static Frontend OCI Images behind Shared Edge Caddy
- Status
-
Partially superseded by ADR-27: Promotion-Only Production Delivery
- Date
-
2026-07-13
Context
ADR-25: Static Nuxt Product Frontends established two independently generated static Nuxt product frontends and direct file delivery through Caddy.
The implementation packages .output/public/ as archives, installs versioned release directories on each host, bind-mounts them into Caddy, and keeps product-specific file routing in the shared edge configuration.
The deployment platform otherwise delivers immutable OCI images.
Host-installed frontend files create a second release mechanism, add host backup and symlink state, and place product-specific cache, fallback, and 404 behavior in shared Caddy configuration.
We want each frontend artifact to contain both its generated files and its product-owned web-server policy while retaining one shared security edge and a simple full-runtime production release.
Decision Drivers
-
Package files and web-server configuration in one immutable, portable artifact.
-
Keep product-specific routing, cache, privacy-header, and
404policy with the owning frontend. -
Preserve shared TLS, rate limiting, backend route ownership, and bounded legacy-widget compatibility at one trusted edge.
-
Promote the exact frontend image tested in dev to production without rebuilding it.
-
Keep operations appropriate for one VPS and a small team; brief frontend interruption is acceptable.
-
Avoid a Node.js or Nuxt runtime server.
Considered Options
| Option | Pros | Cons |
|---|---|---|
Keep host release directories served directly by Caddy |
Fewest runtime processes. Atomic symlink activation without container restart. |
Separate non-OCI release mechanism. Frontend files and product-specific server policy are split across host state and shared Caddy. |
Send every product-origin request through a frontend container |
Edge Caddy needs only host dispatch. Each product origin appears fully self-contained. |
Duplicates backend route and security policy, adds another backend proxy hop, and gives frontend containers unnecessary backend network access. |
Use one static frontend container per product behind shared edge Caddy |
One OCI artifact per product. Product web-server policy remains local while shared backend/security routing remains centralized. Uses the existing Compose and GHCR operating model. |
Adds two Nginx processes and image patching. Container replacement can briefly interrupt one product. |
Decision
We will package each generated Nuxt frontend as a separate OCI image with an app-local unprivileged Nginx server:
-
frontend-einfache-erechnung -
frontend-clarula
Each final image contains only .output/public/ and the product’s Nginx configuration.
It runs as a numeric non-root user on an internal port with a read-only root filesystem, no Linux capabilities, no secrets, and no published host port.
Frontend request logging is disabled because diagnosis and correction URLs contain bearer capability tokens.
Nginx owns product-specific generated-file resolution, scoped client-shell rewrites, compression, cache and privacy headers, and real 404 responses.
It does not proxy backend requests and has no database network path.
Shared Caddy remains the sole public edge. It owns TLS, host aliases, dev-wide search-engine exclusion, shared rate limits, explicit backend/admin/widget/webhook/callback routes, and the temporary compatibility routes for already-installed widgets. After those routes, Caddy reverse-proxies remaining traffic to the matching frontend container and removes credentials before forwarding static requests.
Dev builds immutable dev-<full-sha> frontend images and verifies them through the deployed topology.
Production promotion resolves both tested image digests and creates prod-<full-sha> from those same digests; production does not rebuild the frontends.
Production promotion and rollback deploy the complete runtime, with both frontends before Caddy, and verify health, release metadata, and public routing.
A short interruption during replacement is accepted; we will not add blue/green deployment until an availability requirement justifies it.
This decision supersedes only the direct-Caddy file delivery, host release directory, and no-product-web-server portions of ADR-25: Static Nuxt Product Frontends and ADR-24: Clarula Monorepo Structure and Neutral Backend Naming.
Static Nuxt generation, independent product artifacts, the /api/v1/ contract, and the shared Spring Boot business monolith remain active.
Consequences
- Positive
-
-
Frontend files and product server policy form one immutable deployment artifact.
-
Host replacement and restore no longer manage frontend directories, checksums, or symlinks.
-
Product routing policy remains in the product-owned image.
-
Dev and production use the same tested image manifests.
-
Caddy remains a small shared security boundary instead of duplicating backend routing in both products.
-
- Negative
-
-
Production runs two additional Nginx processes and must patch their base image.
-
A production rollout replaces all runtime image components, even when only one changed.
-
Rollback depends on retaining the full-runtime image version in every GHCR package or on the host.
-
Nginx and Caddy routing boundaries need container-level and full-stack contract tests.
-
- Neutral
-
-
Nuxt remains build-time tooling; there is still no Node.js or runtime SSR process.
-
The single VPS remains the availability boundary.
-
ADR-14’s automatic dev delivery and manual production promotion remain unchanged.
-
ADR-27: Promotion-Only Production Delivery
- Status
-
Accepted
- Date
-
2026-07-13
Context
ADR-26 introduced full-runtime production promotion, named rollback, component restoration after deployment failure, and complete-runtime restoration after smoke failure. The first production cutover to the two static frontend containers is complete. The rollback branches and their retained-state handling make the production workflow harder to understand and maintain, while the current team has no requirement for automated rollback.
Production must remain manual under ADR-14, and frontend production bytes must still match the images tested in dev. We need the smallest production workflow that preserves those constraints.
Decision Drivers
-
Keep the manual production path easy to understand and operate.
-
Remove one-time cutover and unused recovery branches.
-
Preserve exact-digest frontend promotion from dev.
-
Preserve full-runtime version alignment and Caddy-last deployment order.
-
Fail visibly instead of running additional mutation after a failed check.
Considered Options
| Option | Pros | Cons |
|---|---|---|
Keep named and automatic rollback |
Shortens recovery when every retained image and database schema remain compatible. |
Retains workflow inputs, previous-version capture, reverse deployment, verification branches, and image-retention assumptions. |
Keep automatic restoration but remove named rollback |
Preserves failure recovery while reducing one operator input. |
Still retains most rollback state and branching in the deployment path. |
Use one promotion-only path |
Removes rollback-specific inputs and state. Every production change follows the same tested path. |
A failed deployment can leave mixed component versions or a failed target version in runtime configuration. Recovery requires an operator and another promotion. |
Decision
The production workflow will only promote a complete runtime from a main-reachable, dev-tested revision. It will deploy backend, both frontends, both demos, and Caddy in that order, then verify health, public routing, and frontend release metadata.
We will remove named rollback, previous-version recording, automatic restoration, host-local image handling, and all completed first-cutover branches. The component deployment script will stop on failure without restoring the previous image version. Operational recovery will use the same promotion workflow with a corrective, dev-tested revision.
This decision supersedes ADR-26 only where ADR-26 requires named rollback or automatic restoration. ADR-26’s static frontend image model, exact-digest promotion, complete-runtime production release, and Caddy-last order remain active.
Consequences
- Positive
-
-
Production has one operation, one version-selection model, and one deployment sequence.
-
The workflow no longer carries temporary Hugo/Caddy cutover state.
-
Failed checks remain visible and do not trigger additional automated production mutation.
-
- Negative
-
-
A failed component deployment can leave runtime configuration on the requested version.
-
A failure after earlier components have deployed can leave a mixed runtime until the next successful promotion.
-
Recovery can take longer and requires an available operator.
-
Database migrations must remain compatible with the chosen corrective deployment.
-
- Neutral
-
-
Immutable
prod-*image tags remain for traceability and reproducible promotion. -
Production remains manual-only under ADR-14.
-
Public smoke checks and the production operation lock remain unchanged.
-
ADR-28: Certified Composite Runtime Snapshot Promotion
- Status
-
Partially superseded by ADR-33: Dedicated Static Operator Frontend on
admin.clarula.defor the six-image snapshot; the certified composite promotion model remains accepted. - Date
-
2026-07-13
Context
Dev deploys changed runtime components independently, so its tested runtime can contain images from several source commits. The previous production workflow required both frontends from one exact dev commit but rebuilt backend, Caddy, and demos during production promotion. This prevented promotion after selective dev builds and meant production did not run the complete image combination tested on dev.
We need a manual production operation that promotes the tested dev combination without rebuilding runtime components and still identifies every successful production release as one human-readable set.
Decision Drivers
-
Promote exactly the six image digests tested together on dev.
-
Preserve component-selective automatic dev delivery.
-
Make a production dispatch without optional input select the current certified dev runtime.
-
Avoid runtime image builds during the production operation.
-
Identify one production cohort across all image packages and host-recovery metadata.
-
Prevent a later dev deployment from changing an active production promotion.
Considered Options
| Option | Pros | Cons |
|---|---|---|
Keep one source revision and rebuild non-frontend images in production |
One Git SHA labels the release. Existing backend and demo build flow remains. |
Selective dev builds block unrelated promotions. Four production images differ operationally from the dev-tested artifacts. |
Resolve six moving |
Simple package-level convention. No additional manifest artifact. |
Tag reads are not atomic. A concurrent dev deployment can produce a combination that never passed one smoke test. Moving tags do not preserve component provenance. |
Promote one certified composite runtime snapshot |
Preserves selective builds, freezes all six digests, and promotes the tested combination without rebuilding it. |
Adds a runtime-snapshot OCI artifact, release-finalization logic, and composite provenance instead of one source SHA. |
Decision
After each successful dev deployment and full public smoke test, the dev workflow will record all six running immutable image tags, resolve their OCI manifest digests, and publish one certified runtime-snapshot OCI image.
The snapshot records each component’s image, digest, and source SHA plus the dev workflow run and deployment-control revision.
All six component digests and the runtime snapshot receive the same immutable dev-run-<run-id>-<attempt> cohort tag.
The package-local moving dev tags identify the images in the certified combination; the single runtime-snapshot:dev tag moves last and is the authoritative current-dev pointer.
A failed dev deployment or smoke test does not publish a new certified pointer.
Dev and production operations are serialized within their own environments.
A production dispatch defaults to runtime-snapshot:dev, resolves it to one immutable digest, and validates the referenced successful Dev certification on main before mutation.
After production approval, it revalidates the snapshot’s cohort tags and pulls each component by the recorded digest.
The host uses the snapshot’s dev-run-<run-id>-<attempt> ID as a temporary local rollout reference while deploying the complete runtime with Caddy last.
Later Dev deployments cannot change the selected bytes.
An operator may explicitly select a retained immutable snapshot only when its deployment-compatibility schema matches the current production workflow. Snapshot schema version 2 marks the separate EER/Clarula database boundary and rejects older snapshots before production secrets, SSH, or host mutation. Dev certification also requires the running backend to advertise product-databases-v1 through operational info, so a selective manual run cannot stamp stale pre-boundary backend bytes as schema 2.
Production does not rebuild backend, Caddy, either frontend, or either demo.
Caddy therefore packages both validated environment configurations in one image; Compose selects the active environment configuration at runtime.
After successful production smoke tests, the workflow will add the same immutable prod-<UTC timestamp>-<production run ID> tag to all six component digests and to the runtime snapshot.
It will then record that release ID in the production runtime environment and move each package-local prod pointer.
The runtime-snapshot manifest and OCI digests are authoritative; dev, prod, and the shared release tag are readable pointers.
OCI registries cannot update seven package tags atomically, so a failed finalization is recovered through a new production dispatch rather than a same-run retry.
This decision supersedes ADR-26 and ADR-27 only where they require one source revision, frontend-only exact-digest promotion, or production builds for other runtime components. Their static frontend boundary, complete-runtime production deployment, Caddy-last order, manual production gate, and no-automatic-rollback policy remain active.
Consequences
- Positive
-
-
Production runs the complete image combination that passed the dev smoke suite.
-
Selective dev builds no longer prevent later production promotion.
-
Production spends no time building runtime images and cannot introduce build-time byte differences.
-
Shared timestamped tags make release cohorts readable across packages.
-
The runtime snapshot preserves per-component source and digest provenance.
-
- Negative
-
-
A successful dev runtime is a composite release rather than one Git commit.
-
Tag finalization spans several packages and can fail partially; operators must start a new promotion to complete the environment pointers.
-
Cohort validation, digest pulls, and release finalization add registry operations to each promotion.
-
Exact image promotion does not make secrets, database state, or environment-specific runtime values identical.
-
- Neutral
-
-
Production rollout remains sequential and can leave mixed runtime versions after a failed component, as accepted by ADR-27.
-
PostgreSQL and Metabase remain separately pinned operational images outside the six-component runtime snapshot.
-
Retained old image sets support audit and host restoration, not guaranteed database-compatible rollback. Production promotion rejects snapshots from an older deployment-compatibility schema.
-
ADR-29: Partner-Scoped DATEV Connection and Mandate Import Port
- Status
-
Partially superseded by ADR-32: Clarula Firm as DATEV and Tenant Anchor for DATEV and Clarula tenant ownership and by ADR-33: Dedicated Static Operator Frontend on
admin.clarula.defor the operator surface; the port, OAuth, token, import, and adapter decisions remain accepted. - Date
-
2026-07-14
Context
The Clarula partner demo needs DATEV mandate identity before it can classify invoices as incoming or outgoing and route them conservatively.
A firm login and firm self-service do not exist yet, but the existing Clarula admin can assist onboarding.
The removed DATEV spike proved an advisor-level OAuth connection, encrypted token persistence, and token refresh against accounting:documents; it did not provide the master-client identity required by the approved Clarula flow.
The implementation must remain reproducible without DATEV credentials, support sandbox validation, and later switch to production without changing the Clarula domain model.
Decision Drivers
-
Keep DATEV as the source of mandate identity.
-
Reuse the existing
Partneras the firm, admin, and future embed anchor. -
Never expose DATEV credentials to Clarula; authenticate directly at DATEV.
-
Keep local development and the partner demo reproducible.
-
Minimize persisted DATEV fields and fail closed when source identity is ambiguous.
-
Keep mandate import separate from later document handover.
Decision
Clarula stores one DATEV connection per Partner.
A Clarula administrator starts an OAuth 2.0/OpenID Connect authorization-code flow with PKCE for an active partner, and an authorized firm representative authenticates directly at DATEV.
The callback remains under /admin/**, revalidates the admin session, and binds the short-lived authorization attempt to the initiating admin, partner, state, nonce, and PKCE verifier.
Connect, import, confirmation, and disconnect mutations additionally require a per-admin-session action token.
Access and single-use refresh tokens are encrypted at rest.
The connection stores the granted scopes, provider subject, expiry, and the stable DATEV business-partner reference verified through org-info.
A connection is usable only when its firm reference still matches the retained import snapshot; disconnect does not remove that source binding.
Connection replacement, refresh-token use, import persistence, and disconnect are serialized with a stable partner-row database lock; missing, unreadable, rejected, or ambiguously consumed refresh tokens require reconnection.
Reconnect cannot silently switch DATEV firm.
The business capability depends on a DatevApi port for authorization, refresh, revocation, firm verification, and mandate import.
A deterministic mock adapter is the default local and demo implementation.
One HTTP adapter supports sandbox and production through environment-specific endpoints and credentials.
The operating mode is mandatory adapter configuration, not a feature flag; the admin capability is always present.
The mode is deliberately absent from the persisted domain model. Before an operator changes it, real provider grants must be revoked, the backend stopped, and the complete Clarula database recreated; no runtime compatibility or mixed-mode cleanup path exists.
The mock authorization controller exists only in mock mode.
The HTTP adapter reads master-data:master-clients, requires every master client to carry the verified DATEV business-partner ID, maps only approved firm, name, address, VAT/tax-number, provenance, and stable-reference fields, and discards complete provider responses after normalization.
Malformed, cross-firm, or ambiguous provider data fails the whole import.
Each partner owns one current, minimized import snapshot. Imported mandate, identity, and demo-target fields are read-only. Clarula-owned mandate confirmation is stored separately. A same-source re-import updates normalized fields and preserves confirmations; a changed DATEV firm fails closed and requires an explicit future replacement flow. Missing mandates remain visible as unavailable instead of being silently reassigned or deleted.
The DATEV document-handover port remains separate and uses a deterministic demo adapter until its production contract is decided.
Consequences
- Positive
-
-
Admin-assisted onboarding works before a firm account area exists.
-
Mock, sandbox, and production use one application contract.
-
Tokens, imported identity, and confirmations have explicit ownership and provenance.
-
A wrong DATEV login cannot silently replace another firm’s imported mandates.
-
The reproducible partner demo has no mandatory DATEV runtime dependency.
-
- Negative
-
-
Clarula persists highly sensitive OAuth tokens and selected mandate identity fields.
-
Key recovery currently means reconnecting; automated encryption-key rotation is deferred.
-
Firm-wide long-term DATEV grants and automatic synchronization remain unresolved.
-
DATEV firm changes require an explicit replacement workflow rather than automatic reconciliation.
-
Adapter-mode changes require a destructive, manually supervised reset of the complete Clarula database.
-
Related
-
ADR-32: Clarula Firm as DATEV and Tenant Anchor — replaces
Partnerownership and the partner-row lock with Clarula-ownedFirmownership and a firm-row lock.
ADR-30: One BootJar with Product-Isolated Spring Child Contexts
- Status
-
Partially superseded by ADR-33: Dedicated Static Operator Frontend on
admin.clarula.defor the Clarula admin servlet mapping; the sibling-context model remains accepted. - Date
-
2026-07-15
Context
The backend currently places einfache-eRechnung (EER) and Clarula business capabilities in one Gradle project and one Spring ApplicationContext.
That keeps operations simple, but package conventions alone cannot prevent one product from importing the other product’s code, discovering its Spring beans, or using its persistence infrastructure.
Clarula now has its own tenant, DATEV, and document-routing model, while EER retains its invoice-conversion, customer-account, and partner-affiliation model.
The products need stronger construction and runtime boundaries without introducing a distributed system.
A focused runtime proof established that one embedded servlet server can eagerly host sibling DispatcherServlet application contexts, each with a conventional local transaction manager, while a persistence-free parent exposes only shared stateless beans.
Decision Drivers
-
Preserve one backend artifact, process, deployment, and operational failure domain for the small team.
-
Make EER-to-Clarula source dependencies fail at compile time.
-
Prevent component scanning and dependency injection from crossing product boundaries accidentally.
-
Let each product use normal Spring Data JPA, Flyway, and
@Transactionalconventions without qualifiers or a routing transaction manager. -
Keep public path ownership explicit and testable.
-
Retain a path to separate processes if security, scale, or operations later require it.
Considered Options
| Option | Pros | Cons |
|---|---|---|
Keep one Gradle project and one application context |
Lowest migration effort. Standard Spring Boot auto-configuration. |
Package rules remain discipline-based. Product beans, repositories, and transaction infrastructure remain mutually visible. |
Build one BootJar from product subprojects and sibling application contexts |
Adds compile-time dependencies and runtime bean/persistence boundaries while preserving one process and deployment. |
Requires explicit bootstrap, servlet mappings, scanning, and per-child auto-configuration. |
Deploy one Spring Boot application per product |
Provides process, classpath, credential, scaling, and failure isolation. |
Adds another runtime, health surface, deployment sequence, and inter-process integration before the team needs them. |
Decision
The backend becomes a Gradle multi-project build with four subprojects:
applications/backend/
├── app/ # composition root and sole BootJar owner
├── core/ # product-neutral, persistence-free shared capabilities
├── eer/ # einfache-eRechnung business application
└── clarula/ # Clarula business application
Only :app applies the Spring Boot executable packaging and produces backend.jar.
The dependency graph is directional:
app -> core, eer, clarula
eer -> core
clarula -> core
core -> no product subproject
:eer and :clarula must not depend on each other or on :app.
:app contains only startup, product-context registration, shared servlet/container infrastructure, and route ownership; it must not become a third business module or mediate direct product calls.
:core contains only product-neutral, stateless code and contracts that both products genuinely share; it contains no product domain entity, repository, migration, DataSource, JPA configuration, or transaction boundary.
Shared implementation convenience alone is not a reason to move code into :core.
Gradle dependency assertions and architecture tests enforce these rules in addition to package conventions from ADR-6: Package-by-Feature Organization.
At runtime, :app starts one root ServletWebServerApplicationContext and one embedded servlet server.
The root eagerly creates two sibling child WebApplicationContext instances and registers one DispatcherServlet for EER and one for Clarula.
The root is the composition and servlet-registration context; it scans no product controller, JPA entity, or repository and defines no DataSource, EntityManagerFactory, Flyway, or PlatformTransactionManager bean.
Only explicitly listed persistence-free operational endpoints may remain root-owned.
Each product child owns and scans only its product configuration and packages. A child can resolve product-neutral beans from the parent, but the parent cannot resolve child beans and neither sibling can resolve the other sibling’s beans. Both children start eagerly; failure to initialize either child fails startup of the one backend process.
Each product child has exactly one local conventional PlatformTransactionManager bean named transactionManager, one local JPA persistence unit, and one local Flyway migration boundary.
Unqualified @Transactional therefore resolves within the owning child.
There is no shared, routing, chained, or cross-product transaction manager.
ADR-31: Separate Logical PostgreSQL Databases and Roles defines the corresponding database boundary.
Every externally reachable business path belongs to exactly one product child through the servlet-mapping registry in :app.
EER is the default / dispatcher so its established public routes remain unchanged; Clarula’s longer /admin/clarula/ mapping wins by standard servlet matching, and the root operational servlet owns /actuator/.
This deliberate mapping overlap is safe only because product controller scans are disjoint and route-exclusivity tests cover both positive and negative dispatch.
Edge host routing is not a substitute for backend path ownership.
Unknown product handler paths remain 404 after the applicable security boundary, and moving or versioning a public path remains an explicit API compatibility change rather than an incidental consequence of component scanning.
The boundary is verified at both levels:
-
Compile-time checks reject forbidden Gradle project dependencies and product-package imports.
-
Runtime tests prove one server, two eager sibling contexts, root and bridge persistence freedom, sibling bean invisibility, separate Flyway histories, one local conventional transaction manager per product, unqualified product-local rollback, scheduler/async ownership, exclusive path dispatch, aggregate health, eager failure, and pool shutdown.
Completed implementation
The implementation now realizes the symmetric target.
:app contains the BackendApplication composition root, the root operational DispatcherServlet, global security/filter composition, product health, and eager product-context registration; it defines no DataSource, EntityManagerFactory, Flyway, or PlatformTransactionManager.
:eer owns the existing EER controllers, services, entities, repositories, Flyway history, templates, static resources, widget build, schedulers, and product tests.
:clarula continues to own the complete firm/DATEV capability and its independent persistence boundary.
Both product contexts are eager sibling GenericWebApplicationContext instances behind one parentless shared-services bridge.
The bridge publishes only product-neutral stateless services (Clock, ObjectMapper, RestClient, JWT codec, and the optional short-lived operator token service); it contains no product or persistence bean.
Root auto-configuration explicitly excludes product mail, template, OpenAPI, AI, task, dispatcher, and persistence infrastructure; EER excludes its inert auto-configured dispatcher and actuator info endpoint.
The EER DispatcherServlet is the default / servlet, the longer /admin/clarula/ mapping selects Clarula, and /actuator/ is handled by the persistence-free root operational servlet.
Startup failure in either product closes any context already created and fails the one process; normal root shutdown closes both Hikari pools and the EER-owned asynchronous event executor.
A root ApplicationRunner delegates ordered product ApplicationRunner and CommandLineRunner beans after the servlet server has started, preserving Boot runner timing and failure semantics for local seeding and future startup tasks.
Spring application lifecycle events do not propagate from the root into either sibling; a product must use the delegated runner contract or an explicit composition bridge rather than assuming it receives the root ApplicationReadyEvent.
Internal composition and product-boundary names now use backend, EER, Clarula, and operator terminology. Compatibility identifiers remain unchanged where external consumers or deployed state depend on them: the invoicex EER database, INVOICEX_* environment variables, JWT issuer/audiences, public routes, backend.jar, and API/widget contracts.
This is modular-monolith isolation, not a hard security boundary. Both products still share one JVM, heap, operating-system identity, artifact, startup, and failure domain; the process also receives both products' database credentials. Reflection, deliberately hostile code, shared-process compromise, or resource exhaustion can cross the child-context boundary. A requirement for adversarial, regulatory, credential, or failure isolation must trigger a decision for separate processes and deployment identities.
Consequences
- Positive
-
-
Product source dependencies become visible and enforceable in Gradle.
-
Spring bean discovery, JPA repositories, migrations, and transactions stay local to one product.
-
Both products retain conventional Spring transaction semantics without bean qualifiers.
-
Deployment, observability, local startup, and production promotion still operate one backend artifact and process.
-
Explicit servlet ownership prevents accidental route collisions and frontend fallbacks from hiding missing backend routes.
-
- Negative
-
-
Bootstrap and test infrastructure become more complex than standard single-context Spring Boot auto-configuration.
-
Cross-cutting framework configuration must be classified deliberately as root-safe or product-local.
-
A startup failure, memory leak, or process crash in one product still affects both products.
-
Sharing a capability now requires an intentional
:corecontract instead of a direct product import. -
The route-ownership registry and boundary tests must evolve with every public endpoint.
-
- Neutral
-
-
Product capabilities still deploy together and cannot scale independently.
-
Package-by-feature remains the organization inside each product subproject.
-
Static product frontends and the certified composite runtime promotion model are unchanged.
-
Related
-
ADR-5: Monolith-First Architecture — partially superseded for internal construction and runtime context structure; one process and deployment unit remain active.
-
ADR-24: Clarula Monorepo Structure and Neutral Backend Naming — partially superseded for the backend’s internal project layout.
ADR-31: Separate Logical PostgreSQL Databases and Roles
- Status
-
Accepted
- Date
-
2026-07-15
Context
ADR-30: One BootJar with Product-Isolated Spring Child Contexts separates EER and Clarula at Gradle and Spring-context boundaries while retaining one backend process. Keeping both products in one PostgreSQL database would leave tables, privileges, migrations, joins, foreign keys, and restore operations inside one persistence boundary. It would also let a repository or ad hoc query bypass the product boundary even when Spring beans are isolated.
The team still operates one VPS and does not need separate PostgreSQL servers. It does need product-owned schema evolution, least-privilege access, and the ability to back up or restore one product without treating the other product’s data as the same unit.
Decision Drivers
-
Make cross-product persistence access fail through database authorization, not only code review.
-
Give EER and Clarula independent migration histories and recovery units.
-
Preserve one PostgreSQL installation and the current low operational overhead.
-
Keep every business transaction local to one product.
-
Avoid distributed transaction coordination and hidden cross-product data models.
Considered Options
| Option | Pros | Cons |
|---|---|---|
Separate schemas in one logical database |
Simple connection management. PostgreSQL foreign keys and joins remain available. |
Grants are easier to weaken accidentally, one connection can still address both schemas, and backup/restore remains database-wide by default. |
Separate logical databases and roles in one PostgreSQL cluster |
Strong namespace and connection boundary, independent migrations and dumps, one PostgreSQL runtime to operate. |
No native cross-database foreign keys or joins. The shared cluster remains one physical availability and capacity boundary. |
Separate PostgreSQL instances |
Stronger process, resource, superuser, and failure isolation. |
Adds runtime, backup, monitoring, patching, and capacity overhead that the current threat and load model do not justify. |
Decision
Each environment provides two logical PostgreSQL databases in the existing PostgreSQL cluster: one owned by EER and one owned by Clarula. Their concrete names are environment configuration, not a cross-product contract.
Each product uses distinct credentials and a distinct PostgreSQL login role. A product role receives connection and object privileges only for its own database. No EER application or migration role receives Clarula privileges, and no Clarula application or migration role receives EER privileges. Operational superuser or backup credentials may cover the cluster, but they are never application credentials and are not exposed to either product context. Production may later split each product’s migration owner from its runtime role without changing this decision.
The root application context consumes neither credential set and creates no persistence beans.
The EER sibling binds only EER connection properties; the Clarula sibling binds only Clarula connection properties.
Each child creates its own DataSource, JPA persistence unit, conventional local transactionManager, and Flyway instance as defined by ADR-30: One BootJar with Product-Isolated Spring Child Contexts.
Flyway migrations are product-owned:
-
EER migrations ship from
:eer, execute only against the EER database, and maintain an EER-local schema history. -
Clarula migrations ship from
:clarula, execute only against the Clarula database, and maintain a Clarula-local schema history. -
A migration must not create or query objects in the sibling database.
-
Failure of either required migration fails startup; successful histories and version numbers remain independent.
Cross-database foreign keys, joins, views, dblink, foreign data wrappers, and XA/JTA transactions are prohibited.
No transaction may claim atomicity across both databases.
If a future product workflow must exchange data, it must use an explicit application contract, carry stable values rather than foreign keys, and define idempotency, ordering, and compensation without a distributed transaction.
That integration requires its own architecture review.
Staged implementation
The 2026-07-15 production slice provisions logical databases invoicex and clarula with distinct non-superuser roles eer and clarula in every Compose environment.
An idempotent one-shot service creates/rotates the roles, creates the Clarula database, revokes public and sibling connection access, and transfers historical EER object ownership from the former operational superuser so the eer role can continue its Flyway chain.
Clarula starts a new migration history at db/clarula/migration/V1; the unshipped EER DATEV V121 is removed instead of copied or marked applied.
The complete EER history is packaged in :eer at db/migration; it runs only through the EER sibling context’s DataSource and role.
Production full-state backups carry separate invoicex.dump and clarula.dump artifacts and restore each with its product owner. The production-to-dev synchronization archive deliberately omits Clarula; it transfers only EER and Metabase, then clears Dev-local Clarula tenant state before restart.
Backups and restores treat the products as separate recovery units. Automation creates separately identifiable EER and Clarula backup artifacts and can restore and verify either logical database independently. A cluster-wide physical backup may supplement this approach, but it does not replace product-specific logical backups and restore tests. No cross-product invariant may require a coordinated point-in-time restore.
Automated access tests run with the real product roles and prove both sides of the boundary:
-
each role can connect to, migrate as configured, and read/write its own database;
-
each role is refused connection or object access to the sibling database;
-
each product integration-test context starts with only its own migration locations and persistence unit; and
-
backup verification identifies and restores one product database without loading the other.
Consequences
- Positive
-
-
Accidental cross-product SQL, ORM relations, and migrations fail at the database boundary.
-
Product schemas can evolve and be restored independently.
-
Local JPA and transaction conventions remain simple in each sibling context.
-
One PostgreSQL cluster preserves the current deployment and patching model.
-
A later process split can reuse the same database and credential boundaries.
-
- Negative
-
-
Cross-product joins and foreign keys are unavailable, even for reporting or operational convenience.
-
Shared reference data may need intentional duplication or an explicit integration contract.
-
Backup, restore, provisioning, and test automation must handle two logical databases and credential sets.
-
A workflow that touches both products cannot use one atomic transaction and must expose partial-failure behavior.
-
- Neutral
-
-
PostgreSQL remains one physical failure, capacity, maintenance, and superuser boundary.
-
The shared backend process holds both credential sets, so role separation limits ordinary connections but is not protection against full process compromise.
-
Metabase or future reporting access must be granted separately per product; application roles remain write/read scoped to their own database.
-
Related
-
ADR-5: Monolith-First Architecture — supersedes its one-database clause while preserving one backend process and deployment unit.
-
ADR-9: Analytics Reports as Code via SQL Views — report views remain database-local and product-owned.
-
ADR-30: One BootJar with Product-Isolated Spring Child Contexts
ADR-32: Clarula Firm as DATEV and Tenant Anchor
- Status
-
Partially superseded by ADR-33: Dedicated Static Operator Frontend on
admin.clarula.defor the implemented Clarula admin UI path; the Firm tenant and ownership decision remains accepted. - Date
-
2026-07-15
Context
The Partner aggregate was introduced for the EER embeddable-widget registry, redirect allow-list, origin attribution, and partner-tariff affiliation flow.
ADR-29: Partner-Scoped DATEV Connection and Mandate Import Port later reused that aggregate as the Clarula tax firm, DATEV connection owner, imported-mandate owner, admin workspace anchor, and future embed anchor.
That reuse avoided a second concept while both products shared one application context and database.
Clarula now has an independent tenant lifecycle and persistence boundary under ADR-30: One BootJar with Product-Isolated Spring Child Contexts and ADR-31: Separate Logical PostgreSQL Databases and Roles. A Clarula tax firm owns DATEV authorization, mandates, uploader grants, and document submissions; an EER partner represents a distribution and affiliation relationship. Keeping one aggregate for both meanings would couple Clarula tenant identity to EER commercial and widget lifecycles and would require cross-database ownership.
Decision Drivers
-
Give Clarula one product-owned aggregate root for tax-firm tenancy and data ownership.
-
Keep EER partner attribution, affiliation, and tariff behavior independent from Clarula.
-
Preserve the DATEV port, token, source-verification, and minimized-import decisions from ADR-29: Partner-Scoped DATEV Connection and Mandate Import Port.
-
Avoid an unvalidated cross-product identity or customer-master-data mapping in the partner-demo MVP.
-
Make tenant-scoped authorization and persistence references unambiguous.
Considered Options
| Option | Pros | Cons |
|---|---|---|
Continue using EER |
Reuses existing records, admin pages, and identifiers. |
Creates a cross-product aggregate and database dependency. Partner affiliation and Clarula tenancy retain incompatible meanings and lifecycles. |
Introduce Clarula |
Separates the domain names while retaining an explicit relationship to existing partners. |
Adds mapping ownership, synchronization, conflict, deletion, and authorization rules before a cross-product use case has been validated. |
Introduce independent Clarula |
Gives Clarula a clean tenant boundary and leaves future integration reversible. |
May duplicate limited organization metadata and cannot reuse an EER partner identifier automatically. |
Decision
Clarula owns a Firm aggregate in the :clarula subproject and Clarula database.
Each Firm has a stable Clarula identifier and a Clarula-owned lifecycle independent of EER and DATEV; connecting, disconnecting, or importing from DATEV does not create, activate, deactivate, or reactivate it.
Firm is the tenant and ownership anchor for Clarula state, including DATEV connections, minimized import snapshots, imported mandates, mandate confirmations, uploader grants, firm-scoped widget configuration, and document submissions.
Every tenant-scoped Clarula operation resolves and authorizes a Firm before accessing that state.
Possession of a firm identifier alone grants no authority.
The DATEV decisions in ADR-29: Partner-Scoped DATEV Connection and Mandate Import Port remain active with Firm replacing Partner as owner:
-
Clarula stores one DATEV connection per
Firm. -
Authorization attempts, encrypted tokens, provider subject, verified DATEV business-partner reference, and current import snapshot bind to that
Firm. -
Connection replacement, refresh-token use, import persistence, and disconnect serialize on a stable firm-owned database lock.
-
DatevApi, deterministic mock operation, sandbox/production HTTP adaptation, source verification, minimized read-only imports, separate Clarula confirmations, and the separate document-handover port remain unchanged.
The DATEV business-partner reference is external provenance for a Firm; it is not an EER Partner identifier and does not create a cross-product relationship.
Partner remains owned by EER for its existing widget registry, redirect, origin-attribution, affiliation, and tariff use cases.
It does not anchor Clarula tenancy, DATEV state, mandates, uploader access, or submissions.
Clarula code and persistence do not reference EER Partner entities, repositories, tables, or identifiers.
The partner-demo MVP has no Partner-to-Firm mapping.
It introduces no mapping table, cross-database foreign key, shared UUID convention, lookup adapter, synchronization job, or implicit match by name, domain, email, or DATEV reference.
The same real-world tax firm may therefore have independent EER Partner and Clarula Firm records with no asserted relationship.
Any one-time data migration, if required, must not establish a durable mapping.
If a later validated use case needs shared commercial identity, onboarding transfer, or single sign-on, it must define an explicit integration with source ownership, consent, lifecycle, conflict, deletion, and authorization rules. It must not restore a shared persistence aggregate.
Implementation
The first production migration creates firms and all firm-owned DATEV/import tables only in the Clarula database.
The authenticated UI at /admin/clarula/firms creates, lists, deactivates, and reactivates firms; DATEV workspaces are nested beneath the selected firm.
The former EER partner detail no longer links to DATEV, and the Clarula module has no compile-time import, repository lookup, table reference, foreign key, or identifier mapping to EER Partner.
No data migration maps historical partner records to firms: migration V120 already removed the spike data, and the replacement EER V121 was not retained in the shipped migration chain.
Consequences
- Positive
-
-
Clarula tenant ownership is explicit in code, transactions, database grants, and authorization checks.
-
EER partner lifecycle changes cannot silently alter Clarula DATEV or mandate ownership.
-
Clarula can evolve firm onboarding and self-service without changing partner-tariff behavior.
-
The DATEV adapter and security work from ADR-29 remain reusable with a clearer aggregate boundary.
-
Avoiding an MVP mapping removes synchronization and false-identity risks.
-
- Negative
-
-
Existing Partner-based Clarula implementation and demo fixtures must move to
Firmownership. -
Limited organization metadata may exist independently in both products.
-
Operators cannot navigate from an EER partner to a Clarula firm through a product mapping in the MVP.
-
A future cross-product commercial relationship will require a deliberate integration rather than a local foreign key.
-
- Neutral
-
-
Firmis a tenant aggregate, not a new authentication mechanism; admin and future firm-user authorization remain separate concerns. -
DATEV remains the source of imported mandate identity and routing evidence, while Clarula owns confirmations and uploader grants.
-
Historical ADRs and PDRs retain the term
Partnerwhere it accurately records the earlier decision.
-
Related
-
ADR-10: Cross-Domain Redirect via Server-Side Partner Registry — remains the EER partner registry decision.
-
ADR-21: Partner Affiliation Confirmation via One-Time Email Token — remains EER-owned.
-
ADR-29: Partner-Scoped DATEV Connection and Mandate Import Port — partially superseded only for the ownership anchor and corresponding lock.
-
ADR-30: One BootJar with Product-Isolated Spring Child Contexts
ADR-33: Dedicated Static Operator Frontend on admin.clarula.de
- Status
-
Accepted
- Date
-
2026-07-16
Context
The backend currently renders two administrative areas with Thymeleaf: EER partner administration under /admin/ and Clarula firm, DATEV, and mandate administration under /admin/clarula/.
Both areas use the operator identity from ADR-13: Admin Identity via Configuration, Not as a Customer Role, but their presentation remains coupled to backend releases and the canonical einfache-eRechnung origin.
The public product frontends are already independent static Nuxt applications under ADR-25: Static Nuxt Product Frontends and ADR-26: Static Frontend OCI Images behind Shared Edge Caddy.
The founders need one redesigned internal workspace behind one login while EER and Clarula remain separate products with separate backend modules and databases. The workspace must not consume paths on either public product origin. Moving presentation must not move business rules, authorization, transactions, DATEV OAuth state, or persistence into the frontend, and a unified screen must not restore the cross-product aggregate rejected by ADR-32: Clarula Firm as DATEV and Tenant Anchor.
Decision Drivers
-
Provide one coherent operator workflow and one login for both products.
-
Remove administrative HTML, CSS, and browser interaction code from the backend.
-
Keep public product origins free of operator routes and cookies.
-
Preserve EER and Clarula domain, API, transaction, and database ownership.
-
Retain static frontend delivery and the one-backend modular monolith.
-
Keep cookie authentication, DATEV OAuth callbacks, and unsafe mutations same-origin and explicitly protected.
Considered Options
| Option | Pros | Cons |
|---|---|---|
Keep backend-rendered administration |
No additional runtime image or browser API migration. Existing authentication and callbacks remain unchanged. |
Prevents the requested redesign and keeps presentation coupled to backend releases. |
Add administration to each public product frontend |
Presentation follows product ownership and adds no third frontend application. |
Duplicates the operator shell, splits one session across origins, couples internal tools to public releases, and complicates DATEV OAuth and cookie handling. |
Create one operator frontend under an existing product path |
One login and one deployable admin application with a small origin migration. |
Continues to occupy a public product origin and makes the shared operator workspace appear EER-owned. |
Create one operator frontend on |
One workspace and origin, independent presentation releases, no operator routes on public product origins, and same-origin APIs and callbacks. |
Adds one runtime image and requires a coordinated cookie, magic-link, API, edge-routing, and DATEV callback migration. |
Decision
We will add a third static Nuxt application, applications/frontend/apps/operator-admin, to the existing frontend workspace.
It will generate one independently versioned OCI image containing static files and an unprivileged Nginx configuration; production will run no Node.js or Nuxt server.
The application will provide one operator shell, login experience, navigation, and technical UI foundation with separate EER and Clarula feature modules.
Shared presentation must not introduce shared Partner/Firm transport types or infer a relationship between them.
A combined dashboard may compose separately authorized product summaries in the browser, but the backend will not add cross-product joins, repositories, transactions, or aggregate endpoints for display convenience.
https://admin.clarula.de will become the only operator origin.
Caddy will route explicit operator APIs and provider callbacks on that host to the backend and forward declared operator documents and assets to the operator frontend image.
Only declared route families with bounded dynamic segments may use a client-shell rewrite; unknown paths return a real 404 instead of a global application fallback.
The public einfache-eRechnung and Clarula origins will not serve, proxy, or redirect /admin paths after the coordinated cutover; those paths will return 404.
The former routes are not a compatibility API, and in-flight short-lived login and OAuth attempts will be allowed to expire before removal.
All browser operations for the new application will use dedicated, versioned operator APIs; they will not enter the product-v1 contract. Backend servlet ownership is explicit and non-overlapping:
-
/api/operator/v1/auth/**belongs to the EER sibling for operator login, verification, session bootstrap, logout, and the existing remember-session persistence. -
/api/operator/v1/eer/**belongs to the EER sibling for partner administration. -
/api/operator/v1/clarula/**belongs to the Clarula sibling for firm, DATEV, and mandate administration, including the DATEV callback.
The root servlet registry will enforce the same longest-path ownership; Caddy routing is not a substitute for backend dispatch boundaries.
EER partner operations remain implemented and transacted in :eer; Clarula firm, DATEV, and mandate operations remain implemented and transacted in :clarula.
The backend will publish separate generated operator contracts and clients for authentication, EER, and Clarula so transport types retain their ownership even though one frontend consumes all three.
Changes within operator v1 remain additive; a breaking change requires a new major contract and a migration period because frontend and backend artifacts deploy independently.
The root composition layer and :core will not gain product business logic or persistence.
Backend APIs will return JSON or application/problem+json, including 401 and 403, and will not redirect API requests to HTML.
The configuration allow-list, operator/customer identity separation, short-lived operator JWT, and persistent remember-session model from ADR-13: Admin Identity via Configuration, Not as a Customer Role remain active. EER will continue to own magic-link issuance and verification, remember-session persistence and refresh, session bootstrap, and logout behind the operator auth contract. The product-neutral root filter will validate the short-lived JWT before servlet dispatch but will remain persistence-free; Clarula will not access EER session persistence. The backend verification endpoint will consume the one-time magic-link token and redirect to a token-free frontend URL. Login tokens will not enter frontend state, browser-visible errors, analytics, referrers, or request logs, and magic-link submission will remain enumeration-safe and receive a dedicated edge rate limit.
The admin_session and admin_remember_session cookies will be host-only for admin.clarula.de, HTTP-only, Secure, and SameSite=Lax; because the host is operator-only, their path will be /.
Caddy will remove cookie and authorization headers before forwarding static requests to Nginx; the operator Nginx container will have no backend proxy capability or backend network access.
Static files and the login shell may be fetched without authentication, but every administrative datum and mutation requires backend authorization.
A read-only auth bootstrap operation will return a signed CSRF token bound to the stable operator remember session and current operator identity; it will not enable CORS, so another origin cannot read the token.
Before business dispatch, the root security filter will reject every authenticated unsafe operator request whose CSRF header is missing or invalid, or whose Origin is absent or differs from https://admin.clarula.de.
The CSRF token remains valid across short-lived JWT expiry so the frontend can use it for remember-session refresh; logout, re-authentication, remember-session revocation, or remember-session expiry invalidates it.
Pre-authentication magic-link submission is exempt from the CSRF token because it has no authenticated ambient authority, but it still requires the exact operator Origin, enumeration-safe behavior, and edge rate limiting.
The backend-owned one-time verification request establishes the session before redirecting to the token-free frontend URL.
DATEV authorization will continue to use server-held state, nonce, PKCE verifier, initiating operator, firm binding, and expiry.
Its registered callback will move to the operator API on admin.clarula.de and remain backend-owned; the backend will redirect only the sanitized outcome to a frontend route.
Provider codes, tokens, state, and internal correlation values will not enter static documents, client logs, analytics, or frontend state.
The operator host will be non-indexable and use no product analytics.
Operator HTML, APIs, auth responses, and callback-result responses will use no-store, Referrer-Policy: no-referrer, X-Robots-Tag: noindex, nofollow, and a restrictive content-security policy; content-addressed static assets may retain immutable caching.
Static Nginx access logging will be disabled, while Caddy and backend observability will omit or redact token-bearing request targets.
The backend will emit privacy-minimized audit events for login, denial, logout, refresh, revocation, EER partner mutations, Clarula firm lifecycle changes, DATEV connection and import actions, and mandate confirmations.
Events will include operator identity, product, stable resource identifier, outcome, and correlation identifier, but no token, provider body, or sensitive request payload.
The new image will join the certified composite runtime snapshot from ADR-28: Certified Composite Runtime Snapshot Promotion; the certified runtime therefore grows from six to seven application images.
This decision partially supersedes ADR-13: Admin Identity via Configuration, Not as a Customer Role for the operator origin, routes, and cookie path; ADR-25: Static Nuxt Product Frontends for retaining backend-rendered admin presentation; ADR-28: Certified Composite Runtime Snapshot Promotion for the six-image snapshot; ADR-30: One BootJar with Product-Isolated Spring Child Contexts for the Clarula /admin/clarula/* servlet mapping; and ADR-32: Clarula Firm as DATEV and Tenant Anchor for the implemented Clarula admin UI path.
Their identity, static product frontend, promotion, modular-monolith, and tenant-ownership decisions remain active.
Consequences
- Positive
-
-
Operators get one coherent workspace and session for both products.
-
Public product origins no longer expose administrative routes or cookies.
-
Admin presentation and backend business capabilities can evolve independently through explicit contracts.
-
Product module, transaction, and database boundaries remain enforceable.
-
The new design is not constrained by the existing backend templates.
-
- Negative
-
-
Deployment, patching, certification, smoke testing, and recovery gain a seventh application image.
-
The origin cutover invalidates existing operator sessions, bookmarks, magic links, and unfinished DATEV authorization attempts.
-
Both product areas share one frontend release and can be affected by one shell defect.
-
Cookie-authenticated JSON mutations require explicit CSRF and origin protection.
-
The publicly retrievable static shell cannot itself protect data; security depends on backend authorization and correct edge routing.
-
- Neutral
-
-
The
clarula.desubdomain is the operator entry point, not a change in EER domain or data ownership. -
The backend remains one BootJar and process, and the products retain separate sibling contexts and logical databases.
-
EER continues to own remember-session persistence behind the product-neutral auth contract until scale or role requirements justify a separate identity decision.
-
Thymeleaf may remain in the backend for non-admin rendering after the administrative templates are removed.
-
Compliance
-
Frontend dependency checks keep EER and Clarula feature modules and generated transport types separate.
-
Backend architecture and database-isolation tests continue to reject cross-product imports, repositories, and transactions.
-
Contract tests verify versioned operator APIs, JSON authorization failures, CSRF enforcement, and product route ownership.
-
Edge tests prove that
admin.clarula.deowns operator documents, APIs, cookies, and callbacks while/adminreturns404on all product origins. -
Deployment tests verify the unprivileged static image, stripped credentials, security headers, release metadata, and inclusion in the certified runtime snapshot.
ADR-34: EER-Owned Format-Neutral Invoice Model as Conversion Core
- Status
-
Accepted
- Date
-
2026-07-16
- Supersedes
- Partially supersedes
-
ADR-18: LLM-Only Semantic Extraction with Specialized Parallel Calls for its intermediate representation, normalization ownership, and ZUGFeRD-shaped handoff, and ADR-22: Diagnosis-First Validation Failure Routing for XML-backed correction state. Their extraction strategy and diagnosis-first routing remain active.
Context
The EER conversion pipeline assembled ExtractedInvoice under formats.zugferd, applied EN 16931-oriented normalizers to that type, mapped it directly to Mustang, and later treated generated ZUGFeRD XML as correction and delivery state.
Although implemented in Kotlin, that handoff was not format-neutral: it mixed source observations, interpreted values, EN 16931 terms, and fields shaped for one renderer.
It also made the EER invoice.conversion capability depend on a model owned by a target-format package.
EER needs one stable semantic interpretation for current ZUGFeRD EN16931 generation and possible future XRechnung generation without generating and reparsing ZUGFeRD XML first. Those targets overlap but differ in mandatory fields, syntax, validation, and transport metadata. Using either target schema or Mustang’s object model as application state would leak target concerns and lose source evidence needed for diagnosis and correction.
ADR-30: One BootJar with Product-Isolated Spring Child Contexts now places EER and Clarula in isolated sibling application contexts and Gradle modules.
EER owns invoice conversion, customer correction, email, website, and partner-widget workflows under :eer; :core contains no product domain.
This decision therefore establishes an EER-owned model, not a cross-product shared model.
Clarula cannot import it under the accepted module dependency graph; any future genuinely shared invoice-analysis contract requires a separate decision and evidence that it is product-neutral.
ADR-16: Format-Neutral Invoice Conversion Pipeline anticipated a format-neutral pipeline but prescribed intermediate structures that were not retained when ADR-18: LLM-Only Semantic Extraction with Specialized Parallel Calls replaced the extraction flow. This decision preserves that strategic intent within the current EER boundary.
Decision Drivers
-
Give every invoice concept exactly one semantic value.
-
Preserve source-visible facts, evidence, ambiguity, and user corrections independently of generated artifacts.
-
Keep target schemas, libraries, and transport metadata at architecture edges.
-
Add an EER XRechnung adapter later without an XML-to-XML conversion path.
-
Keep context-dependent PDF interpretation in the specialized LLM calls and deterministic validation in Kotlin.
-
Remove generated XML as normal correction state.
-
Preserve the product isolation and persistence ownership established by ADR-30: One BootJar with Product-Isolated Spring Child Contexts and ADR-31: Separate Logical PostgreSQL Databases and Roles.
Considered Options
| Option | Pros | Cons |
|---|---|---|
Keep ZUGFeRD XML or Mustang |
Lowest migration effort. |
Couples extraction and correction to one target and library. XML round-trips can discard unsupported source facts. |
Use an EN 16931 schema model plus a source-evidence envelope |
Strong common basis for ZUGFeRD EN16931 and XRechnung. |
Makes an external standard the owner of the EER domain vocabulary and still couples interpretation to target terms. |
Maintain one extraction and correction model per output |
Each target can optimize independently. |
Duplicates extraction, evidence, and correction; results can diverge for the same PDF. |
Use an EER-owned semantic model with edge adapters |
One evidence-bound semantic interpretation serves EER workflows while output targets remain replaceable. |
Requires a deliberate migration and independent target conformance tests. |
Decision
The invoice.conversion capability in applications/backend/eer owns the format-neutral invoice model and the transition from source evidence to EER invoice semantics.
The unchanged source document remains authoritative for what the issuer stated.
The semantic model is EER’s authoritative structured processing and correction state, but neither extraction nor a recognition correction proves that a defective source invoice became legally valid.
Generated XML and PDFs are target artifacts, not authoritative invoice semantics.
The model has four normative states:
SourceEvidence-
Immutable PDF identity, hash, byte size, complete page evidence, bounded analysis text, and truncation state. It contains no normalized or inferred replacement values.
ObservedInvoice-
Potentially incomplete or ambiguous semantic claims produced by extraction. Every material claim remains bound to
SourceEvidenceand records its interpretation origin. Provider DTOs and Structured Output schemas map once into this state but do not own it. InvoiceDraft-
The editable semantic aggregate used by diagnosis and correction. It uses invoice concepts such as parties, lines, price basis, taxes, totals, settlement, references, and notes rather than XML paths, BT numbers, Mustang types, or frontend form types. Each concept has one value; parallel
printed*, source/calculated, gross/net, or canonicalized variants are prohibited. Extracted claims and user corrections retain provenance. A draft may remain incomplete so diagnosis and correction can operate before target generation. ValidatedInvoice-
A typed result after common semantic-consistency and source-fidelity validation. It is not a legal-validity assertion and does not imply readiness for every output; each target adapter applies its own readiness and conformance rules.
A target-neutral DocumentAnalysisResult wraps these states for EER workflow handoff.
It distinguishes an invoice candidate with draft and findings, a safely classified non-invoice, an accepted but automatically unreadable source such as an image-only PDF, a permanently rejected source such as an invalid PDF or limit violation, and a transient processing failure.
Invalid candidates remain available for diagnosis instead of being discarded or forced into target XML.
Stable standards such as ISO currencies and countries or UNECE units are allowed; XML structures, ZUGFeRD profile identifiers, XRechnung syntax, transport metadata, and account data are not.
The following ownership and processing rules apply:
-
:eerowns the model, semantic analysis orchestration, common validation, correction snapshots, and target ports. Nothing is moved to:coremerely for implementation convenience. -
Native Xberg interpretation remains local and offline. Complementary plain text and layout-aware Markdown from ADR-17: Native Document Interpreters with Xberg, PDFBox, and Apache POI become one immutable per-page evidence representation before semantic extraction starts.
-
Specialized LLM calls return one complete semantic invoice interpretation. Context-dependent decisions such as line recognition, price kind, implicit quantity, amount-only rows, VAT-inclusive values, and settlement meaning belong to prompts.
-
Deterministic Kotlin code maps types, records provenance, performs arithmetic and consistency checks, and applies target rules. It does not reinterpret PDF layout or derive a missing unit price from line amount and quantity.
-
Every invoice concept has one semantic value. A target may calculate structural syntax values, but it never replaces or reconciles contradictory monetary values; contradictions fail closed.
-
Defaults and code mappings required only by one output belong to that output adapter.
-
ZUGFeRD generation remains governed by ADR-1: Hybrid ZUGFeRD Generation (XML + PDF Attachment) and maps to Mustang only inside
formats.zugferd. If Mustang exposes no writer API for an EN 16931 term, the adapter may deterministically augment the rendered CII XML from the same semantic value. The complete artifact must then pass XSD, Schematron, semantic rendered-value, and source-PDF identity checks. -
A future EER XRechnung adapter maps the same model directly and validates its CIUS independently.
-
Diagnosis and correction operate only on versioned
InvoiceDraftsnapshots. Recognition corrections never rewriteSourceEvidence, repair the original PDF, or silently reconcile source contradictions. -
The relational session metadata owns snapshot schema compatibility. A correction-request preflight checks it before JSON deserialization. An obsolete, active revision-zero snapshot is reanalyzed from its integrity-bound source PDF through the current conversion pipeline without an active database transaction or bound connection, then installed by a short compare-and-swap write that rechecks lifecycle, TTL, version, schema, revision, and hash. Corrected, completed, expired, corrupt, future-version, or concurrently changed snapshots are never silently replaced.
-
Preview and submission re-run common analysis and target readiness gates. Successful completion persists the exact generated ZUGFeRD PDF, verification PDF, and XML for the remaining session lifetime; later downloads serve these immutable artifacts without deserializing or rerendering the draft.
-
API DTOs, persistence records, and frontend forms map to commands and views; the internal model is not a public transport contract.
ExtractedInvoice remains only as the provider-facing Structured Outputs DTO.
ExtractedInvoiceObservationMapper immediately transfers its values into the EER model.
The legacy DTO-to-Mustang, canonicalization, deterministic extraction-normalizer, gross-to-net, and printed-totals validator paths are removed.
Gross-to-net interpretation occurs in the line-item prompt, while the original PDF remains bound through SourceEvidence.
The correction cutover is intentionally destructive.
Migration V123 intentionally deletes all existing pre-model edit sessions, removes XML correction state, and requires every new session to single-write a versioned, integrity-checked InvoiceDraft snapshot.
Draft changes and submission reservations are persisted before external side effects. Completion atomically stores its marker and exact result artifacts, which remain delivery state rather than editable semantics.
Only successful conversions are deliverable or claimable, and an already claimed widget result can be corrected only by the same account.
Consequences
- Positive
-
-
EER extraction, email, website, widget, diagnosis, and correction use one semantic state.
-
Source evidence and semantic claims survive generation and correction without lossy XML or library round-trips.
-
ZUGFeRD and future EER targets can evolve behind independent adapters.
-
Ambiguity and source contradictions remain visible instead of being silently normalized away.
-
The model remains inside the product boundary that owns its workflows and persistence.
-
- Negative
-
-
Extraction, mapping, orchestration, edit sessions, previews, validation, and tests must migrate together.
-
Breaking snapshot-model changes require an explicit relational schema-version bump; obsolete uncorrected sessions incur the cost and nondeterminism of one fresh analysis, recorded under the distinct
reanalysisanalytics origin. -
Completed artifacts duplicate bounded target bytes for the remainder of the 30-day edit-session lifetime so downloads do not depend on historical domain readers or generator behavior.
-
The one-time destructive cutover invalidates all existing pre-model edit capabilities, which are accepted as test-only data at this migration boundary.
-
Every target adapter needs independent readiness, mapping, and conformance tests.
-
The team must prevent the model from becoming a union of every target field.
-
- Neutral
-
-
The backend remains one deployed modular monolith with isolated EER and Clarula child contexts.
-
ADR-18: LLM-Only Semantic Extraction with Specialized Parallel Calls still governs specialized LLM extraction and retry behavior.
-
This decision does not select an XRechnung syntax, DATEV contract, or cross-product shared model.
-
Clarula remains isolated in
:clarulaand does not depend on EER invoice-domain code.
-
Compliance
-
The model imports no Mustang, XML/DOM/JAXB, Spring AI, Jackson, JPA, frontend, Clarula, or DATEV API types.
-
Architecture and Gradle boundary tests reject dependencies from the model toward target adapters and reject EER/Clarula cross-imports.
-
Target adapters do not mutate snapshots or write target defaults back into the model.
-
No step recomputes document totals to replace contradictory semantic totals.
-
Correction contract tests cover draft → API view → correction command → revised draft without an XML round-trip.
-
A versioned golden JSON fixture detects structural snapshot changes that lack an explicit schema-version decision. Snapshot tests prove unsupported versions are rejected before JSON binding, reanalysis holds no database transaction or connection and uses a guarded compare-and-swap, obsolete revision-zero sessions are rebuilt from the source PDF, and completed downloads remain byte-stable without loading the draft.
-
Source-evidence tests prove that semantic claims and user corrections remain bound to the immutable PDF identity.
-
ZUGFeRD tests cover every mapped EN 16931 field, deterministic CII augmentation, rendered-value comparison, XSD, Schematron, payment-role exclusivity, and PAN masking.
-
Unsupported, ambiguous, or lossy required mappings fail closed; adapters neither invent nor silently discard material facts.
-
Full LLM benchmark reports evaluate outcomes, critical fields, business fields, XML validity, notes, and attempts after prompt or evidence changes.
Related
-
ADR-1: Hybrid ZUGFeRD Generation (XML + PDF Attachment) — ZUGFeRD hybrid generation remains an EER edge strategy.
-
ADR-5: Monolith-First Architecture — one deployed modular monolith remains active.
-
ADR-6: Package-by-Feature Organization — package ownership remains feature-oriented inside
:eer. -
ADR-17: Native Document Interpreters with Xberg, PDFBox, and Apache POI — combined plain and layout-aware document evidence remains the input boundary.
-
ADR-18: LLM-Only Semantic Extraction with Specialized Parallel Calls — specialized extraction remains active with a semantic handoff.
-
ADR-22: Diagnosis-First Validation Failure Routing — diagnosis-first routing remains active with draft-backed correction state.
-
ADR-30: One BootJar with Product-Isolated Spring Child Contexts — EER owns invoice conversion in an isolated child application context.
-
ADR-31: Separate Logical PostgreSQL Databases and Roles — EER owns the migration and persistence boundary for draft snapshots.
Quality Requirements
This chapter details how well EER must perform its functions. Chapter 1 defines the top-level quality goals; this chapter expands them into a quality tree and measurable scenarios that drive architectural decisions.
Quality Tree
The quality tree shows each quality attribute, its sub-characteristics, and the scenarios that verify it. Priority reflects business impact: High scenarios shape architecture, Medium scenarios influence implementation choices.
Quality
├── Compliance [High]
│ ├── XSD Validation (QS-1)
│ ├── Schematron Validation — EN16931 rules (QS-1)
│ └── Mustang Business Validation — arithmetic checks (QS-2)
│
├── Usability [High]
│ ├── Email-based workflow — no login for core flow (QS-3)
│ ├── German error messages — MustangErrorHumanizer (QS-4)
│ └── Diagnosis-first recovery; editor only for recognition errors (QS-4)
│
├── Data Privacy [High]
│ ├── EU-only processing — Mistral AI (QS-5)
│ ├── No invoice content persistence (QS-5)
│ └── Temporary edit sessions with cleanup (QS-6)
│
├── Reliability [Medium]
│ ├── Sealed result types — no silent failures (QS-7, QS-8)
│ ├── Business event tracking for auditability (QS-10)
│ └── Healthcheck endpoints (QS-8)
│
├── Performance [Medium]
│ └── End-to-end email response time (QS-9)
│
├── Security [High]
│ ├── Webhook signature validation — HMAC-SHA256 (QS-11)
│ ├── Edge rate limiting — Caddy (QS-12)
│ ├── JWT authentication with HTTP-only cookies (QS-11)
│ ├── Encrypted, source-bound DATEV authorization (QS-13)
│ └── Non-root Docker containers (QS-11)
│
└── Operability [Medium]
└── OpenTelemetry tracing to New Relic (QS-10)
Quality Scenarios
Overview
The following table summarizes all quality scenarios. Detailed descriptions follow in Scenario Details.
| ID | Quality Attribute | Scenario | Expected Response | Priority |
|---|---|---|---|---|
QS-1 |
Compliance |
User submits a valid German B2B invoice PDF |
System generates EN16931-compliant ZUGFeRD 2.5 XML that passes XSD and Schematron validation |
High |
QS-2 |
Compliance |
Generated ZUGFeRD PDF is checked with Mustang validator |
Validation passes with zero errors |
High |
QS-3 |
Usability |
Non-technical small business owner sends first PDF |
Receives e-invoice via email within minutes, no registration needed for email flow |
High |
QS-4 |
Usability |
Validation fails because the invoice’s monetary values conflict |
User receives a German diagnosis that requires source correction and resubmission; the editor is offered only for a recognition or mapping error |
High |
QS-5 |
Privacy |
User asks where their data is processed |
All LLM calls go to Mistral AI EU servers, no data leaves EU |
High |
QS-6 |
Privacy |
Invoice edit session expires |
Scheduler deletes edit sessions after configured TTL |
Medium |
QS-7 |
Reliability |
Mistral AI API is temporarily unavailable |
System returns extraction error email, no data loss, retryable |
Medium |
QS-8 |
Reliability |
Database connection is lost during processing |
Transaction rolls back, user gets error email, can resend |
Medium |
QS-9 |
Performance |
User sends a standard 1-page invoice |
E-invoice response email arrives within 60 seconds |
Medium |
QS-10 |
Operability |
System error occurs in production |
Error is captured by OpenTelemetry, visible in New Relic with full trace |
Medium |
QS-11 |
Security |
Attacker sends forged webhook request |
Mailgun HMAC signature validation rejects the request |
High |
QS-12 |
Security |
Attacker floods abuse-prone endpoints (magic link, contact form, free website upload, invoice edit) to exhaust server resources or brute-force tokens |
Caddy blocks requests exceeding per-IP thresholds with HTTP 429 before they reach the application |
High |
QS-13 |
Security and reliability |
A DATEV token expires, is rotated, or belongs to a different firm during mandate import |
Clarula serializes refresh, replaces the encrypted token pair atomically, and fails closed on source mismatch without changing the imported snapshot or confirmations |
High |
Scenario Details
QS-1: EN16931 Compliance on Valid Input
| Source |
Authenticated user |
| Stimulus |
Sends a valid German B2B invoice PDF via email |
| Environment |
Normal operation |
| Artifact |
|
| Response |
System extracts invoice data via Mistral AI, generates ZUGFeRD 2.5 XML (EN16931 profile), validates against XSD and Schematron rules, embeds XML into PDF/A-3, and replies with the hybrid PDF |
| Measure |
Generated XML passes XSD validation. Schematron validation produces zero EN16931 rule violations. Every generated invoice is validated before delivery — invalid invoices are never sent to users. |
| Architecture impact |
Mustang Project runs EN16931 validation on every generated ZUGFeRD invoice. The pipeline never delivers a failing target; it routes findings through diagnosis-first recovery instead of treating generated XML as editable invoice state. |
QS-2: Mustang Business Validation
| Source |
System (post-generation) |
| Stimulus |
Generated ZUGFeRD PDF is verified against Mustang validator |
| Environment |
Normal operation, after XML generation |
| Artifact |
|
| Response |
Mustang validates arithmetic consistency (line item totals, tax calculations, grand total), structural completeness, and XML/PDF embedding correctness |
| Measure |
Zero validation errors. Any arithmetic mismatch triggers a German-language diagnosis and no delivery; immutable source evidence cannot be overwritten through the editor. |
| Architecture impact |
Validation is a mandatory step in the generation pipeline, not an optional check. The |
QS-3: Zero Learning Curve
| Source |
Non-technical small business owner (first-time user) |
| Stimulus |
Sends a PDF invoice to the EER email address |
| Environment |
First use, no prior registration |
| Artifact |
|
| Response |
System receives the email via Mailgun webhook, creates an account from the sender’s email address, extracts invoice data, generates the ZUGFeRD e-invoice, and replies to the sender |
| Measure |
User receives a compliant e-invoice within minutes. No registration form, no documentation, no software installation required. First email submission creates the account automatically. |
| Architecture impact |
Email-as-interface drives the entire system design. The Mailgun webhook is the primary entry point. Account creation is implicit on first contact. The web UI exists only for corrections and account management. |
QS-4: Actionable German Error Messages
| Source |
User who submitted a PDF with extraction or source-invoice findings |
| Stimulus |
Common or target validation detects mismatched totals, missing fields, or a recognition error |
| Environment |
Normal operation, validation failure |
| Artifact |
Diagnosis workflow and |
| Response |
System translates technical findings into a German diagnosis (e.g., "Die Summe der Positionen (450,00 €) stimmt nicht mit dem Gesamtbetrag (500,00 €) überein."). It asks for source correction and resubmission when the PDF itself is defective. Only a genuine recognition or mapping error opens a pre-filled correction draft; Clarula may instead submit the unchanged original for firm review. |
| Measure |
Every supported validation fixture names the affected field and routes to its expected recovery category. No source-defect fixture can alter |
| Architecture impact |
Diagnosis-first classification separates source defects from recognition errors before recovery. Correction revises a versioned |
QS-5: EU-Only Data Processing
| Source |
User or data protection authority |
| Stimulus |
Inquiry about where invoice data is processed |
| Environment |
Any |
| Artifact |
All external integrations (Mistral AI, Mailgun, Hetzner VPS) |
| Response |
All invoice content processing occurs within the EU. Mistral AI (French company) hosts models on EU servers. The application runs on a Hetzner VPS in Germany. No invoice content reaches US jurisdiction. |
| Measure |
No outbound API call transmits invoice content to a non-EU endpoint. Verifiable through network policy and OpenTelemetry traces. |
| Architecture impact |
EU data residency drove the migration from OpenAI to Mistral AI. Spring AI’s provider abstraction made this a configuration change. See Solution Strategy: EU-First Data Processing and constraint LC-2 in Architecture Constraints. |
QS-6: Edit Session Cleanup
| Source |
System scheduler |
| Stimulus |
Edit session exceeds configured TTL |
| Environment |
Background processing |
| Artifact |
|
| Response |
Scheduled job deletes expired edit sessions and their associated data from the database |
| Measure |
No edit session persists beyond its TTL. The scheduler runs periodically and removes all expired sessions in a single sweep. |
| Architecture impact |
Privacy by design — the system stores no invoice content permanently. |
QS-7: LLM Provider Unavailability
| Source |
Mistral AI API |
| Stimulus |
Returns HTTP 5xx or connection timeout during extraction |
| Environment |
Degraded (third-party outage) |
| Artifact |
|
| Response |
System catches the extraction failure, records a business event, and sends the user an error email explaining that extraction failed temporarily. The user can resend the PDF later. |
| Measure |
No invoice is silently lost. User receives an error email within 60 seconds of the final failed attempt. The sealed |
| Architecture impact |
Each extractor performs one bounded infrastructure retry through |
QS-8: Database Failure During Processing
| Source |
PostgreSQL database |
| Stimulus |
Connection lost or transaction timeout during invoice processing |
| Environment |
Degraded (infrastructure failure) |
| Artifact |
Spring transaction management, |
| Response |
The Spring transaction rolls back. If email delivery is still possible, the user receives an error email. If not, the user can resend the original PDF — the system is stateless with respect to invoice content. |
| Measure |
No partial writes persist. Database returns to a consistent state after recovery. User can retry by resending the email. |
| Architecture impact |
Spring’s declarative transaction management ensures atomicity. The "no master data" strategy (see Solution Strategy) means losing a transaction loses no irreplaceable data — the user still has the original PDF. |
QS-9: Email Response Time
| Source |
User |
| Stimulus |
Sends a standard 1-page invoice PDF |
| Environment |
Normal operation, no system degradation |
| Artifact |
Full processing pipeline — webhook → extraction → validation → generation → email reply |
| Response |
User receives the ZUGFeRD e-invoice as an email reply |
| Measure |
End-to-end response time under 60 seconds (target: 30 seconds). LLM extraction accounts for the majority of this time. Measured via OpenTelemetry trace duration. |
| Architecture impact |
LLM extraction is the bottleneck. The rest of the pipeline (validation, PDF embedding, email sending) adds < 5 seconds. Performance optimization focuses on prompt efficiency and model selection, not application-level caching. |
QS-10: Production Error Observability
| Source |
System (error condition in production) |
| Stimulus |
Unhandled exception or business-level error |
| Environment |
Production |
| Artifact |
OpenTelemetry Java Agent, New Relic |
| Response |
Error is captured as an OpenTelemetry span with full stack trace, request context, and correlation IDs. Business events record the failure in PostgreSQL for auditing. |
| Measure |
Every error is visible in New Relic within 30 seconds. Traces include the full request path from webhook receipt to failure point. No error goes unrecorded. |
| Architecture impact |
OpenTelemetry Java Agent instruments all Spring components, JPA queries, and HTTP calls without code changes. Business events provide a domain-level audit trail alongside technical traces. See constraint TC-12 in Architecture Constraints. |
QS-11: Webhook Forgery Prevention
| Source |
External attacker |
| Stimulus |
Sends a forged HTTP POST to the inbound email webhook endpoint |
| Environment |
Normal operation |
| Artifact |
|
| Response |
The validator computes HMAC-SHA256 over the request’s timestamp and token using the shared webhook signing key. If the signature does not match, the request is rejected with HTTP 403. |
| Measure |
Every webhook request is validated before processing. Forged requests never reach the processing pipeline. Validation failures are logged with the source IP. |
| Architecture impact |
|
QS-13: Source-Bound DATEV Authorization
| Source |
DATEV provider or Clarula administrator |
| Stimulus |
An access token expires, a single-use refresh token rotates, or the authorized DATEV firm differs from the stored connection |
| Environment |
Mock, sandbox, or production adapter mode |
| Artifact |
|
| Response |
Clarula serializes refresh-token use, encrypts and persists the replacement pair, verifies the stored DATEV firm reference before import, and rejects ambiguous or changed sources |
| Measure |
No raw token appears in persistence, logs, UI, or analytics. One refresh token is submitted at most once concurrently. A source mismatch persists no mandate changes and preserves existing confirmations under their original source. |
| Architecture impact |
A stable firm row serializes connection replacement, refresh, import persistence, and disconnect. Imported snapshots retain their source firm after disconnect; same-source re-import is idempotent, while source replacement is a separate future operation. Adapter-mode changes require revoking real provider grants, stopping the backend, and resetting the complete Clarula database rather than runtime transition logic. See ADR-29. |
QS-12: Edge Rate Limiting on Abuse-Prone Endpoints
| Source |
External attacker |
| Stimulus |
Floods abuse-prone endpoints (magic link authentication, contact form, free website upload, invoice edit submission) to brute-force tokens or exhaust server resources (CPU, LLM budget, outbound email quota) |
| Environment |
Normal operation, potential DoS or brute-force attack |
| Artifact |
Caddy reverse proxy with |
| Response |
Caddy buckets requests per client IP via |
| Measure |
Canonical and compatibility aliases share one budget on every public host. Contact and free upload allow 5 requests/hour per IP; magic-link and registration submission allow 20 requests/5 minutes; correction preview/submission, diagnosis editor entry, and widget uploads allow 30 requests/5 minutes; widget auth, claim-with-session, and session-identity share one budget of 30 requests/5 minutes; frontend failure reporting allows 120 requests/5 minutes; widget result/status reads allow 300 requests/5 minutes. |
| Architecture impact |
Abuse-prone inbound endpoints are rate-limited at the edge. The Spring Boot application additionally contains outbound provider rate-limiting logic for accepted work that later calls Mistral or Mailgun. Blocking at the reverse proxy prevents abusive traffic from consuming any application resources, while |
QS-13: Provider Rate-Limit Resilience for Outbound API Calls
| Source |
External provider (for example Mistral AI or Mailgun) |
| Stimulus |
Returns HTTP 429 because concurrent conversions or mail bursts exceed the provider quota |
| Environment |
Normal operation under bursty customer traffic |
| Artifact |
|
| Response |
Outbound calls are queued inside the application, paced per configured provider budget, and retried once when the provider sends |
| Measure |
Productive Mistral chat calls stay at or below the configured pace ( |
| Architecture impact |
Edge rate limiting and outbound provider rate limiting solve different problems. Caddy still protects abuse-prone inbound endpoints before they consume app resources. |
Traceability to Architecture
The following table maps quality scenarios to the architectural decisions and components that address them.
| Scenario | Architectural Decision | Implementing Component |
|---|---|---|
QS-1, QS-2 |
Mandatory validation before delivery |
|
QS-3 |
Email as primary interface |
|
QS-4 |
Diagnosis-first recovery with source-evidence preservation |
Diagnosis workflow, versioned invoice drafts, |
QS-5 |
EU-first data processing |
Mistral AI via Spring AI, Hetzner VPS |
QS-6 |
Privacy by design — no persistent content |
|
QS-7, QS-8 |
Sealed result types for exhaustive error handling |
|
QS-9 |
LLM extraction as primary bottleneck |
Mistral AI |
QS-10 |
Zero-code observability via Java agent |
OpenTelemetry Java Agent, New Relic export |
QS-11 |
Webhook signature validation |
|
QS-12 |
Edge rate limiting at reverse proxy |
Caddy |
QS-13 |
Outbound provider rate limiting with queueing and 429 retry |
|
Risks and Technical Debt
This chapter documents known technical risks and accumulated technical debt. Each entry includes an assessment, current mitigation measures, and suggested next steps.
Review this chapter quarterly — or whenever a new external dependency, architectural change, or incident reveals a new risk.
Risk Matrix
The matrix plots all identified risks by probability and impact. Items in the upper-right quadrant require active mitigation; items in the lower-left quadrant are acceptable with monitoring.
| High Impact | Medium Impact | Low Impact | |
|---|---|---|---|
High Probability |
R-2 LLM Extraction Quality |
R-6 Regulatory Changes |
|
Medium Probability |
R-1 LLM Provider Dependency |
R-5 Email Deliverability |
|
Low Probability |
R-3 Key Person Risk (Bus Factor) |
Priority Summary
| ID | Risk | Category | Probability | Impact | Priority |
|---|---|---|---|---|---|
R-1 |
LLM Provider Dependency |
External |
Medium (3) |
High (4) |
12 — High |
R-2 |
LLM Extraction Quality |
Technical |
High (4) |
High (4) |
16 — Critical |
R-3 |
Key Person Risk (Bus Factor) |
Organizational |
Low (2) |
Critical (5) |
10 — High |
R-4 |
Single VPS Deployment |
Infrastructure |
Low (2) |
High (4) |
8 — Medium |
R-5 |
Email Deliverability |
External |
Medium (3) |
Medium (3) |
9 — Medium |
R-6 |
Regulatory Changes |
Business |
High (4) |
Medium (3) |
12 — High |
Priority = Probability × Impact. Critical: 15—25 (act now). High: 10—14 (plan mitigation this quarter). Medium: 5—9 (monitor and schedule).
Technical Risks
R-1: LLM Provider Dependency
Category: External
Probability: Medium (3) — any API provider can experience outages or degradation
Impact: High (4) — core conversion pipeline stops; no invoices processed
Priority: 12 (High)
Description: EER depends on Mistral AI’s Chat Completions API for every invoice conversion. An API outage, rate-limit change, or model deprecation halts all invoice processing. No local fallback or secondary provider exists.
Current Mitigation:
-
Spring AI abstracts the LLM provider behind a
ChatClientinterface. Switching to another provider (OpenAI, Anthropic, a self-hosted model) requires configuration changes, not code changes. See Solution Strategy, EU-First Data Processing. -
OpenTelemetry traces capture API latency and error rates; New Relic alerts on anomalies.
Residual Risk: A provider switch still requires prompt tuning and extraction-quality verification. Spring AI abstracts the transport, not the prompt engineering.
Suggested Next Steps:
-
Monitor Mistral AI status page and subscribe to incident notifications.
-
Define a runbook for switching to a backup provider (estimated effort: 1—2 days for prompt re-tuning).
-
Evaluate whether a lightweight local model (e.g., Ollama with a small model) could serve as a degraded-mode fallback for simple invoices.
R-2: LLM Extraction Quality / Hallucination
Category: Technical
Probability: High (4) — LLMs hallucinate by design; extraction errors occur regularly
Impact: High (4) — incorrect e-invoices carry legal and financial consequences
Priority: 16 (Critical)
Description: The LLM may generate wrong amounts, fabricate line items, or assign incorrect buyer/seller names. Structural validation (XSD, Schematron, Mustang arithmetic checks) catches format violations and arithmetic errors, but cannot detect semantically wrong data — a plausible but incorrect buyer name passes all validators.
Current Mitigation:
-
Multi-stage validation: XSD schema check, EN 16931 Schematron rules, Mustang arithmetic verification.
-
Edit-and-retry workflow: users receive a pre-filled correction form when validation fails (see Solution Strategy, Fail-Fast with Edit-and-Retry).
-
Users see the extracted data before the e-invoice leaves the system (in the correction UI), giving them a chance to spot semantic errors.
Residual Risk: Users who do not review the extracted data may forward incorrect e-invoices. The system cannot distinguish "correct extraction" from "plausible hallucination" for semantic fields like names and addresses.
Suggested Next Steps:
-
Add confidence scoring: flag fields where the LLM reports low confidence and force user review for those fields.
-
Build a regression test suite of real-world invoices with known-good extraction results. Run it after every prompt change.
-
Consider a secondary LLM call for cross-validation on high-value invoices.
R-3: Key Person Risk (Bus Factor)
Category: Organizational
Probability: Low (2) — small team, but healthy individuals
Impact: Critical (5) — critical knowledge concentrated in few people; loss of a key person disrupts maintenance, deployment, and troubleshooting
Priority: 10 (High)
Description: Bus factor is low. If a key team member is unavailable for an extended period, the system runs unattended. Bug fixes, security patches, and operational incidents may have no immediate responder.
Current Mitigation:
-
Clean, tested codebase: comprehensive unit and integration tests, enforced formatting, CI/CD pipeline.
-
This arc42 documentation explains the system architecture, deployment, and operational procedures.
-
Docker Compose deployment: another developer can re-deploy the system by running
docker compose up. -
Simple technology stack: Kotlin/Spring Boot/PostgreSQL requires no exotic expertise.
Residual Risk: Documentation and clean code reduce onboarding time but do not eliminate it. A new developer still needs days to become productive with the codebase.
Suggested Next Steps:
-
Keep this architecture documentation current. Outdated docs are worse than no docs.
-
Write a one-page operations runbook covering deployment, failed-deployment recovery, database backup/restore, and incident response.
-
Consider a "trusted second" — a developer who has access to production credentials and has deployed at least once.
R-4: Single VPS Deployment
Category: Infrastructure
Probability: Low (2) — Hetzner has strong uptime history
Impact: High (4) — complete service outage; no redundancy
Priority: 8 (Medium)
Description: All components (application, database, reverse proxy) run on a single Hetzner VPS. A hardware failure, data center incident, or host OS crash takes down the entire service. See Architecture Constraints, TC-6.
Current Mitigation:
-
The dev environment is already reproducible from Terraform and cloud-init, which proves the replaceable-host pattern on a production-like stack.
-
PostgreSQL data resides on a persistent Docker volume.
-
Container restart policies (
unless-stopped) handle transient process crashes.
Residual Risk: Production has not yet fully adopted the same replaceable-host model as dev. No automated failover. Database volume lives on a single disk — disk failure means data loss. Recovery time depends on how quickly the developer notices and responds.
Suggested Next Steps:
-
Implement automated PostgreSQL backups (e.g.,
pg_dumpcron job to offsite storage). -
Finish the prod migration to the Terraform-defined, replaceable-host model already exercised in dev.
-
Accept this risk as appropriate for the current user base and revenue. Revisit when SLA commitments exist.
R-5: Email Deliverability
Category: External
Probability: Medium (3) — email deliverability is inherently unreliable
Impact: Medium (3) — user does not receive the converted e-invoice
Priority: 9 (Medium)
Description: Response emails carrying the ZUGFeRD e-invoice may land in the recipient’s spam folder or be blocked by corporate email gateways. The user has no indication that the conversion succeeded — they simply never see the result.
Current Mitigation:
-
Mailgun manages sender reputation, bounce handling, and feedback loops.
-
SPF, DKIM, and DMARC records are configured for the sending domain.
Residual Risk: Corporate email filters apply custom rules that SPF/DKIM/DMARC cannot override. Attachments (especially PDFs) trigger spam filters at some providers.
Suggested Next Steps:
-
Monitor Mailgun delivery metrics (bounce rate, spam complaints) via the dashboard.
-
Add a "your e-invoice is ready" status page or notification endpoint so users can retrieve their result if the email fails.
-
Consider providing a download link in addition to the email attachment as a fallback delivery channel.
R-6: Regulatory Changes
Category: Business
Probability: High (4) — e-invoicing regulations evolve actively in Germany and the EU
Impact: Medium (3) — requires development effort to adapt; non-compliance risks legal exposure
Priority: 12 (High)
Description: The German e-invoicing mandate is still evolving. New mandatory fields, format version updates (ZUGFeRD 2.5, XRechnung 3.x), or additional validation rules may require prompt adjustments, library upgrades, or new format support.
Current Mitigation:
-
Mustang Project actively tracks ZUGFeRD and EN 16931 standard updates; upgrading the library picks up new validation rules.
-
Package-by-feature architecture isolates format-specific code in
formats.zugferd; new formats (XRechnung, Peppol) can be added as parallel packages without restructuring. -
See Solution Strategy, Technology Decisions, Mustang Project.
Residual Risk: Breaking changes in the standard may require prompt re-engineering and a new extraction/validation cycle. Regulatory timelines may not align with the team’s development capacity.
Suggested Next Steps:
-
Subscribe to ZUGFeRD Forum and FeRD mailing lists for early notice of standard changes.
-
Track BMF (Bundesministerium der Finanzen) announcements on e-invoicing timeline adjustments.
-
Maintain a buffer in the development roadmap for regulatory adaptation work.
Technical Debt Inventory
| ID | Severity | Title | Status | Effort |
|---|---|---|---|---|
TD-1 |
Low |
Outdated SOLUTION_OVERVIEW.md |
✅ Resolved |
S (< 1 day) |
TD-2 |
Low |
Mixed package naming convention |
✅ Resolved |
M (1—2 days) |
TD-3 |
High |
No resilience patterns for external services |
Open |
M (1—2 days) |
TD-4 |
High |
No automated database backup |
✅ Resolved |
S (< 1 day) |
TD-5 |
Medium |
AGB (Terms of Service) missing |
Open |
M (1—2 days, plus legal review) |
TD-6 |
Low |
No performance testing |
Open |
M (1—2 days) |
TD-7 |
Medium |
Prod still uses the older deployment and secret model |
Open |
M (1—2 days) |
TD-1: Outdated SOLUTION_OVERVIEW.md
Severity: Low
Status: ✅ Resolved (2026-04-05)
Effort: Small (< 1 day)
Description:
SOLUTION_OVERVIEW.md documented the package structure with outdated names (api/, config/, domain/, extraction/, processing/, generation/, validation/, template/, service/).
The actual package structure uses business-capability names (email.ingestion, formats.zugferd, invoice.editing, etc.) as documented in Solution Strategy, Package-by-Feature.
Resolution: File deleted. All information is covered by the arc42 architecture documentation (chapters 1—12).
TD-2: Mixed Package Naming Convention
Severity: Low
Status: ✅ Resolved (2026-04-05)
Effort: Medium (1—2 days)
Description:
Some packages used dot-separated directory names (email.ingestion/, invoice.editing/, email.delivery/) while others used standard subdirectory hierarchy (formats/zugferd/, business/tracking/).
The invoice.editing capability was split across two filesystem roots: invoice.editing/ (session, notification) and invoice/editing/ (UI).
Impact: Inconsistency added minor cognitive load when navigating the codebase. No runtime or correctness impact.
Resolution: Standardized all packages to use subdirectory hierarchy, following the Kotlin coding conventions:
-
email.delivery/→email/delivery/ -
email.ingestion/→email/ingestion/ -
invoice.editing/→ merged intoinvoice/editing/
business/tracking/, formats/zugferd/, identity/, and infrastructure/ already used subdirectories and required no changes.
TD-3: No Resilience Patterns for External Services
Severity: High
Status: In Progress
Effort: Medium (1—2 days)
Description:
External service calls still lack full resilience patterns, but they no longer run completely unprotected.
Application-level outbound rate limiting with queueing and one Retry-After-aware retry now protects Mistral and Mailgun from simple burst overruns.
-
A Mistral AI timeout still blocks the processing thread until the HTTP client times out.
-
Mailgun delivery failures beyond provider-rate-limit bursts are still not retried.
-
During a provider outage, incoming invoices are processed and fail without later recovery.
Impact: Transient API failures cause permanent processing failures. Users must re-send their invoice and hope the API is back up.
Proposed Solution:
-
Extend retries beyond HTTP 429 to selected transient provider failures where safe.
-
Add retry for Mailgun delivery failures beyond explicit rate-limit handling.
-
Consider a dead-letter mechanism: persist failed processing attempts and retry them when the external service recovers.
-
A full circuit breaker pattern may be over-engineered for current traffic — start with retries.
This debt directly increases the impact of R-1 (LLM Provider Dependency) and R-5 (Email Deliverability).
TD-4: No Automated Database Backup
Severity: High
Status: ✅ Resolved (2026-04-20)
Effort: Small (< 1 day)
Description: PostgreSQL runs in a Docker container with data on a persistent volume, but no automated backup exists. No disaster recovery procedure is documented.
Impact:
A disk failure or accidental docker volume rm destroys all user accounts, business events, and processing history.
This amplifies R-4 (Single VPS Deployment).
Resolution:
Workflow .github/workflows/nightly-database-backup.yml (Nightly Database Backup) runs a nightly production full-state backup and still provides manual dev backup and dev restore entry points.
Production backups are encrypted tar.gz.gpg archives containing custom-format dumps of invoicex and metabase, Caddy state, runtime config, manifest, and checksums.
Dev backups remain encrypted SQL dumps for developer inspection and the current restore-dev action.
Backups are stored in two independent places: /opt/apps/invoicex/backups/ on the VM and as GitHub Actions artifacts with 90-day retention.
Prod restore stays a documented manual SSH procedure.
Operational runbook and local extractor: deployment/BACKUPS.md, deployment/extract-latest-prod-backup.sh.
Offsite Object Storage upload, dev scheduling, and unified full-state restore support are tracked in issue #580.
TD-5: AGB (Terms of Service) Not Yet Created
Severity: Medium
Status: Open
Effort: Medium (1—2 days of drafting, plus legal review)
Description: The system operates without published Terms of Service (AGB). This was flagged as an open point in PDR: Tiering v2. Real payment processing cannot launch until AGB are finalized. See also Architecture Constraints, LC-7.
Impact: Blocks monetization. Users currently operate under an implicit free-trial agreement with no legal framework.
Proposed Solution:
-
Draft AGB covering: service scope, liability limitations, data processing, cancellation terms.
-
Get legal review (German Rechtsanwalt with IT law expertise).
-
Publish on the website and require acceptance before paid tier activation.
TD-6: No Automated Performance Testing
Severity: Low
Status: Open
Effort: Medium (1—2 days)
Description: LLM response times vary between 2 and 30 seconds depending on PDF complexity and API load, but no performance baseline or alerting threshold exists. The email processing pipeline has no load test.
Impact: Performance regressions go unnoticed until users complain. Capacity limits are unknown — the system may degrade under modest load without warning.
Proposed Solution:
-
Define a performance baseline: measure P50/P95/P99 processing times for a representative set of invoices.
-
Set up New Relic alerts when processing time exceeds the P95 threshold.
-
A formal load test is low priority at current scale — focus on the alerting first.
TD-7: Prod Still Uses the Older Deployment and Secret Model
Severity: Medium
Status: Open
Effort: Medium (1—2 days)
Description:
Dev already uses the new operating model: Terraform-defined infrastructure, a replaceable host, and 1Password-fetched secrets at workflow runtime.
Production still uses the older path: manual deploy workflow, .env generation from GitHub Environment secrets, and the legacy infrastructure module.
Impact: Operating two different deployment models increases cognitive load and makes disaster recovery, onboarding, and documentation harder. The team cannot yet apply the same rebuild procedure to the customer-facing environment that it already validated in dev.
Proposed Solution:
-
Migrate prod workflow secrets to the same 1Password runtime pattern used by dev.
-
Move prod infrastructure fully to the Terraform-defined replaceable-host model.
-
Retire the legacy prod module once the new prod path is live and documented.
Prioritized Action Plan
The following actions address the highest-priority risks and debt items. Order reflects a combination of priority score and implementation effort.
| Priority | Action | Addresses | Effort |
|---|---|---|---|
1 |
Set up automated PostgreSQL backups to offsite storage — ✅ Done (2026-04-20, manual workflow; scheduled + offsite deferred) |
TD-4, R-4 |
Small (< 1 day) |
2 |
Add retry with backoff for Mistral AI and Mailgun API calls — partially done (provider queueing + 429 retry); extend to broader transient failures |
TD-3, R-1, R-5 |
Medium (1—2 days) |
3 |
Draft and publish AGB (Terms of Service) |
TD-5 |
Medium (legal review needed) |
4 |
Build LLM extraction regression test suite |
R-2 |
Medium (ongoing) |
5 |
Migrate prod to the dev-proven operating model (Terraform-defined host + 1Password runtime secrets) |
TD-7, R-4 |
Medium (1—2 days) |
6 |
Write operations runbook (deployment, failed-deployment recovery, backup, incident response) |
R-3, R-4 |
Small (< 1 day) |
7 |
Define performance baselines and alerting thresholds |
TD-6, R-1 |
Small (< 1 day) |
8 |
Delete or redirect outdated SOLUTION_OVERVIEW.md — ✅ Done (2026-04-05) |
TD-1 |
Small (< 1 hour) |
9 |
Standardize package naming convention |
TD-2 |
Medium (1—2 days) |
Glossary
Terms used throughout this documentation and the backend codebase. Domain terms reflect German e-invoicing regulations and the ZUGFeRD ecosystem. Technical terms are specific to the shared backend and its EER and Clarula product contexts.
E-Invoicing Standards and Formats
- EN16931
-
European standard defining the semantic data model for electronic invoice core elements. ZUGFeRD 2.x and XRechnung both implement this standard. EER validates every generated invoice against EN16931 rules.
- Factur-X
-
French equivalent of ZUGFeRD, cross-border compatible. Factur-X 1.09 and ZUGFeRD 2.5 share the same technical specification. Potential future format for EER.
- PDF/A-3
-
ISO 19005-3 standard for long-term archival of PDF documents. Allows embedding arbitrary files — ZUGFeRD uses this to attach XML inside the PDF. ZUGFeRD technically requires PDF/A-3, but most readers also accept regular PDFs with embedded XML.
- Peppol BIS
-
Pan-European Public Procurement OnLine Business Interoperability Specification. European standard for cross-border e-invoicing, primarily used in B2G contexts. Not currently supported by EER.
- XRechnung
-
German implementation of EN16931 for public-sector (B2G) invoicing. Pure XML format without PDF wrapper. Not currently supported by EER but planned for future expansion.
- ZUGFeRD
-
Zentraler User Guide des Forums elektronische Rechnung Deutschland. German e-invoicing standard that embeds structured CII XML within a PDF document. EER generates ZUGFeRD 2.5 with the EN16931 profile (COMFORT level).
Validation
- Schematron Validation
-
Rule-based XML validation using ISO Schematron. Checks EN16931 business rules that XSD schema validation cannot express — for example, "tax amount equals base amount times tax rate."
- XSD Validation
-
XML Schema Definition validation. Checks structural correctness of ZUGFeRD XML against the official CII schema (element names, types, cardinalities).
Libraries and Infrastructure
- caddy-ratelimit
-
Caddy plugin that provides the
rate_limitdirective. The backend uses it for per-IP throttling on public endpoints at the reverse-proxy layer. - Caddy
-
Web server with automatic HTTPS via Let’s Encrypt. Runs as reverse proxy in production, routing traffic to backend and product-frontend containers and terminating TLS.
- Mailgun
-
Email infrastructure provider. Handles inbound email via webhooks and outbound email delivery for EER. Also delivers DOI confirmation and welcome emails for the self-hosted registration flow. Configured for EU region to satisfy data residency constraints.
- Metabase
-
Open-source business analytics platform. Reads from the
business_eventstable in PostgreSQL to provide dashboards and alerts for conversion rates, error patterns, and trial activations. Accessible atanalytics.einfache-erechnung.de(internal use only). - Mistral AI
-
EU-based AI company providing LLM APIs. EER uses the
mistral-small-2603model for invoice data extraction from PDFs. Selected over US-based providers for GDPR compliance (see Solution Strategy). - Mustang Project
-
Open-source Java library (
org.mustangproject) for reading and writing ZUGFeRD/Factur-X invoices. Generates EN16931-compliant CII XML and validates it against Schematron rules. - OpenTelemetry (OTel)
-
Open-source observability framework for traces, metrics, and logs. The backend uses the Java agent for automatic instrumentation, exporting telemetry to New Relic via OTLP.
- PDFBox
-
Apache PDFBox — Java library for PDF document manipulation. Attaches ZUGFeRD XML to the original PDF without re-rendering, preserving fonts and layout.
- Spring AI
-
Spring framework module for integrating LLM providers. Provides the
ChatClientabstraction that EER uses for Mistral AI. Enables provider switches through configuration changes. - Testcontainers
-
Java library for integration testing with real Docker containers. The backend uses it to run PostgreSQL in integration tests, ensuring test parity with production.
Backend System Components
- backend
-
One deployable Spring Boot modular monolith containing EER and Clarula business logic. Persistence-free
:appowns the BootJar and composes eager sibling product contexts;:eerowns the default/dispatcher and EER persistence, while:clarulaowns/admin/clarula/*and Clarula persistence. Code location:applications/backend/ - einfache-eRechnung frontend
-
Nuxt-generated static product frontend containing marketing, legal, content, registration, trial upload, authentication, portal, diagnosis, correction, and email-subscription routes. Public URL: https://einfache-erechnung.de Code location:
applications/frontend/apps/einfache-erechnung/ - Clarula frontend
-
Nuxt-generated static Clarula product frontend containing marketing, legal, contact, demo-entry, and explicitly declared application-shell routes. Public URL: https://clarula.de Code location:
applications/frontend/apps/clarula/ - partner demos
-
Static demo pages for partner/widget flows. Code location:
demos/partner/
EER Domain Concepts
- BaseEntity / ClarulaBaseEntity
-
Product-owned abstract JPA entity conventions providing UUID primary keys,
@Versionoptimistic locking, and audit timestamps. EER entities extendBaseEntity; Clarula entities extendClarulaBaseEntityso neither product introduces a persistence dependency through:core. - Business Event
-
Domain event published via Spring’s
ApplicationEventmechanism. Tracked in the database, as OpenTelemetry spans, and in metrics. Examples:InvoiceReceivedEvent,InvoiceProcessedEvent. - Edit Session
-
Temporary capability record (
InvoiceEditSession) binding the original PDF to an integrity-checkedInvoiceDraftsnapshot whose schema version is relational metadata, plus validation and lifecycle state. Completed capabilities additionally retain their exact immutable result artifacts until expiry. Allows users to diagnose source problems and correct recognition or mapping errors via a web form. Expires after 30 days. - Free Website Upload
-
Anonymous PDF-to-ZUGFeRD conversion via the website at
POST /api/free-upload/convert. No account required. Rate-limited per IP. Formerly called "Trial Conversion" in the codebase — renamed in code toFreeWebsiteUploadControllerandFreeWebsiteUploadEvent. - Magic Link
-
Passwordless authentication method. The user receives an email with a one-time login URL instead of entering a password. First email submission creates the account automatically.
- MustangErrorHumanizer
-
EER component that converts technical Mustang library exceptions into German user-friendly error messages with actionable correction advice.
- PendingRegistration
-
Database entity storing unconfirmed registration requests. Contains email, confirmation token, source path (which landing page), and timestamps. Deleted automatically after 48 hours if not confirmed.
- Validation Status
-
State of an edit session:
PENDING(not yet validated),VALID(all checks passed), orINVALID(validation errors found).
Business and Product Terms
- Clarula Firm
-
The Clarula-owned tenant aggregate for one tax firm. It anchors DATEV authorization, imported mandates, confirmations, and future uploader/document state in the Clarula database. It has no implicit mapping to an EER Partner.
- EER Partner
-
The einfache-eRechnung aggregate for widget origin registration, redirect allow-listing, attribution, affiliation, and partner tariffs. It never anchors Clarula tenancy or DATEV state.
- E-Invoicing Mandate
-
German legal requirement under the Wachstumschancengesetz. B2B businesses must accept e-invoices from January 2025 and send e-invoices from January 2027 (businesses with revenue above 800k EUR).
- EÜR
-
Einnahmenüberschussrechnung — German simplified accounting method for small businesses and freelancers. Relevant because the target audience expects predictable monthly costs, not usage-based pricing.
- Fake Door
-
Product validation technique where a payment UI is shown but no real charges are made. Used to measure upgrade intent before investing in payment processing infrastructure.
- Founding Member
-
Early adopter who registered during the pre-launch phase. Receives Pro-tier access for free as a reward for early feedback.
- Grace Period
-
7-day period after trial expiration where the service continues to work. Prevents hard cutoff for a compliance-critical tool that users depend on for legal obligations.