Skip to main content

ERP-4 — DSN Phase 3.1 Technical Architecture

Sprint 3 (2026-08-12). Implements the full Déclaration Sociale Nominative (DSN) Phase 3.1 pipeline inside the erpnext-ktayl custom image: a erpnext_dsn Frappe app that reads Salary Slips, generates a CRLF/UTF-8 DSN flat file, submits it to the Net-Entreprises CRM endpoint, and parses the ACK — wired end-to-end on-cluster using a mock depot for the test environment.


Context​

LayerStatus
ERPNext v16.28.0 deployed✅ Phase 79
French PCG 2025 + TSCA tax templates✅ ERP-1
Custom image (erpnext-ktayl) + CI pipeline✅ ERP-3
erpnext_dsn Frappe app — DSN Phase 3.1 generator✅ ERP-4
mock-net-entreprises on-cluster depot✅ ERP-4
DSN env vars via GitOps + ESO + Vault✅ ERP-4
hrms app for real Salary Slip integration⬜ Phase 90
Go-live with real Net-Entreprises SIRET⬜ Phase 90

What DSN Phase 3.1 is​

DSN (Déclaration Sociale Nominative) is the French unified payroll declaration. Every employer must file a monthly DSN for each employee with the social security bodies (URSSAF, caisse de retraite, Pôle Emploi). Phase 3.1 is the current mandatory format:

  • A flat text file, CRLF line endings, UTF-8, block-structured
  • Submitted over HTTPS to Net-Entreprises CRM using SIRET + password credentials
  • The depot endpoint returns a CRM XML ACK: ACCEPTE, REJETE, or TRAITEMENT_SANS_ERREUR

In France, a non-submitted or rejected DSN triggers URSSAF penalties. For an insurance IS, DSN is the upstream data source for employee contracts, salary data, and prévoyance collective declarations.


Architecture Overview​

ERPNext gunicorn pod (erp ns)
│
│ whitelisted API call from n8n / cron / user
│ POST /api/method/erpnext_dsn.api.submit_monthly_dsn?year=2026&month=1
│
▼
erpnext_dsn.api.submit_monthly_dsn()
│
├── generate_monthly_dsn() ← reads Salary Slips from Frappe DB
│ builds DSN Phase 3.1 text (CRLF/UTF-8)
│ blocks: S10 (declaration) / S20 (employees) / S90 (totals)
│
└── submit_monthly_dsn_to_crm() ← HTTP POST to DSN_ENDPOINT
│
▼ (test env) (prod)
mock-net-entreprises api.net-entreprises.fr/crm
Deployment (erp ns, port 8080) (SIRET + DSN_LOGIN/PASSWORD)
│
▼
CRM ACK XML parsed
→ Comment stored on Payroll Entry ("DSN 01/2026 — ✅ Acceptée")

The test environment routes to mock-net-entreprises via DSN_TEST_MODE=true + DSN_ENDPOINT. The production switch is a one-line env var change in Vault — no code or image change required.


erpnext_dsn Frappe App​

App layout​

minicloud-erpnext/
└── erpnext_dsn/
├── setup.py
├── hooks.py ← no doc_events (API-only, no hooks)
├── api.py ← whitelisted endpoints
├── dsn_generator.py ← DSN Phase 3.1 file builder
├── dsn_submitter.py ← HTTP POST + CRM ACK parser
└── scripts/
└── setup_test_employee.py ← seeds Jean Dupont + Jan 2026 Salary Slip

DSN file structure​

The generator builds three block types from Frappe documents:

BlockContentFrappe source
S10 (declaration header)SIRET, period (YYYY-MM), emitter, software IDDSN_SIRET env var + DSN_COMPANY_NAME + system date
S20 (employee)NNI (NIR), name, birth date, job category, gross/net salary, contract typeSalary Slip → Employee fields
S90 (footer)Record counts for S10 + S20counted at build time

