La plupart des fichiers TEJ refusés ne le sont pas parce que l'équipe a mal compris la règle fiscale, mais parce que le fichier est passé par un tableur. Ce tutoriel construit le XML directement depuis la base, sans étape intermédiaire où un montant peut redevenir un décimal.
Le format visé est CCT-RS-V2. Le contexte de l'obligation est couvert dans le guide du fichier XML ; on entre ici dans le code.
1. Typer les montants en millimes, et jamais autrement
C'est la décision structurante. Le schéma déclare les montants en entiers arrondis en millimes ; toute représentation flottante dans le trajet est une occasion de produire 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 refuse une quatrième décimale plutôt que de l'arrondir en silence : un montant que la comptabilité stocke avec quatre décimales signale un problème en amont, et l'arrondir le masque.
2. Valider les identifiants au bord
Le matricule fiscal est \d{7}[A-Z]. Le contrôle appartient à la frontière — au moment où la donnée entre — et non au moment de la sérialisation, où l'erreur n'a plus de contexte.
// 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. Garantir l'unicité des références
Le doublon de Ref_certif_chez_declarant fait rejeter le dépôt entier. Il vient rarement d'une faute de frappe : c'est un compteur reparti de 1, ou deux exports concaténés.
// 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. Contrôler l'arithmétique avant d'écrire
// 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;
}La distinction entre erreur et avertissement compte : traiter l'écart d'arrondi comme bloquant condamnerait des fichiers parfaitement déposables, et l'équipe finirait par ignorer l'outil.
5. Sérialiser
// lib/tej/xml.ts
const esc = (s: string) =>
s.replace(/[<>&'"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' }[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>`;
}L'échappement XML n'est pas décoratif : une raison sociale contenant & — « Ben Ali & Fils » — produit un document mal formé que la plateforme rejette avant même de lire une règle métier.
6. Contrôler le résultat
Avant tout dépôt, faites relire le fichier produit. Le validateur XML de retenue à la source applique les règles du cahier des charges et nomme le certificat et le champ en cause ; il travaille entièrement dans le navigateur, donc le fichier ne quitte pas la machine.
En intégration continue, la même logique tient en un test sur un jeu de fixtures :
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, []);
});Ce qu'il faut retenir
- Les millimes sont un type, pas une convention. Si un montant peut être un flottant quelque part dans le trajet, il le sera un jour.
- Un identifiant par bénéficiaire, garanti par le type. Le schéma exprime un
xs:choice; une union discriminée le rend indéformable. - Signalez les deux occurrences d'un doublon, sinon vous rendez le même service inutile que le message de rejet.
- Séparez erreur et avertissement, ou l'équipe apprendra à tout ignorer.
- Ne laissez pas le fichier passer par un tableur. C'est là que naissent les décimales.