writing/tutorial/2026/08
TutorialAug 5, 2026·16 min read

Generating a TEJ-conforming withholding XML file straight from your entries

Building the CCT-RS-V2 file directly from the accounting database in TypeScript: typing amounts as millimes, formatting identifiers, guaranteeing reference uniqueness, arithmetic checks, and validating before deposit — so the file never passes through a spreadsheet.

Most rejected TEJ files are not rejected because the team misunderstood the tax rule, but because the file went through a spreadsheet. This tutorial builds the XML directly from the database, with no intermediate step where an amount can become a decimal again.

The target format is CCT-RS-V2. The context of the obligation is covered in the guide to the XML file; here we go into the code.

1. Type amounts as millimes, and never otherwise

This is the structural decision. The schema declares amounts as integers rounded to millimes; any floating representation along the way is an opportunity to produce 1234.5.

// lib/tej/millimes.ts
 
/**
 * Un montant en millimes. Le dinar compte trois décimales, donc
 * 1 234,500 DT vaut 1_234_500 millimes.
 *
 * Le type nominal empêche de passer un nombre « ordinaire » là où un montant
 * est attendu : c'est le compilateur qui refuse le mélange, pas une revue.
 */
export type Millimes = number & { readonly __brand: 'Millimes' };
 
export function millimes(n: number): Millimes {
  if (!Number.isInteger(n) || n < 0) {
    throw new RangeError(`montant non entier en millimes : ${n}`);
  }
  return n as Millimes;
}
 
/** Convertit un dinar décimal en millimes, en refusant l'imprécision. */
export function fromDinars(d: string | number): Millimes {
  const s = String(d).replace(',', '.').trim();
  if (!/^\d+(\.\d{1,3})?$/.test(s)) {
    throw new RangeError(`montant en dinars invalide : ${d}`);
  }
  const [ent, dec = ''] = s.split('.');
  return millimes(Number(ent) * 1000 + Number(dec.padEnd(3, '0')));
}
 
export const toXml = (m: Millimes): string => String(m);

fromDinars refuses a fourth decimal rather than rounding it silently: an amount your accounts store with four decimals signals a problem upstream, and rounding it hides that.

2. Validate identifiers at the boundary

The tax ID is \d{7}[A-Z]. The check belongs at the boundary — where the data enters — not at serialisation, where the error has lost its context.

// lib/tej/identifiants.ts
const MATRICULE = /^\d{7}[A-Z]$/;
 
export type Beneficiaire =
  | { kind: 'MatriculeFiscal'; value: string }
  | { kind: 'CIN'; value: string }
  | { kind: 'Passeport'; value: string }
  | { kind: 'CarteSejour'; value: string };
 
/**
 * Le schéma impose *exactement un* identifiant par bénéficiaire — un xs:choice.
 * Modéliser cela en union discriminée rend l'invariant impossible à violer,
 * là où un objet à quatre champs optionnels laisse passer zéro ou deux.
 */
export function beneficiaire(b: Beneficiaire): Beneficiaire {
  if (b.kind === 'MatriculeFiscal' && !MATRICULE.test(b.value)) {
    throw new RangeError(`matricule fiscal invalide : ${b.value}`);
  }
  if (!b.value.trim()) throw new RangeError('identifiant vide');
  return b;
}

3. Guarantee reference uniqueness

A duplicate Ref_certif_chez_declarant gets the whole deposit rejected. It rarely comes from a typo: it is a counter restarted at 1, or two exports concatenated.

// lib/tej/references.ts
 
/**
 * Vérifie l'unicité avant sérialisation et signale *les deux* occurrences.
 * « Référence en double » sans dire laquelle oblige à relire tout le fichier,
 * ce qui est exactement le service que la plateforme rend déjà.
 */
export function assertReferencesUniques(refs: string[]): void {
  const vues = new Map<string, number>();
  const conflits: string[] = [];
  refs.forEach((r, i) => {
    const premier = vues.get(r);
    if (premier !== undefined) conflits.push(`« ${r} » : lignes ${premier + 1} et ${i + 1}`);
    else vues.set(r, i);
  });
  if (conflits.length) {
    throw new Error(`références en double :\n  ${conflits.join('\n  ')}`);
  }
}

4. Check the arithmetic before writing

// lib/tej/operation.ts
import type { Millimes } from './millimes';
 