Each block field follows the DSN specification: rubrique.occurrence.rang: value\r\n. Example S10 header:

S10.G00.00.001: 12345678900014 ← SIRET
S10.G00.00.002: 062026 ← period MMYYYY
S10.G00.00.005: 01 ← declaration nature (mensuelle)
S10.G00.00.006: 01 ← fraction (one file)

API endpoints​

Both functions are decorated with @frappe.whitelist() — accessible via the ERPNext REST API with session or API key auth:

# Generate only (returns DSN text, does not submit)
GET /api/method/erpnext_dsn.api.generate_monthly_dsn?year=2026&month=1

# Generate + submit to CRM endpoint
POST /api/method/erpnext_dsn.api.submit_monthly_dsn?year=2026&month=1

submit_monthly_dsn response:

{
"message": {
"success": true,
"employees": 1,
"submitted_at": "2026-08-12T10:23:44",
"response": "ACCEPTE"
}
}

On success, a Comment is stored on the Payroll Entry:

DSN 01/2026 — ✅ Acceptée
Soumis le 2026-08-12 à 10:23:44

On rejection, the full CRM error message is stored and success: false is returned.


Mock Net-Entreprises Server​

Why an on-cluster mock​

Net-Entreprises requires a real registered SIRET and valid employee NIR numbers for acceptance. Testing against the real endpoint during development would:

  • Require a production SIRET (not available in the test environment)
  • Risk polluting real URSSAF declarations with test data
  • Require opening outbound HTTPS to api.net-entreprises.fr from the cluster

The mock-net-entreprises server validates the structure of the incoming DSN and always returns ACCEPTE, enabling full end-to-end pipeline testing on-cluster.

Implementation​

The mock is a pure Python stdlib http.server.BaseHTTPRequestHandler — no external dependencies, no Pip install, runs on the python:3.11-slim image. It performs minimal but meaningful validation:

def do_POST(self):
body = self.rfile.read(int(self.headers["Content-Length"])).decode("utf-8")

# Validate DSN structure: must contain S10 (header) and S90 (footer) blocks
if "S10.G00.00.001" not in body or "S90.G00.00.002" not in body:
self.send_response(400)
...
return

# Return CRM ACK XML
ack_xml = """<?xml version="1.0" encoding="UTF-8"?>
<CR>
<CodeRetour>ACCEPTE</CodeRetour>
<Libelle>Déclaration acceptée</Libelle>
</CR>"""
self.send_response(200)
self.send_header("Content-Type", "application/xml; charset=utf-8")
...

If the DSN body is missing the S10 header or S90 footer (i.e. a malformed or empty file), the mock returns HTTP 400 — this catches generator bugs before they would reach the real endpoint.

Kubernetes manifests (manifests/erpnext/04-mock-net-entreprises.yaml)​

ConfigMap mock-net-entreprises-app ← app.py Python source
Deployment mock-net-entreprises ← python:3.11-slim, runs app.py
Service mock-net-entreprises ← ClusterIP, port 8080

Security posture:

securityContext:
runAsNonRoot: true
runAsUser: 65534
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]

The server is ClusterIP only — it is not reachable from outside the cluster. Only the gunicorn pod in the same namespace (erp) can reach http://mock-net-entreprises.erp.svc.cluster.local:8080/depot.


Configuration — Env Vars & Secrets​

Design decision: test vs production split​

Rather than hard-coding the Net-Entreprises endpoint or credentials, all DSN config is injected as pod environment variables. This makes the test→prod transition a Vault secret update with zero code change.

VariableSourcePurpose
DSN_TEST_MODEHelm envVars (literal "true")When "true", submitter logs each step verbosely and skips TLS verification
DSN_ENDPOINTHelm envVars (literal URL)CRM endpoint — mock URL in test, real in prod
DSN_LOGINExternalSecret → VaultNet-Entreprises username
DSN_PASSWORDExternalSecret → VaultNet-Entreprises password
DSN_SIRETExternalSecret → VaultEmployer SIRET (14 digits)
DSN_COMPANY_NAMEExternalSecret → VaultCompany name for S10 block

