Aller au contenu principal

ERP-3 — Factur-X Custom Image & CI Pipeline

Sprint 3 (2026-08-11). Extends ERP-1 to production-grade e-invoicing: a custom ERPNext Docker image bakes in a erpnext_facturx Frappe app that generates Factur-X Minimum CII XML on every submitted Sales Invoice, with a full CI pipeline (Trivy, Cosign, SBOM, GPG-signed GitOps bump).


Context

LayerStatus
ERPNext v16.28.0 deployed✅ Phase 79
French PCG 2025 + CRM + TSCA tax templates✅ ERP-1
Factur-X Minimum PoC (pure-Python, 5 assertions)✅ ERP-1
Custom ERPNext image (erpnext-ktayl)✅ ERP-3
erpnext_facturx Frappe app — on_submit hook✅ ERP-3
CI pipeline: Trivy + Cosign + SBOM + GPG bump✅ ERP-3
PDF/A-3 embedding (Phase 90)⬜ Phase 90
n8n invoice workflow (SIRET/ORIAS verify → ERPNext)⬜ Phase 90

Architecture — Option C (open-webui model)

minicloud-erpnext is an image build repo only, not a deployment repo. This mirrors minicloud-open-webui: application artifacts live in the image repo, all cluster state (Helm values, ArgoCD Apps, ExternalSecrets, Ingress, Quota) stays in minicloud-gitops.

minicloud-erpnext/ ← image artifacts only
├── Dockerfile
├── erpnext_facturx/ ← Frappe app (baked into image)
│ ├── setup.py
│ ├── hooks.py
│ └── facturx.py
├── .trivyignore ← accepted upstream CVEs
└── .github/workflows/
└── build.yml ← build → Harbor → GitOps bump

minicloud-gitops/
└── helm-values/minicloud-1/
└── erpnext-values.yaml ← image.tag is auto-bumped by CI

Future integrations (n8n webhooks, NATS events, ORIAS client) are added as new Python files inside erpnext_facturx/ or as new Frappe apps alongside it in the repo — then baked into the next image.


Dockerfile

The image extends frappe/erpnext:v16.28.0, installs factur-x==2.0.0, and bakes the erpnext_facturx Frappe app via pip install -e:

FROM frappe/erpnext:v16.28.0

ARG CA_CERT

USER root

RUN /home/frappe/frappe-bench/env/bin/pip install \
"factur-x==2.0.0" \
--no-cache-dir

# Bake erpnext_facturx Frappe app into the image
COPY erpnext_facturx/ /home/frappe/frappe-bench/apps/erpnext_facturx/
RUN /home/frappe/frappe-bench/env/bin/pip install -e \
/home/frappe/frappe-bench/apps/erpnext_facturx --no-cache-dir

RUN if [ -n "${CA_CERT}" ]; then \
echo "${CA_CERT}" > /usr/local/share/ca-certificates/minicloud-ca.crt && \
update-ca-certificates; \
fi

USER frappe

:::caution Do not add pypdf as a dependency frappe requires pypdf==6.13.3. Installing pypdf==5.x downgrades it and breaks Frappe. The factur-x library uses PyPDF4 independently — no extra pypdf install needed. :::


Frappe App — erpnext_facturx

Hook registration

# hooks.py
doc_events = {
"Sales Invoice": {
"on_submit": "erpnext_facturx.facturx.generate_and_attach",
}
}

When a Sales Invoice is submitted in ERPNext, Frappe calls generate_and_attach. There is no UI change — the Factur-X XML (or PDF/A-3 when available) appears as a private file attachment on the invoice.

CII XML generation

_build_cii_xml(doc) builds a Factur-X Minimum profile CII XML (urn:factur-x.eu:1p0:minimum) using Python's standard xml.etree.ElementTree:

CII elementERPNext source
ExchangedDocument/IDdoc.name (e.g. ACC-SINV-2026-00001)
TypeCode380 (commercial invoice, fixed)
IssueDateTimestr(doc.posting_date).replace("-", "") → YYYYMMDD format 102
Seller Namedoc.company
Seller SIRET (schemeID=0002)parsed from Company registration_details via regex SIRET[:\s]+(\d{14})
Seller TVA (schemeID=VA)company.tax_id (e.g. FR12345678900)
Buyer Namedoc.customer
InvoiceCurrencyCodedoc.currency
TaxBasisTotalAmountdoc.net_total
TaxTotalAmountdoc.total_taxes_and_charges
GrandTotalAmountdoc.grand_total
DuePayableAmountdoc.outstanding_amount or grand_total

PDF/A-3 embedding (with fallback)

def generate_and_attach(doc, method=None):
xml_bytes = _build_cii_xml(doc)
pdf_bytes = _get_invoice_pdf(doc)

if pdf_bytes:
try:
from facturx import generate_from_file
# embed XML into PDF/A-3 as attachment
...
save_file(f"{doc.name}-facturx.pdf", final_pdf, ...)
except Exception:
# fallback: XML only (PDF/A-3 embedding failed)
save_file(f"{doc.name}-facturx.xml", xml_bytes, ...)
else:
save_file(f"{doc.name}-facturx.xml", xml_bytes, ...)

Full PDF/A-3 production embedding is Phase 90. The current fallback ensures the CII XML is always attached even if the facturx library cannot generate the PDF.


CI Pipeline

The pipeline in .github/workflows/build.yml has two jobs.

Job 1 — build-and-push

checkout → compute tag → Tailscale → trust CA → Harbor login
→ Buildx (insecure registry) → build + push → Trivy CRITICAL scan
→ Cosign sign → syft SBOM (CycloneDX JSON) → attach SBOM as OCI referrer

Tag strategy:

  • main branch → v16.28.0-facturx-{sha} (SHA-pinned, immutable)
  • other branches → {branch}-{sha} (development only, not promoted)

Trivy configuration:

  • scanners: 'vuln' — disables secret scanning (avoids HTTP/2 stream errors on 838 MB image over Tailscale)
  • timeout: 20m
  • .trivyignore suppresses 18 CRITICAL CVEs from the upstream frappe/erpnext:v16.28.0 base image (Chromium ×8, libgnutls30 ×2, Node.js banking deps ×3, Go stdlib ×4). None introduced by our layers.

Job 2 — bump-gitops (main only)

Tailscale → trust CA → checkout minicloud-gitops
→ import GPG key (crazy-max/ghaction-import-gpg@v7)
→ verify image in Harbor (HTTP 200 on manifest endpoint)
→ sed erpnext-values.yaml image.tag
→ GPG-signed commit on branch ci/erpnext-bump-{tag}
→ gh pr create → gh pr merge --admin (auto-merge)

The Harbor verification step (curl -o /dev/null -w "%{http_code}") prevents a GitOps bump if the push failed silently. Auto-merge requires main branch protection to allow --admin bypass — same pattern as minicloud-open-webui.


Trivy — Accepted Upstream CVEs

All 18 suppressed CVEs are in the upstream frappe/erpnext:v16.28.0 base image, not in layers added by minicloud:

PackageCVEsSource
chromium-headless-shell8Debian pkg (used for PDF generation)
libgnutls302Debian base
loader-utils, shell-quote, tar3Node.js in ERPNext banking module
Go stdlib (embedded binary)4Go binary in base image

Remediation: upgrade when ERPNext ships a new base image.


Supply Chain Security

CheckScope
Trivy CRITICAL scanEvery push to main/staging
Cosign keyless signingstaging + main pushes
CycloneDX SBOMmain only (attached as OCI referrer)
GPG-signed GitOps commitEvery auto-bump to minicloud-gitops

To verify the image signature:

cosign verify harbor.10.0.0.200.nip.io/library/erpnext-ktayl:<tag> \
--certificate-identity-regexp=".*" \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com"

Adding a New Integration

To add a new Frappe integration (e.g. n8n webhook on invoice submit):

  1. Add a Python file in minicloud-erpnext/erpnext_facturx/ (e.g. n8n_webhook.py)
  2. Register a new event in hooks.py:
    doc_events = {
    "Sales Invoice": {
    "on_submit": [
    "erpnext_facturx.facturx.generate_and_attach",
    "erpnext_facturx.n8n_webhook.trigger",
    ],
    }
    }
  3. Push to main — CI builds and bumps the image tag in erpnext-values.yaml automatically
  4. ArgoCD syncs → ERPNext pods restart with the new app version

