Reference

This page is the detailed engineering reference for the e-invoicing feature. For a shorter introduction, see the E-Invoicing. For Invopop API specifics, see Invopop Integration.

Jira Epic: PLU-778arrow-up-right

Project Status (March 2026)

The core end-to-end flow is implemented and working in staging. The feature is not yet production-ready. The main gaps before go-live are: a fix for VAT rounding discrepancies, a PMS UI for workspace configuration, and wiring the mark_as_issued HTTP adapter stub.

Category
Count

Total tickets in epic

64 (+ 3 subtasks outside epic)

Done

39 (+ PLU-4494, PLU-4495, PLU-4496 as subtasks)

Dismissed

10

Backlog

15

In Progress

0


Architecture

E-invoicing uses the standard CDC/RPC/Kafka patterns already in the codebase, with LVDAM owning invoice data and state, and Abacus owning the e-invoicing decision logic, GOBL conversion, and Invopop integration. The e-invoicing-specific additions are the Issuing and Rejected document states, entity locking during submission, per-workspace Invopop configuration, and the Invopop webhook pipeline. For the full end-to-end flow walkthrough, entity locking details, and a suggested code reading order, see the Architecture page.

Document State Machine

Standard flow: Draft → Issued. E-invoicing introduces intermediate states:

  Draft ──(e-invoicing)──► Issuing ──(accepted)──► Issued

                               └──(rejected)──► Rejected ──(re-submit)──► Issuing
State
Behaviour

Issuing

Locked. All modification commands are blocked. Awaiting tax authority response

Rejected

Can be re-submitted (transitions back to Issuing with a new invoice number). Cannot be deleted or exported


GOBL Conversion