DSN_LOGIN, DSN_PASSWORD, DSN_SIRET, DSN_COMPANY_NAME are all optional: true in their secretKeyRef. This means the gunicorn pod starts cleanly even if Vault has not yet been populated with real credentials — the mock path (DSN_TEST_MODE=true) works without them.

GitOps wiring​

The env vars are injected at two points in helm-values/minicloud-1/erpnext-values.yaml — once for the gunicorn Deployment and once for the worker Deployment (background jobs):

# Applied to both gunicorn + worker sections:
envVars:
- name: DSN_TEST_MODE
value: "true"
- name: DSN_ENDPOINT
value: "http://mock-net-entreprises.erp.svc.cluster.local:8080/depot"
- name: DSN_LOGIN
valueFrom:
secretKeyRef:
name: erpnext-dsn-config
key: DSN_LOGIN
optional: true
- name: DSN_PASSWORD
valueFrom:
secretKeyRef:
name: erpnext-dsn-config
key: DSN_PASSWORD
optional: true
- name: DSN_SIRET
valueFrom:
secretKeyRef:
name: erpnext-dsn-config
key: DSN_SIRET
optional: true
- name: DSN_COMPANY_NAME
valueFrom:
secretKeyRef:
name: erpnext-dsn-config
key: DSN_COMPANY_NAME
optional: true

ExternalSecret → Vault​

manifests/erpnext/01-externalsecrets.yaml contains the erpnext-dsn-config ExternalSecret, which pulls from secret/platform/net-entreprises in Vault:

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: erpnext-dsn-config
namespace: erp
spec:
refreshInterval: 1h
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
target:
name: erpnext-dsn-config
creationPolicy: Owner
data:
- secretKey: DSN_LOGIN
remoteRef:
key: secret/platform/net-entreprises
property: login
- secretKey: DSN_PASSWORD
remoteRef:
key: secret/platform/net-entreprises
property: password
- secretKey: DSN_SIRET
remoteRef:
key: secret/platform/net-entreprises
property: siret
- secretKey: DSN_COMPANY_NAME
remoteRef:
key: secret/platform/net-entreprises
property: company_name

When the Vault path is not yet populated, ESO marks the ExternalSecret as SecretSyncedError but the pods stay Running thanks to optional: true on each secretKeyRef.


L1 Test Suite​

The erpnext_dsn app ships with 108 unit tests (76% coverage) that run in CI without a running Frappe bench, using a sys.modules mock:

# tests/conftest.py
import sys
from unittest.mock import MagicMock
_frappe = MagicMock(name="frappe")
for _mod in ("frappe", "frappe.utils", "frappe.utils.file_manager"):
sys.modules.setdefault(_mod, _frappe)
Test fileTestsWhat it covers
test_dsn_generator.py55S10/S20/S90 block output, CRLF enforcement, UTF-8 encoding, edge cases (zero employees, float amounts, special characters in names)
test_dsn_submitter.py23CRM XML parsing (ACCEPTE, REJETE, SOAP_FAULT, empty body), submit_dsn() with mocked requests.post, error propagation
test_api_helpers.py15Contract type codes (CDI/CDD/interim), salary warnings collection
test_facturx.py15CII XML assertions (Factur-X Minimum profile, shared with erpnext_facturx)

Run locally:

cd ~/Developer/cloudplateform/minicloud-erpnext
pip install -r requirements-test.txt
make test-cov # pytest --cov --cov-fail-under=70

Switching to Production​

When a real SIRET becomes available, the go-live requires two steps and zero code change:

Step 1 — Populate Vault:

ssh controller "kubectl exec -n vault vault-0 -- sh -c \
'VAULT_ADDR=http://127.0.0.1:8200 VAULT_TOKEN=\$(cat /vault-root-token) \
vault kv put -mount=secret platform/net-entreprises \
login=<NET_ENTREPRISES_USER> \
password=<NET_ENTREPRISES_PASSWORD> \
siret=<14_DIGIT_SIRET> \
company_name=\"Ktayl Solutions\"'"

ESO refreshes the Secret within 1 hour (or immediately: kubectl annotate externalsecret erpnext-dsn-config -n erp force-sync=$(date +%s)).

Step 2 — Switch endpoint and disable test mode in GitOps:

In helm-values/minicloud-1/erpnext-values.yaml, update the two literal env vars for both gunicorn and worker:

- name: DSN_TEST_MODE
value: "false"
- name: DSN_ENDPOINT
value: "https://api.net-entreprises.fr/crm/service/dsn/v3/jeton"

Push to main → ArgoCD syncs → pods restart with the new env. No image rebuild needed.


Gotchas​

GotchaRoot causeFix
frappe.init() raises FileNotFoundError: site/logs/database.logFrappe logger uses a relative site/logs/ path — only valid from within the sites/ directoryAlways cd /home/frappe/frappe-bench/sites before running Python scripts in the pod
Sales Stage vs CRM Stage DoesNotExistErrorERPNext v16 stores CRM pipeline stages in the Sales Stage doctype; CRM Stage does not existQuery frappe.db.get_all("Sales Stage", ...) not "CRM Stage"
erpnext_dsn API returns empty results in testhrms app (separate repo from erpnext) is not installed — Salary Slip doctype is part of hrms, not core ERPNext v16Install hrms app in the site (Phase 90 plan) or use the setup_test_employee.py script to seed test data
Pod starts but DSN_LOGIN is emptyVault path secret/platform/net-entreprises not yet populated → ESO error → Secret has empty keysoptional: true on secretKeyRef prevents pod failure; the mock path works without real credentials
requests.post to mock returns connection refusedMock Deployment is down or Service selector mismatchkubectl get pods -n erp -l app=mock-net-entreprises — confirm Running
CRLF stripped by gitDSN files use \r\n line endings; git autocrlf=true on Windows would corrupt themThe generator produces \r\n programmatically in Python ("\r\n".join(lines)) — file is never written to disk through git

Current State​

Verified 2026-08-12 on erp.devandre.sbs:

CheckResult
mock-net-entreprises pod Running✅
DSN_TEST_MODE=true in gunicorn env✅ kubectl exec -n erp <pod> -- env | grep DSN
DSN_ENDPOINT points to mock service✅ http://mock-net-entreprises.erp.svc.cluster.local:8080/depot
ESO ExternalSecret erpnext-dsn-config exists✅ (SecretSyncedError — Vault path not yet populated; mock path unaffected)
108 unit tests passing (76% cov)✅ CI test-unit job green
# Verify DSN env vars in gunicorn pod:
POD=$(kubectl get pod -n erp -l app.kubernetes.io/name=erpnext-gunicorn \
-o jsonpath='{.items[0].metadata.name}')
kubectl exec -n erp $POD -- env | grep DSN

# Check mock server is reachable:
kubectl exec -n erp $POD -- curl -s \
http://mock-net-entreprises.erp.svc.cluster.local:8080/depot \
-X POST -d "S10.G00.00.001: test" \
-H "Content-Type: text/plain"

Phase 90 — Next Steps​

ItemDescription
Install hrms appAdds Salary Slip doctype to Frappe — enables real payroll data in DSN generator instead of seed data
Go-live with real SIRETPopulate secret/platform/net-entreprises in Vault, switch DSN_TEST_MODE=false and real endpoint in gitops
n8n monthly triggern8n workflow fires on the 5th of each month: calls submit_monthly_dsn for the previous month, stores result in Plane issue
DSN anomaly alertsAlertmanager rule: if submit_monthly_dsn POST to CRM returns REJETE → page on-call via Stalwart mail