:::caution One-time bench install-app required per site pip install -e in the Dockerfile registers the app at the Python level. Frappe also needs the app registered in the site database (site_config.json). On the first deploy, run once:

kubectl exec -n erp <gunicorn-pod> -- bash -c \
'cd /home/frappe/frappe-bench && bench --site erp.devandre.sbs install-app erpnext_facturx'

This writes erpnext_facturx into site_config.json on the Longhorn PVC — it persists across pod restarts. Required again only after a full site wipe (disaster recovery). :::


Gotchas

GotchaRoot causeFix
TypeError: 'str' object cannot be interpreted as an integer on posting_date.replace()doc.posting_date is datetime.date, not str. Its .replace(year=, month=, day=) takes keyword args.str(doc.posting_date).replace("-", "")
pypdf==5.1.0 conflicts with frappefrappe hard-requires pypdf==6.13.3; downgrade breaks itDon't install pypdffactur-x uses PyPDF4 independently
Buildx push → x509: certificate signed by unknown authoritydocker/build-push-action runs Buildx in a separate container that doesn't inherit host CA certsbuildkitd-config-inline: [registry."harbor..."] insecure = true
Trivy HTTP/2 INTERNAL_ERROR on git binary in 838 MB imageTrivy secret scan reads every byte of every file; git binary triggers stream errorscanners: 'vuln' disables secret scanning
nginx rolling update deadlockChart injects topologySpreadConstraint maxSkew:1 preventing 2 nginx pods on the same nodenginx.topologySpreadConstraints: [] in erpnext-values.yaml
Frappe app files are ephemeralFiles copied to a running pod are lost on pod restartBake into Docker image with COPY + pip install -e, not copied at runtime
Hook does not fire even though app is pip-installedpip install -e makes the app importable but Frappe only fires hooks from apps registered in site_config.json (installed_apps)Run bench --site <site> install-app erpnext_facturx once after first deploy
Redis stale cache after COA replacementget_party_account returns deleted account from cachefrappe.cache.flushall() after migration (ERP-1 gotcha)

Current State

Validated 2026-08-11: ACC-SINV-2026-00001-1 submitted → generate_and_attach fired → XML attached → 13/13 assertions pass.

FieldValue
Imageharbor.10.0.0.200.nip.io/library/erpnext-ktayl:v16.28.0-facturx-9e0425d
HookSales Invoice.on_submit → erpnext_facturx.facturx.generate_and_attach
OutputACC-SINV-2026-00001-1-facturx.xml (2143 bytes, private attachment)
Profileurn:factur-x.eu:1p0:minimum
SIRET12345678900014 (schemeID=0002)
VATFR12345678900 (schemeID=VA)
AmountsTaxBasis=1500€ + TSCA 13%=195€ → Grand=1695€
PDF/A-3XML fallback (Chromium PDF not available in gunicorn pod)
# Verify running image
ssh controller "kubectl get pods -n erp -l app.kubernetes.io/name=erpnext \
-o jsonpath='{.items[0].spec.containers[0].image}'"

# Verify app installed in site
ssh controller "kubectl exec -n erp \$(kubectl get pod -n erp -l app.kubernetes.io/name=erpnext -o name | head -1 | cut -d/ -f2) -- \
bash -c 'cd /home/frappe/frappe-bench/sites && /home/frappe/frappe-bench/env/bin/python -c \
\"import frappe; frappe.init(site=\\\"erp.devandre.sbs\\\"); frappe.connect(); print(frappe.get_installed_apps())\"'"

Phase 90 — Next Steps

ItemDescription
PDF/A-3 embeddingFull facturx.generate_from_file() production path — requires PDF generated from Frappe print format to be accessible as bytes without Chromium errors
n8n invoice workflowERPNext on_submit → n8n webhook → minicloud-crew-agent (SIRET INFOGREFFE + ORIAS verify) → result written back to ERPNext custom field
Factur-X EN16931 (Confort)Add line-level items (IncludedSupplyChainTradeLineItem) for full audit trail