What's Supported

  • Regular invoices with full totals and tax breakdowns

  • Negative line items using lines with negative prices (not discounts/charges)

  • Country-specific configuration via get_regime_config/1 (returns Verifactu addons and identity extensions for Spain)

  • ID document mapping covering all LVDAM ID types:

    • Spanish IDs (DNI via id_card, NIE via spanish_resident_permit) map to tax_id with country "ES"

    • Foreign passports map to identities with es-verifactu-identity-type: "03"

    • Foreign national IDs map to identities with es-verifactu-identity-type: "04"

    • Other documents (driver's license, student ID) map to identities with es-verifactu-identity-type: "06"

  • Company recipients with tax_number: Spanish companies use tax_id, non-Spanish use identities with es-verifactu-identity-type: "04"

Credit Notes

The infrastructure for credit note references exists (referenced document IDs are tracked on the Document entity). GOBL conversion logic for credit notes has been prototyped but is not wired into the production submission pipeline. See the backlog section for the remaining tickets.

Corrective Invoices

Spanish corrective invoices (facturas rectificativas) are handled through the standard business process: issue a credit note (R4) to cancel the original, then issue a new invoice (F1) as the corrected version. No special GOBL document type is needed.

VAT and Rounding

LVDAM calculates VAT per line item from gross amounts (gross to net), while GOBL aggregates net amounts by rate and calculates tax on the total. This causes discrepancies of 1–10 cents. Bypass mode ($tags: ["bypass"]) is used as a temporary mitigation. The plan is to fix LVDAM's calculations to match GOBL (PLU-4358 + PLU-4627) and remove bypass mode (PLU-4666). For a detailed breakdown of the rounding methods and the remediation plan, see VAT Rounding.


Abacus E-Invoicing Flow

Issue Command (PLU-4233)

The Issue command handler checks e-invoicing eligibility using the document type and workspace configuration:

  1. E-invoicing eligible: Calls LVDAM issuing RPC, receives display_document_number, sets send_to_tax_authority?: true. The entity emits an Issuing event only (not Issued).

  2. Not eligible: Calls LVDAM issue RPC. The entity emits an Issued event (standard flow).

When issuing_at is set on the entity, it is locked. All modification commands are blocked except MarkAsAcceptedByTaxAuthority and MarkAsRejectedByTaxAuthority.

Blocked commands during Issuing: Credit, Refund, Reverse, Delete, UpdateRecipient, AddTransaction, RemoveTransaction, UpdateTransaction, SendToTaxAuthority.

OnIssuing Handler (PLU-4234)

Listens for Issuing events with send_to_tax_authority?: true and dispatches the SendToTaxAuthority command. This handler is the bridge between the document lifecycle and the e-invoicing integration.

Acceptance Flow (PLU-4236)

The MarkAsAcceptedByTaxAuthority handler:

  1. Calls LVDAM mark_as_issued RPC (currently a stub — needs a LVDAM endpoint and adapter wiring)

  2. Entity sets issued_at, clears issuing_at, and sets acceptance fields

  3. Emits both AcceptedByTaxAuthority and Issued events

  4. Pattern matching on document.issued_at differentiates e-invoicing from legacy flow

Rejection Flow (PLU-4237)

The MarkAsRejectedByTaxAuthority handler:

  1. Calls LVDAM mark_as_rejected RPC (PUT /invoicing/rpc/documents/:uuid/reject)

  2. Entity clears issuing_at, sets rejected_by_tax_authority_at and rejection_reason

  3. Emits RejectedByTaxAuthority event

Note: Clearing issuing_at is critical. Without it, issuing? returns true indefinitely, which blocks all subsequent commands on the document.

Booking Document Denormalizers

  • PLU-4235: Handles Issuing event. Sets issuing_started_at on the bookings documents read model.

  • PLU-4238: Handles RejectedByTaxAuthority event. Clears issuing_started_at, sets rejected_by_tax_authority_at and rejected_by_tax_authority_reason.


LVDAM RPC Endpoints

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

Transitions a draft or rejected document to issuing state. Reserves an invoice number atomically. Skips all tax validations (Invopop handles validation).

Scenario
HTTP
Response

Success (draft/rejected to issuing)

200

{ uuid, display_invoice_number, status: "issuing" }

Not in draft/rejected state

422

{ errors: [{field, message}] }

Invalid document type

422

{ errors: [{field: "base", message: "invoiceInvalidDocumentType"}] }

Document not found

410

{ errors: [...] }

Empty line items

422

Raises ArgumentError (prevents number consumption)

Note: This endpoint is not idempotent. It returns 422 for already-issuing documents. This is flagged as a follow-up.

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

Transitions an issuing document to rejected state. This endpoint is idempotent (already-rejected documents return 200).

Scenario
HTTP
Response

Success (issuing to rejected)

200

{ uuid, status: "rejected", ... }

Already rejected (idempotent)

200

{ uuid, status: "rejected", ... }

Not in issuing/rejected state

422

{ errors: [{field, message}] }

Document not found

410

{ errors: [...] }

PUT /invoicing/rpc/documents/:uuid/issue (existing)

Standard issue endpoint. When called by Abacus after tax authority acceptance, transitions issuing to issued.

Rejected State Behaviour (LVDAM)

  • Status stored in JSONB payload['status']

  • Rejected documents cannot be deleted or exported

  • Rejected documents can transition back to issuing (re-submission with a new invoice number)

  • rejected to draft transition is blocked

  • exportable scope excludes draft, issuing, and rejected

  • get_draft_or_rejected_invoice finder supports both initial submission and re-submission

  • TransitionToIssuingState accepts both draft and rejected documents

  • TransitionToRejectedState is idempotent


Workspace Configuration

E-invoicing is configured per workspace using the WorkspaceEInvoicingConfiguration entity (Abacus entity store).

Each configured workspace stores:

  • An encrypted Invopop API token (AES-256 via Shared.Infrastructure.Vault)

  • A workflow ID (which Invopop processing pipeline to use)

  • A company ID (the supplier's UUID in Invopop)

When a document is issued, the system checks whether the workspace has e-invoicing configured. If not, the document follows the standard flow.

Note: There is no PMS UI to configure these settings yet (PLU-4223). Configuration is currently done manually in staging only.


Eventual Consistency

Problem

Multiple asynchronous touchpoints create timing issues:

  • LVDAM to Abacus CDC events can be delayed

  • Document data in Abacus can be stale when event handlers fire

  • PDF generation can show stale data for newly issued invoices

Mitigation

A sync_document function was implemented (PLU-4228) to refetch documents from LVDAM via RPC before critical operations. However, it was rolled back because the synchronous handler could block the Kafka queue on bad data. Re-enabling requires the Kafka inbox pattern (PLU-4309), which moves Kafka messages into Oban for independent processing.

The current e-invoicing Issue flow uses best-effort CDC sync. Failures are logged but do not block the flow.


Error Recovery

When a workflow fails (e.g. rejected at verifactu.generate or verifactu.send), entries can be corrected via PATCH and resubmitted:

Constraint: Once Verifactu has accepted a record, changing the invoice code and resubmitting fails with error 3002 ("No existe el registro de facturacion"). Verifactu maintains a hash chain linking records. To correct an accepted invoice, issue a credit note instead.

Current limitation: The Abacus adapter only uses PUT (create-only). If we need to support resubmission of corrected entries, PATCH support needs to be added to the adapter.


Known Issues and Gaps

Blocking Go-Live

  1. Rounding (PLU-4358 + PLU-4627): VAT calculation differences between LVDAM and GOBL. The decision is to fix our calculations to match GOBL (net to gross, aggregated per rate) and remove bypass mode. Without this fix, invoices will be intermittently rejected.

  2. PMS workspace config UI (PLU-4223): No UI to configure Invopop credentials per workspace. Currently staging-only, manual.

  3. Abacus HTTP adapter stub: mark_as_issued in the HTTP adapter still raises "not implemented". A corresponding LVDAM endpoint is needed to complete the acceptance flow.

Abacus-LVDAM Alignment Gap

  • mark_as_issued adapter is a stub that raises. Needs a corresponding LVDAM endpoint and adapter wiring

Rejection Handling Strategy (Open Decision)

Two strategies are under discussion:

Strategy A (Original): Rejected document is edited in place and resubmitted directly.

Strategy B (Proposed): On rejection, mark the invoice as rejected, immediately create a draft corrective invoice, fix the rejected invoice to get it to issued, then credit note it. Block the corrective from being issued until the rejected invoice reaches issued. Start with manual fix and credit note; build UI only if rejections are frequent.

This decision affects the Abacus-side implementation significantly and has not been finalised.

Other Issues

  • Issuing endpoint not idempotent: Returns 422 for already-issuing documents (unlike issue and reject)

  • No production configuration exists: Invopop workspace configuration is staging-only

  • UUID v7 required: Invopop Silo rejects standard v4 UUIDs; all entry/job IDs must be UUID v7

  • Adapter uses PUT only: No PATCH support for error recovery or resubmission of corrected entries

  • Kafka inbox pattern blocks two tickets: PLU-4289 (credit note validations) and PLU-4309 (sync_document) need the Kafka inbox pattern first


Backlog

Critical for Go-Live

Ticket
Summary
Dependencies
Effort

PLU-4358

Fix rounding to match GOBL (remove bypass mode)

None (LVDAM-side)

Medium

PLU-4627

Fix double rounding in prorated billing services

Related to PLU-4358

Medium

PLU-4223

PMS integration for Invopop (workspace config UI)

None

Spike first

(No ticket)

Wire Abacus HTTP adapter to real LVDAM endpoints

PLU-4494/4496 merged

Small-Medium

Important for UX

Ticket
Summary

PLU-3969

Parent story: Introduce Issuing and Rejected States (covers PMS UI and full lifecycle; most sub-work done)

PLU-4149

Display and Filter Issuing/Rejected docs in Invoice List View

PLU-4224

Expose rejected reason via GQL

PLU-4225

Filter issuing/rejected via GQL

PLU-4226

PMS documents view: issuing/rejected filters

PLU-4227

Show rejected reason in PMS

PLU-4084

Display error when supplier is not registered

PLU-4220

Remove invoice number on rejection

Credit Notes via E-Invoicing

Ticket
Summary

(No ticket)

Credit note GOBL conversion (code exists from PLU-4179 investigation, needs wiring)

PLU-4293

LVDAM calls Abacus to issue credit notes

PLU-4288

Backfill referenced document data

PLU-4289

Re-enable credit note type validations (blocked by Kafka inbox)

Blocked

Ticket
Summary
Blocker

PLU-4309

Re-enable sync_document function

Kafka inbox pattern

PLU-4289

Credit note validations

Kafka inbox pattern


Implementation Patterns

Command Design (Abacus)

Pattern
When to use
Example

External timestamps (enforce: true)

Syncing from CDC or external system

Credit, Reverse, Refund

System timestamps (no field)

Abacus is source of truth

SendToTaxAuthority

Mixed source (enforce: false)

Either CDC or Abacus-initiated

Issue

LVDAM RPC Operations

New RPC business logic uses Dry::Operation with:

  • Nested Dry::Validation::Contract

  • Named steps (validate, find, transition, persist)

  • on_failure hook for logging

  • Controller pattern-matches on result.success?

  • Validation failures return a 4-tuple: Failure([result, :validation_error, message, errors_hash])

  • Operations rescue known exceptions and map to named Failure codes

Last updated