Cross-references:
- Detailed contribution spec:
spec-cotisations-comparaison.md - Shared execution list:
../todo.md
- Merge
MutuelleandPrévoyanceinto a single tab namedOrganismes complémentaires - New fixed tab order:
URSSAF,PAS,Retraite,Organismes complémentaires - In
URSSAF, target a 4-level control chain:AgrégévsBordereauvsCodevsSalariés - In the URSSAF employee view, display employee names in clear text
- Fewer tabs means a simpler mental model for payroll users.
- Moving
Retraitebefore complementary organisms matches the priority order of the control workflow. - The URSSAF view becomes actionable: not just "there is a gap", but "which code is off" and "which employees are behind that amount".
- At establishment level, URSSAF is controlled through CTP / bordereau blocks (
S21.G00.22/S21.G00.23). - At employee level, DSN uses individual contribution blocks (
S21.G00.78/S21.G00.81). - These two levels do not always expose the same business code one-to-one.
- Recommendation: only show employee attribution when the DSN link is explicit and testable. If the norm does not allow a reliable link for a given code, the product must say "non rattaché" instead of inventing a match.
Why this is important:
- If we guess the mapping, the UI may point to the wrong employee even though the math looks precise.
- If we keep the rule strict, users can trust the cases we do show.
Files: server/static/app.js, server/static/index.html, server/static/style.css
- Replace the 5 current fixed family tabs with 4 fixed tabs:
URSSAFPASRetraiteOrganismes complémentaires
- Merge
prevoyanceandmutuelleonly at presentation level - Keep backend family typing unchanged (
prevoyanceandmutuellestay distinct in payloads and reconciliation logic) - Render both complementary families inside the same
Organismes complémentairestab without losing organism / contrat / adhésion context - Apply the same order in both global and establishment scopes
Acceptance criteria:
- No organism disappears from the current result set after the tab merge
- The new order is stable everywhere in the UI
- Existing comparison calculations remain unchanged for PAS / URSSAF / Retraite / complementary items
Files: spec-cotisations-comparaison.md, dsn_extractor/contributions.py, tests/test_contributions.py
- Define which URSSAF codes can be linked reliably from establishment-level CTP controls to employee-level declarations
- Lock a first verified mapping table for explicit cases only
- Include at least one representative example around code
027(Contribution au dialogue social) if the DSN mapping is normatively reliable - Mark non-mappable or ambiguous codes as
non_rattache/non_calculableinstead of forcing a person-level attribution - Document the product rule in the spec and in tests before UI work
Acceptance criteria:
- Every employee-level URSSAF attribution shown in UI is backed by an explicit mapping rule
- Ambiguous cases remain visible as warnings, not hidden and not guessed
Files: dsn_extractor/models.py, dsn_extractor/contributions.py
- Extend the URSSAF detail payload to carry an employee-level subtotal for each code when mapping is reliable
- Compute, for each eligible URSSAF code, the comparison between:
- CTP / code amount at establishment level
- summed individual amount at employee level
- Add a per-code delta between establishment declaration and employee subtotal
- Preserve the existing controls:
Agrégé vs BordereauBordereau vs somme des codes
- Add employee drill-down rows with:
- employee name
- declaration code(s) used
- amount
- DSN line references
- Emit explicit warnings when a code has individual data but no reliable mapping
Acceptance criteria:
- A URSSAF gap can be read at 4 levels: aggregate, bordereau, code, employee
- The engine never fabricates a code-to-employee match
Files: server/static/app.js, server/static/index.html, server/static/style.css
- In the URSSAF panel, keep the current high-level summary cards / badges
- In the detail table, add the employee-level subtotal and per-code delta
- Make each URSSAF code row expandable to reveal the employee list for that code
- Keep the default filter
Afficher uniquement les écarts - Show employee names in clear text in the expanded URSSAF code view
- Distinguish clearly between:
- no employee detail found
- employee detail found but not reliably allocable
- employee detail found and matched
Acceptance criteria:
- A payroll user can identify the offending URSSAF code without leaving the screen
- If the code is allocable, the user can see which employees explain the amount
- If the code is not allocable, the UI explains that limitation explicitly
Files: tests/test_contributions.py, tests/test_server.py, tests/fixtures/
- Add fixtures for:
- clean URSSAF equality
- code-level mismatch
- employee-level mismatch on a mapped code
- ambiguous code-to-employee mapping
- regularization case
- Add regression tests for the merged complementary tab and the new tab order
- Verify that
globalandper establishmentviews stay consistent after the UI change
Dependencies: pydantic, pytest
- Full DSN compliance validation
- DPAE / signalement reconstruction
- Narrative interpretation of payroll events
- Payroll item inference beyond explicit DSN coding
- Web interface, Docker, deployment
- Init Python 3.12 project with
pyproject.toml - Install MVP deps:
pydantic,pytest - Create package structure:
dsn_extractor/ __init__.py __main__.py parser.py models.py normalize.py extractors.py enums.py tests/ conftest.py test_parser.py test_extractors.py test_normalize.py fixtures/ single_establishment.dsn multi_establishment.dsn no_s54_blocks.dsn unknown_enum_codes.dsn missing_contract_fields.dsn
Files: parser.py
- Regex line parser:
r"^(S\d+\.G\d+\.\d+\.\d+),'(.*)'$" - Produce ordered list of
(code, raw_value, line_number)records - Split into:
- file-level rubrics:
S10.*,S20.*,S90.* - establishment-level rubrics:
S21.G00.06.*,S21.G00.11.* - employee-level blocks
- file-level rubrics:
- Establishment context tracking:
- maintain current active establishment from
S21.G00.11blocks - assign subsequent employee blocks (
S21.G00.30.*) to the active establishment context - assign
S21.G00.54blocks to the active establishment context - if an employee or
S21.G00.54block appears before any establishment context is set, place it in an "unassigned" bucket and emit a global warning
- maintain current active establishment from
- Employee boundary: new block on each
S21.G00.30.001 - Tests:
- line count preserved, no line dropped
- single-establishment: one establishment, correct employee count
- multi-establishment: multiple establishments, employees assigned correctly
- unassigned blocks: employee before any establishment context triggers warning
Files: models.py, normalize.py, enums.py
Top-level structure:
source_file
declaration { ... }
company { ... }
establishments [ { identity, counts, amounts, extras, quality } ]
global_counts { ... }
global_amounts { ... }
global_quality { ... }
Each establishment entry contains its own counts, amounts, extras, quality.
Global sections aggregate across all establishments.
- Date:
YYYYMMDD→YYYY-MM-DD - Decimal:
decimal.Decimal - Empty string →
None
- Retirement category (
S21.G00.40.003):01→cadre,02→extension_cadre,04→non_cadre98→other_no_cadre_split,99→no_complementary_retirement
- Contract nature (
S21.G00.40.007):01→cdi_prive,02→cdd_prive,29→convention_stage
- Conventional status (
S21.G00.40.002): raw code grouping only — no normalized label map unless enum table is explicitly implemented and tested - Unknown codes: preserve raw value, never fail extraction, emit warning
- Round-trip date/decimal normalization
- Unknown enum codes pass through without error
Files: extractors.py
-
norm_version←S10.G00.00.001 -
declaration_nature_code←S20.G00.05.001 -
declaration_kind_code←S20.G00.05.002 -
declaration_rank_code←S20.G00.05.003 -
period_start←S20.G00.05.005 -
period_end←S20.G00.05.007 -
month=YYYY-MMfromperiod_start -
dsn_id←S20.G00.05.009
-
siren←S10.G00.01.001 -
nic←S10.G00.01.002 -
siret=siren + nic -
name,address,postal_code,city,country_code
- Primary source:
S21.G00.11.*fields (nic, naf, address, name, ccn) - Fallback:
S21.G00.06.*whenS21.G00.11absent — emit warning - CCN: primary
S21.G00.11.022; fallback: uniqueS21.G00.40.017across employees of that establishment- if multiple different employee-level
S21.G00.40.017values exist, setccn_code = nulland emit warning
- if multiple different employee-level
-
siret= company siren + establishment nic
-
employee_blocks_count= count ofS21.G00.30.001occurrences in this establishment- This is the user-facing "number of bulletins (employés)" for MVP. It is a structural count of DSN employee sections, not a deduplicated headcount.
-
stagiaires= employees whereS21.G00.40.007 == "29" -
employees_by_retirement_category_code+employees_by_retirement_category_label -
employees_by_conventional_status_code(raw grouping, no label map) -
employees_by_contract_nature_code -
new_employees_in_month= employees where contract start date (S21.G00.40.001) falls inside[period_start, period_end]- Note: this is a monthly-DSN proxy for new hires, not a DPAE count
-
exiting_employees_in_month= employees where contract end date (S21.G00.62.001) falls inside[period_start, period_end]and rupture code (S21.G00.62.002) ≠"099"
-
tickets_restaurant_employer_contribution_total= sum ofS21.G00.54.002whereS21.G00.54.001 == "17"- Note: this represents the employer contribution as coded in DSN. It does not represent the full employee-facing ticket restaurant value when payroll software does not encode it explicitly this way.
- Return
nullwhen no type17block is present for the establishment (types18/19may still exist)
-
transport_public_total= sum where type18(returnnullwhen no type18present) -
transport_personal_total= sum where type19(returnnullwhen no type19present)
-
net_fiscal_sum← sumS21.G00.50.002 -
net_paid_sum← sumS21.G00.50.004 -
pas_sum← sumS21.G00.50.009 -
gross_sum_from_salary_bases(optional)
Per-establishment and global. The extractor must never invent values; ambiguous or missing data must surface as warnings.
- Multiple establishments detected in file
- Missing
S21.G00.11— fallback toS21.G00.06used - Missing or invalid period dates (
S20.G00.05.005/S20.G00.05.007) - Employee block missing contract start date (
S21.G00.40.001) - Contract end block (
S21.G00.62) missing rupture code (S21.G00.62.002) - Unknown retirement category code (not in enum map)
- Unknown contract nature code (not in enum map)
- No
S21.G00.54block family present in establishment - Employee or amount block could not be assigned to an establishment
- Conflicting employee-level CCN (
S21.G00.40.017) values prevent establishment CCN derivation
-
global_counts: sum all per-establishment counts -
global_amounts: sum all per-establishment amounts -
global_quality: merge all per-establishment warnings + file-level warnings
Files: tests/
-
single_establishment.dsn— one establishment, standard employee mix -
multi_establishment.dsn— two+ establishments, employees split across them -
no_s54_blocks.dsn— noS21.G00.54family at all -
unknown_enum_codes.dsn— retirement/contract codes not in enum map -
missing_contract_fields.dsn— employee blocks with missing contract start or end fields
- Parser: line count, block segmentation, establishment assignment
- Single-establishment: all fields extracted, counts correct
- Multi-establishment: per-establishment counts correct, global aggregates match sum
- No S54:
tickets_restaurant_employer_contribution_totalisnull, warning emitted - Unknown codes: extraction succeeds, raw codes preserved, warnings emitted
- Missing fields: extraction succeeds, warnings list missing expected data
- Acceptance criteria validated against both per-establishment and global output
Files: dsn_extractor/__main__.py
-
python -m dsn_extractor path/to/file.dsn→ JSON to stdout -
--pretty— indented output -
--per-establishment— output per-establishment detail (default) -
--global-only— output only global aggregates, omit per-establishment detail - Exit code 0 on success, 1 on parse failure
- Parses every line without losing order
- Segments establishments correctly; assigns employees to parent establishment
employee_blocks_countmatches actualS21.G00.30.001occurrences per establishment- Returns
month,company, per-establishmentccn_code - Groups employees by
S21.G00.40.003(retirement category) per establishment - Counts
new_employees_in_monthbyS21.G00.40.001inside month window — not labeled as DPAE - Counts
exiting_employees_in_monthbyS21.G00.62.001inside month window, rupture ≠"099" - Returns
tickets_restaurant_employer_contribution_total = nullwhen type17absent - Unknown enum codes preserved as raw values, never cause extraction failure
- Warnings emitted for all ambiguous/missing data cases
- Global aggregates equal sum of per-establishment values
- Never invents values not present in the file
- Establishment CCN from employee-level fallback (
S21.G00.40.017) only used when unique within the establishment; conflicting values →ccn_code = null+ warning
Additional dependencies: fastapi, uvicorn, python-multipart
Files: server/app.py
-
POST /api/extract— accepts multipart.dsnupload, returns full JSON output -
GET /health— health check -
GET /— serves static frontend - CORS config for deploy domain
- Max upload size: 10 MB
- Error responses: 400 on parse failure with quality warnings
Files: server/static/index.html, style.css, app.js
Design: Linear / Vercel / Attio density. Dark surface, Inter + JetBrains Mono, 13px body, fine-line borders, 4px spacing grid.
- Full-viewport centered drop area
- Drag-over state with border highlight
- File input fallback button
- Accepts
.dsnfiles only - Upload spinner
- Header: company name, SIRET, period month
- Establishment selector (tabs/dropdown) when multiple present
- Summary cards: employee count, hires, exits, stagiaires
- Table: employees by retirement category (code + label + count)
- Table: employees by contract nature
- Amounts: tickets restaurant employer contribution, transport (
—when null) - Extras: net fiscal, net paid, PAS totals
- Quality warnings banner
- Global vs per-establishment toggle
- "Upload another" reset
- Background hierarchy: root → surface → surface-hover → surface-active
- Borders as primary spatial separators
- Shadows: sm on cards, md on overlays
- Radius: 6px components, 12px containers
- All interactive elements: hover + active states
- Status colors: info / success / warning / error
- Monospace for codes, SIRET, amounts; sans for labels
- Dockerfile: Python 3.12 slim, install deps, run uvicorn
-
koyeb.yamlor GitHub integration deploy - Health check:
GET /health - Target: single service, 256 MB RAM, 0.1 vCPU
- Custom domain (optional)