Architecture

This page covers the e-invoicing-specific architectural decisions and flows. It assumes familiarity with the general CDC, RPC, and Kafka patterns used across the codebase.

Communication Overview

E-invoicing adds a third async channel (Invopop webhooks) alongside the existing CDC and RPC patterns:

Mechanism
E-Invoicing Use

CDC

Syncs invoice data from LVDAM into Abacus. Triggers Raise, Issue, and lifecycle commands

RPC

Abacus calls LVDAM to transition document state (issuing, reject, issue)

Webhooks

Invopop delivers tax authority results via Kafka. Triggers MarkAsAccepted or MarkAsRejected

E-Invoicing RPC Endpoints

These are the LVDAM endpoints specific to e-invoicing (standard issue is reused):

RPC call
LVDAM endpoint
When

issuing_document/1

PUT /invoicing/rpc/documents/:uuid/issuing

Issue command for e-invoicing-eligible document

mark_as_rejected/1

PUT /invoicing/rpc/documents/:uuid/reject

After MarkAsRejectedByTaxAuthority

mark_as_issued/1

(stub, not yet wired)

After MarkAsAcceptedByTaxAuthority

Invopop Webhook Pipeline

The webhook flow adds an e-invoicing-specific Kafka consumer and event handler chain:

Invopop webhook → Kafka (webhooks_incoming_invopop)
    → Kafka.Handler.Webhooks.Incoming.Invopop (validates, extracts fields)
    → WebhookReceived event
    → WebhookReceived.Dispatch (resolves document, loads workspace config)
    → Provider.on_invoice_event/3 (fetches Silo entry for QR/hash)
    → MarkAsAcceptedByTaxAuthority or MarkAsRejectedByTaxAuthority

The webhook only contains the silo_entry_id. The dispatch handler fetches the full entry from Invopop's API to get the QR code, hash, and status.

End-to-End Flow

The complete lifecycle of an invoice submitted through e-invoicing:

Phase 1: Invoice Data Arrives in Abacus

  1. Invoice created/updated in LVDAM → CDC event → DocumentChanged → accounting commands (Raise, Issue, etc.)

Phase 2: Issue Decision

  1. Issue command reaches DocumentHandler.before_fetch_entity/1

  2. Checks WorkspaceEInvoicingConfiguration.configured?/1

  3. E-invoicing enabled: calls issuing_document/1 RPC → LVDAM transitions to issuing, reserves invoice number → best-effort sync_document to refresh CDC data

  4. Not enabled: calls issue_document/1 (standard flow, skips all e-invoicing logic)

Phase 3: Submit to Tax Authority

  1. Document entity sets issuing_at, emits Issuing event with send_to_tax_authority?: true

  2. OnIssuing.SendToTaxAuthority handler dispatches SendToTaxAuthority command

  3. before_fetch_entity(SendToTaxAuthority) calls EInvoicing.submit_invoice/1

  4. Invopop provider builds GOBL document, calls PUT /silo/v1/entries/{id} then PUT /transform/v1/jobs/{id}

  5. Entity records sent_to_tax_authority_at and e_invoicing_reference_id

Phase 4: Receive Result

  1. Invopop sends webhook → Kafka → WebhookReceived event → dispatch resolves document and calls Provider.on_invoice_event/3

Phase 5: Update State

  1. Success: MarkAsAcceptedByTaxAuthority → RPC mark_as_issued (currently a stub) → entity clears issuing_at, sets issued_at, emits AcceptedByTaxAuthority + Issued

  2. Failure: MarkAsRejectedByTaxAuthority → RPC mark_as_rejected (PUT /reject) → entity clears issuing_at, sets rejected_by_tax_authority_at + rejection_reason, emits RejectedByTaxAuthority

Entity Locking

When issuing_at is set on the Document entity, it acts as a lock. Almost all modification commands check for it and reject execution.

Blocked while issuing: Credit, Refund, Reverse, Delete, UpdateRecipient, AddTransaction, RemoveTransaction, UpdateTransaction, SendToTaxAuthority

Allowed while issuing: MarkAsAcceptedByTaxAuthority, MarkAsRejectedByTaxAuthority

Clearing issuing_at is critical — both acceptance and rejection must set it to nil. If skipped, the document is permanently locked with no automated recovery. See https://github.com/lavanda-uk/nimbus/blob/main/docs/finance-and-accounting/e-invoicing/troubleshooting.md for recovery steps.

Workspace Configuration

E-invoicing is configured per workspace. The WorkspaceConfiguration entity holds an e_invoicing_provider field (Invopop struct with API token, workflow ID, and supplier/company ID). Credentials are encrypted at rest via Shared.Infrastructure.Vault.

A denormalized WorkspaceEInvoicingConfiguration read model provides configured?/1 for fast eligibility checks during the Issue command. Handle nil workspace IDs gracefully — documents may lack them during CDC backfill.

No PMS UI exists yet (PLU-4223). Configuration is manual via IEx console in staging.

Eventual Consistency

CDC events are asynchronous, so Abacus may act on stale data when the Issue command fires. The DocumentHandler makes a best-effort sync_document call after the issuing_document RPC succeeds, refetching the document from LVDAM. If the sync fails, it logs a warning but does not block the flow.

A more comprehensive version of sync_document in the CDC handler was rolled back because it blocked the Kafka queue on bad data. Re-enabling requires the Kafka inbox pattern (PLU-4309), which moves messages into Oban jobs for independent processing.

Suggested Reading Order

For a new engineer, this order traces the end-to-end flow:

  1. Issue decision: document_handler.ex (before_fetch_entity clauses for Issue)

  2. Submit to Invopop: on_issuing/send_to_tax_authority.exinfrastructure/e_invoicing.exe_invoicing_providers/invopop.ex

  3. Receive result: kafka/handlers/webhooks_incoming/invopop.exwebhook_received/dispatch.exinvopop.ex (on_invoice_event)

  4. LVDAM sync: http_adapter.ex + LVDAM documents_controller.rb

Last updated