export type Operation = {
  montantHT: Millimes;
  montantTVA?: Millimes;
  montantTTC: Millimes;
  montantRS: Millimes;
  montantNetServi: Millimes;
  tauxRS: number;
};
 
export function verifierOperation(op: Operation, ligne: number): string[] {
  const pb: string[] = [];
 
  // Contrôle dur : la plateforme le refuse.
  if (op.montantTTC - op.montantRS !== op.montantNetServi) {
    pb.push(
      `ligne ${ligne} : net servi ${op.montantNetServi} ≠ ${op.montantTTC} − ${op.montantRS}`,
    );
  }
  // Contrôle souple : l'arrondi produit légitimement un millime d'écart.
  const tva = op.montantTVA ?? 0;
  if (op.montantHT + tva !== op.montantTTC) {
    pb.push(`ligne ${ligne} : avertissement, HT + TVA (${op.montantHT + tva}) ≠ TTC (${op.montantTTC})`);
  }
  if (op.tauxRS < 0 || op.tauxRS > 100) {
    pb.push(`ligne ${ligne} : taux de retenue hors bornes (${op.tauxRS})`);
  }
  return pb;
}

The distinction between error and warning matters: treating rounding drift as blocking would condemn perfectly filable files, and the team would end up ignoring the tool.

5. Serialise

// lib/tej/xml.ts
const esc = (s: string) =>
  s.replace(/[<>&'"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' }[c]!));
 
export function serialiser(d: {
  declarant: string; annee: string; mois: string; certificats: CertificatXml[];
}): string {
  const certs = d.certificats.map((c) => `
      <Certificat>
        <Beneficiaire><IdTaxpayer><${c.idKind}>${esc(c.idValue)}</${c.idKind}></IdTaxpayer></Beneficiaire>
        <DatePayement>${c.datePaiement}</DatePayement>
        <Ref_certif_chez_declarant>${esc(c.reference)}</Ref_certif_chez_declarant>
        <ListeOperations>${c.operations.map((o) => `
          <Operation>
            <MontantHT>${o.montantHT}</MontantHT>
            <TauxRS>${o.tauxRS.toFixed(2)}</TauxRS>
            <MontantTTC>${o.montantTTC}</MontantTTC>
            <MontantRS>${o.montantRS}</MontantRS>
            <MontantNetServi>${o.montantNetServi}</MontantNetServi>
          </Operation>`).join('')}
        </ListeOperations>
      </Certificat>`).join('');
 
  return `<?xml version="1.0" encoding="UTF-8"?>
<DeclarationsRS>
  <Declarant>${esc(d.declarant)}</Declarant>
  <ReferenceDeclaration>
    <ActeDepot>AJOUT</ActeDepot>
    <AnneeDepot>${d.annee}</AnneeDepot>
    <MoisDepot>${d.mois}</MoisDepot>
  </ReferenceDeclaration>
  <AjouterCertificats>${certs}
  </AjouterCertificats>
</DeclarationsRS>`;
}

XML escaping is not decorative: a company name containing & — "Ben Ali & Fils" — produces a malformed document that the platform rejects before reading a single business rule.

6. Check the result

Before any deposit, have the produced file read back. The withholding XML validator applies the specification's rules and names the certificate and field at fault; it works entirely in the browser, so the file does not leave the machine.

In CI, the same logic fits in a test over a fixture set:

import { test } from 'node:test';
import assert from 'node:assert/strict';
 
test('le fichier du mois ne contient aucune anomalie bloquante', () => {
  const doc = construireDeclaration(ecrituresDuMois());
  const erreurs = doc.certificats.flatMap((c, i) =>
    c.operations.flatMap((o) => verifierOperation(o, i + 1)),
  ).filter((m) => !m.includes('avertissement'));
  assert.deepEqual(erreurs, []);
});

What to take away

  • Millimes are a type, not a convention. If an amount can be a float somewhere in the path, one day it will be.
  • One identifier per beneficiary, guaranteed by the type. The schema expresses an xs:choice; a discriminated union makes it impossible to deform.
  • Report both occurrences of a duplicate, or you render the same useless service the rejection message already does.
  • Separate errors from warnings, or the team will learn to ignore everything.
  • Do not let the file pass through a spreadsheet. That is where decimals are born.