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 SlipEmployee 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 envkubectl exec -n erp <pod> -- env | grep DSN
DSN_ENDPOINT points to mock servicehttp://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