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β
| Layer | Status |
|---|---|
| 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 element | ERPNext source |
|---|---|
ExchangedDocument/ID | doc.name (e.g. ACC-SINV-2026-00001) |
TypeCode | 380 (commercial invoice, fixed) |
IssueDateTime | str(doc.posting_date).replace("-", "") β YYYYMMDD format 102 |
Seller Name | doc.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 Name | doc.customer |
InvoiceCurrencyCode | doc.currency |
TaxBasisTotalAmount | doc.net_total |
TaxTotalAmount | doc.total_taxes_and_charges |
GrandTotalAmount | doc.grand_total |
DuePayableAmount | doc.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:
mainbranch β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.trivyignoresuppresses 18 CRITICAL CVEs from the upstreamfrappe/erpnext:v16.28.0base 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:
| Package | CVEs | Source |
|---|---|---|
chromium-headless-shell | 8 | Debian pkg (used for PDF generation) |
libgnutls30 | 2 | Debian base |
loader-utils, shell-quote, tar | 3 | Node.js in ERPNext banking module |
| Go stdlib (embedded binary) | 4 | Go binary in base image |
Remediation: upgrade when ERPNext ships a new base image.
Supply Chain Securityβ
| Check | Scope |
|---|---|
| Trivy CRITICAL scan | Every push to main/staging |
| Cosign keyless signing | staging + main pushes |
| CycloneDX SBOM | main only (attached as OCI referrer) |
| GPG-signed GitOps commit | Every 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):
- Add a Python file in
minicloud-erpnext/erpnext_facturx/(e.g.n8n_webhook.py) - Register a new event in
hooks.py:doc_events = {"Sales Invoice": {"on_submit": ["erpnext_facturx.facturx.generate_and_attach","erpnext_facturx.n8n_webhook.trigger",],}} - Push to
mainβ CI builds and bumps the image tag inerpnext-values.yamlautomatically - 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β
| Gotcha | Root cause | Fix |
|---|---|---|
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 frappe | frappe hard-requires pypdf==6.13.3; downgrade breaks it | Don't install pypdf β factur-x uses PyPDF4 independently |
Buildx push β x509: certificate signed by unknown authority | docker/build-push-action runs Buildx in a separate container that doesn't inherit host CA certs | buildkitd-config-inline: [registry."harbor..."] insecure = true |
Trivy HTTP/2 INTERNAL_ERROR on git binary in 838 MB image | Trivy secret scan reads every byte of every file; git binary triggers stream error | scanners: 'vuln' disables secret scanning |
| nginx rolling update deadlock | Chart injects topologySpreadConstraint maxSkew:1 preventing 2 nginx pods on the same node | nginx.topologySpreadConstraints: [] in erpnext-values.yaml |
| Frappe app files are ephemeral | Files copied to a running pod are lost on pod restart | Bake into Docker image with COPY + pip install -e, not copied at runtime |
| Hook does not fire even though app is pip-installed | pip 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 replacement | get_party_account returns deleted account from cache | frappe.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.
| Field | Value |
|---|---|
| Image | harbor.10.0.0.200.nip.io/library/erpnext-ktayl:v16.28.0-facturx-9e0425d |
| Hook | Sales Invoice.on_submit β erpnext_facturx.facturx.generate_and_attach |
| Output | ACC-SINV-2026-00001-1-facturx.xml (2143 bytes, private attachment) |
| Profile | urn:factur-x.eu:1p0:minimum |
| SIRET | 12345678900014 (schemeID=0002) |
| VAT | FR12345678900 (schemeID=VA) |
| Amounts | TaxBasis=1500β¬ + TSCA 13%=195β¬ β Grand=1695β¬ |
| PDF/A-3 | XML 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β
| Item | Description |
|---|---|
| PDF/A-3 embedding | Full facturx.generate_from_file() production path β requires PDF generated from Frappe print format to be accessible as bytes without Chromium errors |
| n8n invoice workflow | ERPNext 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 |