Fetching latest headlines…
Why a Rendered Invoice Can Still Fail Send
NORTH AMERICA
πŸ‡ΊπŸ‡Έ United Statesβ€’May 11, 2026

Why a Rendered Invoice Can Still Fail Send

0 views0 likes0 comments
Originally published byDev.to

An invoice PDF can exist and still not be an invoice package.

That sentence shaped the send path. The easy implementation would render the invoice, store the PDF, try to build the XML, and log a warning if the compliance layer failed. The client still gets a document. The API still returns success. The business can "fix it later."

That is exactly the failure I did not want.

Klevar Docs treats e-invoicing as part of finalization, not as decoration after rendering. For an invoice that routes to Factur-X, the send path has to build CII XML, validate it, embed it into the PDF, convert the container to PDF/A-3b, validate that with veraPDF, upload the XML, and replace the PDF with the conformant output. If any hard requirement fails, the send fails.

The orchestrator is intentionally thin. buildComplianceArtifacts() does profile resolution and dispatches to a branch. The Factur-X branch owns the hard path. The XRechnung branch owns the public-sector German XML path.

The Factur-X branch is where the philosophy is visible:

const valid = validationRaw.valid === true;
if (!valid) {
  await markDocumentFacturXFailed(db, document.id);
  throw new FacturXValidationRejectError({
    reason: 'schematron_failed',
    errors: Array.isArray(validationRaw.errors) ? validationRaw.errors : [],
    invoice_id: invoice.id,
    factur_x_validation_status: 'fail',
  });
}

const fallbackResult = await embedAndValidateWithFallback(
  plainPdfBytes,
  build.xml,
  fallbackContext,
  fallbackDeps,
);

if (!fallbackResult.conformant) {
  throw new PdfARequiredRejectError({
    reason: 'pdf_a_non_conformant',
    stage_failed: fallbackResult.stage_failed,
    invoice_id: invoice.id,
  });
}

There are two details in that snippet that matter.

First, validation failure is a typed 422, not an internal server error. The invoice shape is wrong. The operator needs to fix the invoice, not restart the service. Missing BT fields, sidecar 4xx failures, and schematron failures all become the same class of business-visible rejection.

Second, the document row is marked as failed before throwing. That was a later correction. Without it, a failed send could leave the row looking cleaner than reality because the broader transaction never committed. The code now best-effort updates documents.factur_x_validation_status = 'fail' so a polling operator sees the truth after a rejection.

What surprised me was how much logic had to live before the sidecar call. I expected mustangproject to be the hard part. The harder part was building the DTO without lying. Seller name, seller VAT ID, registration ID, address, client identity, tax category, reverse charge handling, unit code, document type, original invoice reference, payment terms, issue date, due date. Every one of those fields has a business rule.

The builder is pure TypeScript for that reason. It reads frozen invoice, client, and entity snapshot data and returns a transport DTO for the Java sidecar. It does not query the database. It does not default from live entity state. It does not mutate the invoice.

That purity paid off when credit notes entered the path. A credit note is not just a negative invoice. It carries a different document type code and can need a preceding invoice reference. The builder got an explicit discriminator for invoice versus credit_note, and the sidecar maps that into the XML. The alternative was to infer from amounts, which would have been clever and wrong.

XRechnung is intentionally different. German public-sector invoices route to standalone XML. The human-readable PDF remains a companion, not the legal container. That means the branch skips PDF/A-3b embedding work and persists XML as the authoritative artifact. Today the validator status can be pass, fail, or skipped because the KoSIT validation lane had an acknowledged gap. I kept that explicit instead of pretending both branches had identical maturity.

The system now has an uncomfortable but correct behavior: it can render a beautiful PDF, then refuse to send it.

That is the point. A valid-looking artifact is not enough. The service needs to know whether the document is acceptable for the legal path it is taking. For a plain letterhead, PDF bytes may be enough. For an invoice that routes to Factur-X, the XML and PDF/A container are part of the document. If they fail, the document failed.

I would rather make the operator fix a rejection than let a client receive a file that only looks complete.

Comments (0)

Sign in to join the discussion

Be the first to comment!