La circulaire de la Banque Centrale de Tunisie n°2026-10 du 25 septembre 2026 abroge la circulaire 2018-16 et réécrit les règles applicables à tous les établissements de paiement tunisiens. Elle entre en vigueur trois mois après sa publication (article 52), soit fin décembre 2026. La presse a surtout parlé des nouveaux plafonds. Pour l'équipe qui tient le système de comptes, les plafonds sont la partie facile. La circulaire change aussi ce qui est plafonné, crée un compte commerçant qui échappe aux plafonds, impose un compte global égal à tout moment à la somme des soldes, place un registre de tests mesurable devant l'enrôlement à distance et ajoute une annexe de reporting de plus de trente déclarations codées.
Ce tutoriel transforme ces articles en un seul module TypeScript accompagné de tests. Chaque règle renvoie à l'article dont elle vient : un responsable conformité peut lire le code, un développeur peut lire la circulaire.
Ce que vous allez construire
Un module unique, payment-institution.ts, qui :
- Applique les niveaux de compte de l'article 17 et les règles d'ouverture des articles 17 et 21.
- Comptabilise les opérations sous les plafonds de solde, les plafonds journaliers de retrait en espèces, l'interdiction du solde débiteur de l'article 22 et les règles du compte commerçant de l'article 23.
- Applique les plafonds par opération de l'article 3 et refuse une opération plafonnée non justifiée.
- Tient un registre chaîné par empreintes conservé dix ans, conformément à l'article 16.
- Rapproche le compte global de l'ensemble des soldes (articles 24 à 26) et calcule la date limite de dépôt.
- Mesure le procédé d'enrôlement à distance dans un registre de performance avec un taux de fausses acceptations (article 18) et bloque la mise en production quand une preuve manque.
- Calcule le calendrier de reporting de l'annexe 1 bis, les déclarations d'incident grave et l'état RAM06 par canal.
Prérequis
- Node.js 20 ou plus récent et TypeScript 5 en mode strict
- Une pratique d'un système de tenue de comptes
- La circulaire elle-même, ouverte à côté : circulaire n°2026-10 sur le site de la BCT. Les numéros d'articles ci-dessous suivent la version de la BCT, qui a corrigé une erreur de numérotation présente dans la copie diffusée par la presse le 25 septembre.
D'où viennent ces obligations
| Règle | Article | Ce que fait le code |
|---|---|---|
| Trois niveaux de compte client, plafonds de solde et de retrait en espèces | 17 | LIMITS, post |
| Un seul compte client par personne | 21 | assertCanOpen |
| Aucun concours, jamais de solde débiteur | 22 | post |
| Comptes commerçants hors plafonds, crédités uniquement par les paiements acceptés | 23 | post |
| Transferts par remise d'espèces à 3 000 TND, fonds de l'étranger à 20 000 TND, tous deux justifiés | 3 | assertOperationCaps |
| Registres conservés au moins dix ans | 16 | append, retainUntil |
| Compte global en banque au plus tard le jour ouvrable suivant, égal à tous les soldes, rapproché | 24, 25, 26 | reconcile, depositDeadline |
| Enrôlement à distance, registre de performance, tests d'intrusion, audit tous les deux ans | 18 | performance, goLiveBlockers |
| Annexe de reporting | 50 et annexe 1 bis | returnsDue, incidentReturns, ram06 |
Trois lectures de ce tutoriel sont les nôtres et sont signalées là où elles apparaissent : le calcul du taux de fiabilité, le décompte des délais DR+N et le sort d'un crédit qui dépasserait un plafond.
Étape 1 : des montants en millimes, des comptes par niveau
Le dinar a trois décimales. Des dinars en virgule flottante produisent un contrôle de plafond qui échoue à 1 500,000 parce que la somme valait 1 500,0000000002. Stockez chaque montant en nombre entier de millimes et convertissez une seule fois, en bordure du système.
// payment-institution.ts — BCT Circular 2026-10
// Amounts are in millimes (1 dinar = 1,000 millimes), always whole numbers.
export type Millimes = number;
export const TND = (dinars: number): Millimes => Math.round(dinars * 1000);
export type HolderKind = 'natural' | 'legal';
export type AccountKind = 'level1' | 'level2' | 'level3' | 'merchant';
export interface Account {
id: string;
holderId: string;
holderKind: HolderKind;
kind: AccountKind;
balance: Millimes;
}
export interface Limits {
balanceCap: Millimes | null; // null = no ceiling
dailyCashWithdrawalCap: Millimes | null; // null = none set by the text
}
// Article 17 for client accounts; Article 23 exempts merchant accounts.
export const LIMITS: Record<AccountKind, Limits> = {
level1: { balanceCap: TND(1_500), dailyCashWithdrawalCap: null },
level2: { balanceCap: TND(5_000), dailyCashWithdrawalCap: TND(3_000) },
level3: { balanceCap: TND(20_000), dailyCashWithdrawalCap: TND(10_000) },
merchant: { balanceCap: null, dailyCashWithdrawalCap: null },
};
export class RuleError extends Error {
constructor(public readonly article: string, detail: string) {
super(`${article}: ${detail}`);
}
}Deux détails de l'article 17 faciles à rater :
- Le niveau 1 n'a pas de plafond journalier de retrait en espèces dans le texte. Son seul plafond est le solde de 1 500 dinars. Ne recopiez pas le plafond du niveau 2.
- Le texte de 2018 plafonnait le total des sorties par jour (250, 500 et 1 000 dinars). Le texte de 2026 ne plafonne que les retraits en espèces : 3 000 dinars par jour au niveau 2 et 10 000 au niveau 3. Un virement ou un paiement par carte ne compte plus dans une limite journalière. Si votre moteur additionne encore tous les débits, il est désormais plus strict que la réglementation, et vos clients le remarqueront.
Étape 2 : les règles d'ouverture
L'article 17 réserve le niveau 1 aux personnes physiques. Les niveaux 2 et 3 sont ouverts aux personnes physiques et morales. L'article 21 interdit d'ouvrir plus d'un compte de paiement client au profit d'une même personne.
export type OpeningRequest = Omit<Account, 'balance'>;
export function assertCanOpen(existing: readonly Account[], req: OpeningRequest): Account {
if (req.kind === 'level1' && req.holderKind !== 'natural') {
throw new RuleError('Art. 17', 'a level 1 account is reserved for natural persons');
}
const isClient = (k: AccountKind) => k !== 'merchant';
if (isClient(req.kind) && existing.some((a) => a.holderId === req.holderId && isClient(a.kind))) {
throw new RuleError('Art. 21', 'this holder already has a client payment account');
}
return { ...req, balance: 0 };
}Nous lisons l'article 21 comme visant les comptes de paiement client : un commerçant peut donc détenir un compte client et un compte commerçant. Le compte commerçant est une catégorie distincte à l'article 23, avec sa propre fiche d'identification (annexe 4) et sa propre convention. Si votre service juridique en fait une lecture plus stricte, modifiez isClient et le test correspondant.
Conséquence pratique de l'article 21 : faire passer un client du niveau 1 au niveau 2 est un changement de niveau sur le même compte, pas un second compte. Modélisez la montée de niveau comme une mise à jour avec une nouvelle fiche d'identification (les annexes 1, 2 et 3 de la circulaire diffèrent selon le niveau), jamais comme une ouverture.
Étape 3 : les types d'opération et les plafonds de l'article 3
L'article 3 plafonne deux catégories d'opérations, par opération :
- Les transferts effectués par remise d'espèces : 3 000 dinars.
- La mise à disposition de fonds en provenance de l'étranger : la contre-valeur de 20 000 dinars.
Les deux doivent être « dûment justifiées ». Le moteur traite une référence de justification absente comme un refus, pas comme un avertissement.
export type OperationType =
| 'cash_deposit'
| 'cash_withdrawal'
| 'transfer_in'
| 'transfer_out'
| 'cash_transfer' // Art. 2, fourth dash: transfer funded by handing over cash
| 'foreign_receipt' // Art. 2: funds received from abroad (dinar counter-value)
| 'merchant_receipt' // a payment accepted by a merchant (Art. 23)
| 'direct_debit'
| 'payment';
export interface Operation {
id: string;
accountId: string;
type: OperationType;
amount: Millimes; // always positive; the type gives the direction
executedAt: Date;
channel: string; // your own channel codes, used for RAM06
justificationRef?: string; // Art. 3: capped operations must be justified
}
const CREDITS: ReadonlySet<OperationType> = new Set<OperationType>([
'cash_deposit', 'transfer_in', 'cash_transfer', 'foreign_receipt', 'merchant_receipt',
]);
export const isCredit = (t: OperationType): boolean => CREDITS.has(t);
// Article 3: per-operation ceilings.
export const PER_OPERATION_CAP: Partial<Record<OperationType, Millimes>> = {
cash_transfer: TND(3_000),
foreign_receipt: TND(20_000),
};
export function assertOperationCaps(op: Operation): void {
const cap = PER_OPERATION_CAP[op.type];
if (cap === undefined) return;
if (op.amount > cap) {
throw new RuleError('Art. 3', `${op.type} is capped at ${cap / 1000} TND per operation`);
}
if (!op.justificationRef) {
throw new RuleError('Art. 3', `${op.type} must be duly justified`);
}
}Le champ channel n'est pas une exigence réglementaire sur l'opération elle-même. Il est là parce que la déclaration RAM06 de l'étape 10 ventile les opérations par canal, et le moment le moins coûteux pour enregistrer le canal est la création de l'opération.
Étape 4 : comptabiliser sous les plafonds
C'est le cœur du système. Un crédit vérifie le plafond de solde et les règles du compte commerçant. Un débit vérifie l'interdiction du solde débiteur et, pour les retraits en espèces, le plafond journalier.
// Tunisia is UTC+1 all year (no daylight saving since 2009).
const TUNIS_OFFSET_MS = 60 * 60 * 1000;
export const tunisDay = (d: Date): string =>
new Date(d.getTime() + TUNIS_OFFSET_MS).toISOString().slice(0, 10);
export function post(account: Account, op: Operation, history: readonly Operation[]): Account {
if (op.accountId !== account.id) throw new RuleError('Ledger', 'operation posted to the wrong account');
if (!Number.isInteger(op.amount) || op.amount <= 0) {
throw new RuleError('Ledger', 'amount must be a positive whole number of millimes');
}
assertOperationCaps(op);
const limits = LIMITS[account.kind];
if (isCredit(op.type)) {
if (account.kind === 'merchant' && op.type !== 'merchant_receipt') {
throw new RuleError('Art. 23', 'a merchant account is credited only by accepted payments');
}
if (account.kind !== 'merchant' && op.type === 'merchant_receipt') {
throw new RuleError('Art. 23', 'merchant receipts go to a merchant account');
}
const next = account.balance + op.amount;
if (limits.balanceCap !== null && next > limits.balanceCap) {
throw new RuleError('Art. 17', `balance would exceed ${limits.balanceCap / 1000} TND`);
}
return { ...account, balance: next };
}
const next = account.balance - op.amount;
if (next < 0) throw new RuleError('Art. 22', 'a payment account can never be overdrawn');
const cap = limits.dailyCashWithdrawalCap;
if (op.type === 'cash_withdrawal' && cap !== null) {
const day = tunisDay(op.executedAt);
const used = history
.filter((o) => o.accountId === account.id && o.type === 'cash_withdrawal' && tunisDay(o.executedAt) === day)
.reduce((sum, o) => sum + o.amount, 0);
if (used + op.amount > cap) {
throw new RuleError('Art. 17', `cash withdrawals are capped at ${cap / 1000} TND per day`);
}
}
return { ...account, balance: next };
}Trois décisions sont codées ici.
Le jour est le jour de Tunis. Un retrait à 0 h 30 heure locale appartient au nouveau jour, même s'il est encore la veille en UTC. La Tunisie est en UTC+1 toute l'année depuis 2009, un décalage fixe est donc correct. Si vos serveurs tournent en UTC et que vous regroupez par toISOString().slice(0, 10), un client qui retire à 23 h 45 puis à 0 h 15 est bloqué pour une journée qu'il n'a pas utilisée.
Un crédit qui dépasserait le plafond est refusé. La circulaire plafonne le solde. Elle ne dit pas quoi faire d'un virement entrant qui le dépasserait. Le refuser est notre choix, car l'alternative (l'accepter et loger l'excédent ailleurs) crée un solde qui existe hors du compte, ce que l'article 25 rend difficile à défendre. Quel que soit votre choix, rendez-le explicite et informez l'émetteur.
Le compte commerçant est à sens unique. L'article 23 dispose qu'il est crédité exclusivement des flux générés par les paiements acceptés par le commerçant. Un virement client vers ce compte est refusé. Un encaissement commerçant vers un compte client est aussi refusé, pour que les encaissements ne servent pas à pousser un compte client au-delà de son plafond.
Étape 5 : le registre, dix ans et la preuve d'intégrité
L'article 16 impose des registres de toutes les opérations prévues à l'article 2, conservés au moins dix ans à compter de la date d'exécution. L'article 12 ajoute la traçabilité de chaque opération et l'enregistrement en temps réel, y compris dans le réseau d'agents. Une chaîne d'empreintes donne les deux : chaque entrée s'engage sur la précédente, si bien qu'un montant modifié casse la chaîne à partir de ce point.
import { createHash } from 'node:crypto';
export interface RegisterEntry {
seq: number;
op: Operation;
balanceAfter: Millimes;
retainUntil: Date;
prevHash: string;
hash: string;
}
// Article 16: kept for at least ten years from the date of execution.
export function retainUntil(executedAt: Date): Date {
const d = new Date(executedAt.getTime());
d.setUTCFullYear(d.getUTCFullYear() + 10);
return d;
}
const digest = (prevHash: string, seq: number, op: Operation, balanceAfter: Millimes): string =>
createHash('sha256')
.update(JSON.stringify([prevHash, seq, op.id, op.accountId, op.type, op.amount,
op.executedAt.toISOString(), op.channel, op.justificationRef ?? null, balanceAfter]))
.digest('hex');
export function append(register: readonly RegisterEntry[], op: Operation, balanceAfter: Millimes): RegisterEntry {
const last = register[register.length - 1];
const seq = last ? last.seq + 1 : 1;
const prevHash = last ? last.hash : 'genesis';
return { seq, op, balanceAfter, retainUntil: retainUntil(op.executedAt), prevHash,
hash: digest(prevHash, seq, op, balanceAfter) };
}
// Returns the first broken sequence number, or null if the chain is intact.
export function verifyChain(register: readonly RegisterEntry[]): number | null {
let prev = 'genesis';
for (const [i, e] of register.entries()) {
if (e.seq !== i + 1 || e.prevHash !== prev || e.hash !== digest(prev, e.seq, e.op, e.balanceAfter)) return e.seq;
prev = e.hash;
}
return null;
}
export const mayPurge = (e: RegisterEntry, now: Date): boolean => now.getTime() >= e.retainUntil.getTime();Pour la production :
- Écrivez l'entrée du registre dans la même transaction que la modification du solde. Un solde qui a bougé sans entrée au registre est exactement l'écart que cherche un inspecteur.
retainUntilest un plancher, pas une date de suppression. Les règles sur les données personnelles peuvent imposer de garder moins pour certains champs et les règles anti-blanchiment plus pour d'autres. Ne purgez que lorsque toutes les règles applicables le permettent.- Un terminal d'agent qui met des opérations en file hors ligne rompt le « temps réel » de l'article 12. Si vos agents travaillent hors ligne, enregistrez séparément l'heure d'exécution et l'heure d'enregistrement, et déclarez l'écart.
Étape 6 : le compte global et le rapprochement quotidien
Les fonds des clients et des commerçants n'appartiennent pas à l'établissement. L'article 24 impose de les déposer sur un compte global unique dans une banque, au plus tard le jour ouvrable suivant leur réception. Les commissions ne doivent pas être comptabilisées sur ce compte. L'article 25 exige que son solde soit égal, à tout moment, à la somme des soldes des comptes clients et commerçants. L'article 26 impose un rapprochement régulier et documenté.
export interface Reconciliation {
globalBalance: Millimes;
sumOfAccounts: Millimes;
difference: Millimes; // positive = more in the global account than owed to holders
ok: boolean;
}
// Articles 25 and 26: the global account equals the sum of client and merchant balances.
export function reconcile(globalBalance: Millimes, accounts: readonly Account[]): Reconciliation {
const sumOfAccounts = accounts.reduce((sum, a) => sum + a.balance, 0);
const difference = globalBalance - sumOfAccounts;
return { globalBalance, sumOfAccounts, difference, ok: difference === 0 };
}
const DAY_MS = 86_400_000;
const isNonWorking = (d: Date, holidays: ReadonlySet<string>): boolean => {
const weekday = d.getUTCDay();
return weekday === 0 || weekday === 6 || holidays.has(d.toISOString().slice(0, 10));
};
// Article 24: funds reach the global account no later than the next working day after receipt.
// Holidays are an input: the religious ones move every year.
export function depositDeadline(receivedAt: Date, holidays: ReadonlySet<string>): string {
let d = new Date(`${tunisDay(receivedAt)}T00:00:00Z`);
do {
d = new Date(d.getTime() + DAY_MS);
} while (isNonWorking(d, holidays));
return d.toISOString().slice(0, 10);
}Comment lire un écart non nul :
- Positif (plus d'argent en banque que ce qui est dû aux titulaires) : le plus souvent des commissions laissées sur le compte global au lieu d'être virées sur le compte propre de l'établissement. C'est une infraction à l'article 24, même si personne n'a perdu d'argent.
- Négatif (moins en banque que ce qui est dû) : des fonds reçus mais pas encore déposés, acceptable seulement jusqu'à la date donnée par
depositDeadline, ou un vrai déficit.
Les jours fériés sont une entrée parce que les fêtes religieuses suivent le calendrier lunaire. Chargez-les chaque année depuis une source officielle au lieu de les coder en dur.
Étape 7 : le registre de performance de l'enrôlement à distance
L'article 18 ouvre l'enrôlement à distance à tous les niveaux, à condition que le procédé vérifie l'identité au moins aussi bien qu'en présence physique. Avant la mise en production, le procédé doit être testé en préproduction et les résultats consignés dans un registre de performance qui évalue le taux de fiabilité du dispositif, y compris le taux de fausses acceptations.
export interface Trial {
genuine: boolean; // true: a real person with their own valid document
accepted: boolean; // what the onboarding process decided
}
export interface PerformanceReport {
trials: number;
falseAcceptanceRate: number; // impostors accepted / impostor trials
falseRejectionRate: number; // genuine rejected / genuine trials
reliability: number; // correct decisions / all trials
}
export function performance(trials: readonly Trial[]): PerformanceReport {
const impostors = trials.filter((t) => !t.genuine);
const genuine = trials.filter((t) => t.genuine);
if (impostors.length === 0 || genuine.length === 0) {
throw new RuleError('Art. 18', 'the test set needs both genuine and impostor trials');
}
const falseAccepts = impostors.filter((t) => t.accepted).length;
const falseRejects = genuine.filter((t) => !t.accepted).length;
return {
trials: trials.length,
falseAcceptanceRate: falseAccepts / impostors.length,
falseRejectionRate: falseRejects / genuine.length,
reliability: (trials.length - falseAccepts - falseRejects) / trials.length,
};
}La circulaire nomme le taux de fausses acceptations mais ne définit pas la fiabilité. Nous rapportons trois chiffres : les fausses acceptations (imposteurs acceptés), les faux rejets (vrais clients refusés) et la part de décisions correctes. Gardez les trois dans le registre. Un procédé réglé uniquement contre les fausses acceptations peut refuser tant de vrais clients que le canal à distance cesse de fonctionner.
Le jeu de tests compte plus que la formule. Les essais d'imposteurs doivent inclure des photos imprimées, des rejeux d'écran, des pièces appartenant à quelqu'un d'autre et des pièces expirées. Versionnez le jeu d'essais pour que l'audit suivant puisse le rejouer.
Étape 8 : la barrière de mise en production
L'article 18 énumère ce que le procédé doit faire (vérification des pièces, preuve de vie, authentification à deux facteurs au moins, consentement explicite, chiffrement, fiche KYC générée automatiquement) et ce qui doit l'entourer : des tests d'intrusion et des audits de sécurité par des organismes homologués par l'Agence Nationale de la Cybersécurité (ANCS), puis un audit au moins tous les deux ans et à chaque modification réglementaire ou technologique susceptible d'affecter le procédé.
export interface OnboardingEvidence {
documentAuthenticity: boolean;
liveness: boolean;
twoFactor: boolean;
explicitConsent: boolean;
encryption: boolean;
autoKycRecord: boolean;
register?: PerformanceReport; // from pre-production, Step 7
penTestReportRef?: string; // by a body approved by ANCS
lastAuditAt?: Date;
lastMaterialChangeAt?: Date; // regulatory or technological change since then
}
const addYears = (d: Date, years: number): Date => {
const out = new Date(d.getTime());
out.setUTCFullYear(out.getUTCFullYear() + years);
return out;
};
// maxFalseAcceptance is your own risk appetite: the circular names the rate, not a threshold.
export function goLiveBlockers(e: OnboardingEvidence, maxFalseAcceptance: number, now: Date): string[] {
const blockers: string[] = [];
const required: [keyof OnboardingEvidence, string][] = [
['documentAuthenticity', 'document authenticity check'],
['liveness', 'liveness check'],
['twoFactor', 'two-factor authentication'],
['explicitConsent', 'explicit consent to data processing'],
['encryption', 'encryption of personal data'],
['autoKycRecord', 'automatic KYC record'],
];
for (const [key, label] of required) if (e[key] !== true) blockers.push(`Art. 18: missing ${label}`);
if (!e.register) blockers.push('Art. 18: no pre-production performance register');
else if (e.register.falseAcceptanceRate > maxFalseAcceptance) {
blockers.push(`Art. 18: false acceptance ${e.register.falseAcceptanceRate} above ${maxFalseAcceptance}`);
}
if (!e.penTestReportRef) blockers.push('Art. 18: no penetration test by an ANCS-approved body');
if (!e.lastAuditAt) blockers.push('Art. 18: no security audit');
else if (now.getTime() >= addYears(e.lastAuditAt, 2).getTime()) blockers.push('Art. 18: audit older than two years');
else if (e.lastMaterialChangeAt && e.lastMaterialChangeAt.getTime() > e.lastAuditAt.getTime()) {
blockers.push('Art. 18: material change since the last audit');
}
return blockers;
}La fonction renvoie une liste de blocages plutôt qu'un booléen, pour qu'une chaîne de déploiement affiche exactement ce qui manque. Le seuil de fausses acceptations est un paramètre parce que la circulaire n'en fixe pas. Fixez-le dans votre politique de risque, validée par le conseil, et conservez la valeur utilisée pour chaque version.
L'article 13 est distinct et s'applique aussi : un audit annuel de sécurité de tout le système d'information par un cabinet certifié par l'ANCS, avec envoi du rapport à la BCT. Ce rapport est la déclaration RCIA250100 de l'étape suivante.
Étape 9 : le calendrier de reporting de l'annexe 1 bis
L'article 50 ajoute l'annexe 1 bis à la circulaire 2017-6. Chaque déclaration a un code, une périodicité, un délai et un format. Voici celles qui concernent le plus une équipe de tenue de comptes :
| Code | Déclaration | Périodicité | Délai | Format |
|---|---|---|---|---|
| RAM05 | Indicateurs commerciaux | Mensuelle | DR+15 j | XML |
| RAM06 | Opérations par canal, en nombre et en montant | Mensuelle | DR+15 j | XML |
| RCT03 / RCT04 | Bilan / état de résultat | Trimestrielle | DR+30 j | XML |
| RAT07 | Agences propres et agents mandatés | Trimestrielle | DR+30 j | XML |
| RROT390 | Déclaration trimestrielle des incidents | Trimestrielle | DR+30 j | XML |
| RCIA250100 | Audit annuel de sécurité du système d'information | Annuelle | DR+45 j | |
| RROI380 | Fiche préliminaire d'incident grave | En cas d'incident | Jour de l'incident | XML |
| RROI381 | Fiche de clôture d'incident grave | En cas d'incident | Date de l'incident + 10 j | non précisé |
export type Frequency = 'monthly' | 'quarterly' | 'annual';
export interface PeriodicReturn {
code: string;
frequency: Frequency;
format: 'XML' | 'PDF';
}
const rows = (frequency: Frequency, format: 'XML' | 'PDF', codes: string[]): PeriodicReturn[] =>
codes.map((code) => ({ code, frequency, format }));
// Annex 1 bis to Circular 2017-6, as added by Article 50 of Circular 2026-10.
export const ANNEX_1_BIS: PeriodicReturn[] = [
...rows('monthly', 'XML', ['RAM05', 'RAM06']),
...rows('quarterly', 'XML', ['RCT03', 'RCT04', 'RAT07', 'RST650', 'RROT390', 'RLABFTT330']),
...rows('quarterly', 'PDF', ['RGT240140', 'RGT240150']),
...rows('annual', 'XML', ['RAA10', 'RAA20', 'RGA210', 'RGA220', 'RGA230', 'RLABFTA310']),
...rows('annual', 'PDF', ['RAA783', 'RGA240009', 'RGA240020', 'RGA240030', 'RGA240050', 'RGA240190',
'RCIA250100', 'RCIA250110', 'RCIA250120', 'RCIA250130', 'RCIA250140', 'RCIA250150', 'RCIA250160',
'RLABFTA320', 'RLABFTA350']),
];
// DR+15, DR+30, DR+45: counted here in calendar days from the reference date.
export const DAYS_AFTER_DR: Record<Frequency, number> = { monthly: 15, quarterly: 30, annual: 45 };
const addDays = (isoDay: string, days: number): string =>
new Date(Date.parse(`${isoDay}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10);
const isMonthEnd = (isoDay: string): boolean => addDays(isoDay, 1).slice(8, 10) === '01';
export function frequenciesClosingOn(referenceDate: string): Frequency[] {
if (!isMonthEnd(referenceDate)) return [];
const month = referenceDate.slice(5, 7);
const out: Frequency[] = ['monthly'];
if (['03', '06', '09', '12'].includes(month)) out.push('quarterly');
if (month === '12') out.push('annual');
return out;
}
export interface Due {
code: string;
format: 'XML' | 'PDF' | null; // null: the annex leaves the cell blank
referenceDate: string;
due: string;
}
export function returnsDue(referenceDate: string): Due[] {
const closing = frequenciesClosingOn(referenceDate);
return ANNEX_1_BIS
.filter((r) => closing.includes(r.frequency))
.map((r) => ({ code: r.code, format: r.format, referenceDate, due: addDays(referenceDate, DAYS_AFTER_DR[r.frequency]) }));
}
// Serious incidents: RROI380 on the day, RROI381 on the day plus ten.
export function incidentReturns(occurredAt: Date): Due[] {
const day = tunisDay(occurredAt);
return [
{ code: 'RROI380', format: 'XML', referenceDate: day, due: day },
{ code: 'RROI381', format: null, referenceDate: day, due: addDays(day, 10) },
];
}Ce que le code suppose, et pourquoi :
- DR est la date de référence, le dernier jour de la période déclarée. L'annexe écrit DR+15j. Nous comptons en jours calendaires et ne décalons pas un délai qui tombe un week-end. Si votre lecture de la circulaire 2017-6 diffère, modifiez
addDayset les tests, pas le tableau. - L'annexe laisse vide la case du format pour RROI381, le code renvoie donc
nullau lieu de deviner. - Trois types de déclarations sont volontairement hors calendrier. RROS370 (incidents de rupture de service) est semestrielle avec un seul délai écrit « fin août ». Les trois rapports des commissaires aux comptes (RCACA150, RCACA170, RCACA260) sont dus un mois avant l'AGO : leur date dépend de votre assemblée, pas de la fin de période. Ajoutez-les à la main à votre calendrier de conformité.
En cas d'incident grave, l'article 13 impose aussi d'informer immédiatement la BCT et l'ANCS. Si l'incident expose des données personnelles, les délais de la loi organique 2004-63 courent en parallèle ; notre calculateur de délai de notification de violation les détaille.
Étape 10 : RAM06, les opérations par canal
RAM06 déclare chaque mois les opérations par canal, en nombre et en montant. L'annexe nomme la déclaration, mais la circulaire ne liste pas les canaux et ne publie pas le schéma XML, que la BCT transmet aux déclarants via son Système d'Échange des Données (SED). Le code produit donc les chiffres, et vous branchez le sérialiseur correspondant au schéma reçu.
export interface ChannelLine {
channel: string;
count: number;
amount: Millimes;
}
// RAM06: operations by channel, in number and in value, for one month (Tunis time).
export function ram06(ops: readonly Operation[], month: string /* YYYY-MM */): ChannelLine[] {
const lines = new Map<string, ChannelLine>();
for (const op of ops) {
if (tunisDay(op.executedAt).slice(0, 7) !== month) continue;
const line = lines.get(op.channel) ?? { channel: op.channel, count: 0, amount: 0 };
line.count += 1;
line.amount += op.amount;
lines.set(op.channel, line);
}
return [...lines.values()].sort((a, b) => a.channel.localeCompare(b.channel));
}Deux vérifications avant le premier envoi :
- Le mois est le mois de Tunis. Une opération à 0 h 30 le 1er février heure locale appartient à février.
- Additionnez en millimes et convertissez une seule fois. Arrondir chaque ligne en dinars puis additionner donne un total qui ne correspond pas à votre comptabilité.
Si le SED est indisponible, l'article 51 prévoit une adresse de secours pour les déclarations : reporting.EP@bct.gov.tn.
Tester votre implémentation
Les tests ci-dessous s'exécutent avec le lanceur de tests intégré à Node (node --import tsx --test payment-institution.test.ts). Chaque test porte le nom de l'article qu'il démontre.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
TND, assertCanOpen, post, append, verifyChain, mayPurge, reconcile, depositDeadline,
performance, goLiveBlockers, returnsDue, incidentReturns, ram06, RuleError,
type Account, type Operation, type OnboardingEvidence,
} from './payment-institution';
const at = (iso: string) => new Date(iso);
const acct = (kind: Account['kind'], balance = 0): Account =>
({ id: 'A1', holderId: 'H1', holderKind: 'natural', kind, balance });
const op = (type: Operation['type'], dinars: number, iso = '2026-12-28T09:00:00Z', extra: Partial<Operation> = {}): Operation =>
({ id: `${type}-${iso}-${dinars}`, accountId: 'A1', type, amount: TND(dinars), executedAt: at(iso), channel: 'app', ...extra });
const article = (fn: () => unknown) => {
try { fn(); } catch (e) { return (e as RuleError).article; }
return 'none';
};
test('Art. 17: level 1 is for natural persons only', () => {
assert.equal(article(() => assertCanOpen([], { id: 'X', holderId: 'C1', holderKind: 'legal', kind: 'level1' })), 'Art. 17');
assert.equal(assertCanOpen([], { id: 'X', holderId: 'C1', holderKind: 'legal', kind: 'level2' }).balance, 0);
});
test('Art. 21: one client account per holder, merchant account aside', () => {
const existing = [acct('level2')];
assert.equal(article(() => assertCanOpen(existing, { id: 'B', holderId: 'H1', holderKind: 'natural', kind: 'level3' })), 'Art. 21');
assert.equal(article(() => assertCanOpen(existing, { id: 'M', holderId: 'H1', holderKind: 'natural', kind: 'merchant' })), 'none');
});
test('Art. 17: balance ceilings, inclusive', () => {
assert.equal(post(acct('level1', TND(1_000)), op('transfer_in', 500), []).balance, TND(1_500));
assert.equal(article(() => post(acct('level1', TND(1_000)), op('transfer_in', 500.001), [])), 'Art. 17');
assert.equal(article(() => post(acct('level3', TND(19_999)), op('cash_deposit', 2), [])), 'Art. 17');
});
test('Art. 17: daily cash withdrawal cap counts the Tunis day', () => {
const a = acct('level2', TND(5_000));
const morning = op('cash_withdrawal', 2_000, '2026-12-28T08:00:00Z');
assert.equal(article(() => post(a, op('cash_withdrawal', 1_001, '2026-12-28T15:00:00Z'), [morning])), 'Art. 17');
assert.equal(post(a, op('cash_withdrawal', 1_000, '2026-12-28T15:00:00Z'), [morning]).balance, TND(4_000));
// 23:30 UTC on the 28th is 00:30 on the 29th in Tunis: a new day.
assert.equal(post(a, op('cash_withdrawal', 3_000, '2026-12-28T23:30:00Z'), [morning]).balance, TND(2_000));
// A transfer out is not a cash withdrawal.
assert.equal(post(a, op('transfer_out', 4_000), [morning]).balance, TND(1_000));
});
test('Art. 22: never overdrawn', () => {
assert.equal(article(() => post(acct('level3', TND(100)), op('payment', 100.001), [])), 'Art. 22');
});
test('Art. 23: merchant account takes accepted payments only, no ceiling', () => {
assert.equal(post(acct('merchant', TND(50_000)), op('merchant_receipt', 10_000), []).balance, TND(60_000));
assert.equal(article(() => post(acct('merchant'), op('transfer_in', 10), [])), 'Art. 23');
assert.equal(article(() => post(acct('level2'), op('merchant_receipt', 10), [])), 'Art. 23');
});
test('Art. 3: per-operation caps and justification', () => {
const a = acct('level3');
assert.equal(article(() => post(a, op('cash_transfer', 3_000.001, undefined, { justificationRef: 'J1' }), [])), 'Art. 3');
assert.equal(article(() => post(a, op('cash_transfer', 100), [])), 'Art. 3');
assert.equal(post(a, op('foreign_receipt', 20_000, undefined, { justificationRef: 'SWIFT-1' }), []).balance, TND(20_000));
assert.equal(article(() => post(a, op('foreign_receipt', 20_000.001, undefined, { justificationRef: 'S' }), [])), 'Art. 3');
});
test('Ledger: millimes are whole numbers', () => {
assert.equal(article(() => post(acct('level2'), { ...op('transfer_in', 1), amount: 1.5 }, [])), 'Ledger');
});
test('Art. 16: hash chain and ten-year retention', () => {
const r1 = append([], op('transfer_in', 10, '2026-12-28T09:00:00Z'), TND(10));
const r2 = append([r1], op('payment', 4, '2026-12-29T09:00:00Z'), TND(6));
assert.equal(verifyChain([r1, r2]), null);
assert.equal(verifyChain([r1, { ...r2, balanceAfter: TND(7) }]), 2);
assert.equal(r1.retainUntil.toISOString(), '2036-12-28T09:00:00.000Z');
assert.equal(mayPurge(r1, at('2036-12-28T08:59:59Z')), false);
assert.equal(mayPurge(r1, at('2036-12-28T09:00:00Z')), true);
});
test('Arts 24-26: reconciliation and next working day', () => {
const accounts = [acct('level1', TND(1_200)), { ...acct('merchant', TND(8_800)), id: 'M1' }];
assert.deepEqual(reconcile(TND(10_000), accounts), { globalBalance: 10_000_000, sumOfAccounts: 10_000_000, difference: 0, ok: true });
assert.equal(reconcile(TND(10_015), accounts).difference, TND(15));
const none = new Set<string>();
assert.equal(depositDeadline(at('2026-12-31T10:00:00Z'), none), '2027-01-01');
assert.equal(depositDeadline(at('2026-12-31T10:00:00Z'), new Set(['2027-01-01'])), '2027-01-04');
// Friday 23:30 UTC is Saturday in Tunis: next working day is Monday.
assert.equal(depositDeadline(at('2027-01-08T23:30:00Z'), none), '2027-01-11');
});
test('Art. 18: performance register rates', () => {
const trials = [
...Array.from({ length: 98 }, () => ({ genuine: true, accepted: true })),
...Array.from({ length: 2 }, () => ({ genuine: true, accepted: false })),
...Array.from({ length: 199 }, () => ({ genuine: false, accepted: false })),
{ genuine: false, accepted: true },
];
const r = performance(trials);
assert.equal(r.falseAcceptanceRate, 0.005);
assert.equal(r.falseRejectionRate, 0.02);
assert.equal(r.reliability, 0.99);
assert.throws(() => performance([{ genuine: true, accepted: true }]), RuleError);
});
test('Art. 18: go-live gate', () => {
const ready: OnboardingEvidence = {
documentAuthenticity: true, liveness: true, twoFactor: true, explicitConsent: true, encryption: true, autoKycRecord: true,
register: { trials: 300, falseAcceptanceRate: 0.005, falseRejectionRate: 0.02, reliability: 0.99 },
penTestReportRef: 'PT-2026-11', lastAuditAt: at('2026-11-15T00:00:00Z'),
};
const now = at('2026-12-20T00:00:00Z');
assert.deepEqual(goLiveBlockers(ready, 0.01, now), []);
assert.equal(goLiveBlockers(ready, 0.001, now).length, 1);
assert.match(goLiveBlockers({ ...ready, liveness: false }, 0.01, now)[0], /liveness/);
assert.match(goLiveBlockers(ready, 0.01, at('2028-11-15T00:00:00Z'))[0], /two years/);
assert.match(goLiveBlockers({ ...ready, lastMaterialChangeAt: at('2026-12-01T00:00:00Z') }, 0.01, now)[0], /material change/);
});
test('Annex 1 bis: deadlines by reference date', () => {
assert.deepEqual(returnsDue('2026-12-15'), []);
const jan = returnsDue('2027-01-31');
assert.deepEqual(jan.map((d) => d.code), ['RAM05', 'RAM06']);
assert.equal(jan[0].due, '2027-02-15');
const feb = returnsDue('2027-02-28');
assert.equal(feb[0].due, '2027-03-15');
const mar = returnsDue('2027-03-31');
assert.equal(mar.length, 10);
assert.equal(mar.find((d) => d.code === 'RCT03')?.due, '2027-04-30');
const dec = returnsDue('2027-12-31');
assert.equal(dec.length, 2 + 8 + 21);
assert.equal(dec.find((d) => d.code === 'RCIA250100')?.due, '2028-02-14');
});
test('Incidents: same day and day plus ten, Tunis time', () => {
const [now, closing] = incidentReturns(at('2027-02-03T23:15:00Z'));
assert.equal(now.due, '2027-02-04');
assert.equal(closing.due, '2027-02-14');
assert.equal(closing.format, null);
});
test('RAM06: count and value by channel for the Tunis month', () => {
const ops = [
op('payment', 10, '2027-01-05T09:00:00Z', { channel: 'app' }),
op('payment', 2.5, '2027-01-06T09:00:00Z', { channel: 'app' }),
op('cash_deposit', 100, '2027-01-07T09:00:00Z', { channel: 'agent' }),
op('payment', 1, '2026-12-31T23:30:00Z', { channel: 'app' }), // 00:30 on 1 Jan in Tunis
op('payment', 7, '2027-01-31T23:30:00Z', { channel: 'app' }), // 00:30 on 1 Feb in Tunis
];
assert.deepEqual(ram06(ops, '2027-01'), [
{ channel: 'agent', count: 1, amount: 100_000 },
{ channel: 'app', count: 3, amount: 13_500 },
]);
});Les tests à garder dans votre intégration continue, quoi que vous changiez par ailleurs, sont les tests de limite : un solde d'exactement 1 500,000 dinars est accepté et 1 500,001 est refusé, un retrait en espèces à 0 h 30 heure de Tunis compte pour le nouveau jour, et un montant modifié casse la chaîne du registre au bon numéro de séquence.
Dépannage
Un client est bloqué pour un virement après un retrait en espèces. Votre moteur additionne encore tous les débits dans la limite journalière, comme la circulaire 2018-16. Depuis la 2026-10, seuls les retraits en espèces comptent.
Le rapprochement est faux exactement du montant des frais du mois. Les commissions sont restées sur le compte global. Virez-les sur le compte propre de l'établissement ; l'article 24 interdit de les y comptabiliser.
Des retraits proches de minuit sont bloqués ou autorisés le mauvais jour. Le jour est calculé en UTC. Utilisez tunisDay.
La chaîne du registre casse après une migration. Un champ a été reformaté (par exemple des dates écrites sans millisecondes). Calculez l'empreinte sur une forme canonique, comme digest le fait avec toISOString, et corrigez en ajoutant des entrées, jamais en réécrivant les anciennes.
L'audit d'enrôlement est « valide » mais la version a été bloquée. Une modification importante (nouveau fournisseur de preuve de vie, nouvelle version de SDK) a été enregistrée après le dernier audit. L'article 18 exige un nouvel audit après une telle modification, pas seulement tous les deux ans.
Pour aller plus loin
- Lisez ce que change la circulaire 2026-10 pour les établissements de paiement pour la synthèse côté direction : gouvernance, partenariats, produits et comparaison avec la 2018-16.
- Le même schéma de registres et de déclarations codées se retrouve dans la circulaire 2026-08 sur les plateformes de crédit sur l'honneur, avec horodatage et envoi par le SED.
- Ajoutez les autres déclarations de l'annexe 1 bis (gouvernance, LBA/FT et rapports d'audit) au même calendrier, pour qu'une seule liste pilote tous les rappels.
Conclusion
La circulaire 2026-10 donne plus de marge aux établissements de paiement : des plafonds plus hauts, l'enrôlement à distance à tous les niveaux et les comptes commerçants. Elle demande aussi des preuves. Le système de comptes doit montrer que le plafond a tenu, le registre que rien n'a changé, le compte global qu'il correspond au millime près, et le procédé d'enrôlement doit venir avec un registre qui mesure combien de fois il laisse entrer la mauvaise personne. Tout cela tient dans un module dont les tests portent le nom des articles, écrit avant fin décembre plutôt qu'après la première inspection.
Si vous dirigez un établissement de paiement ou en construisez un, et souhaitez une lecture indépendante de votre système de comptes, de votre parcours d'enrôlement et de votre chaîne de reporting au regard de la circulaire avant son entrée en vigueur, demandez-nous un diagnostic. Nous vérifions le code et les flux de données par rapport au texte, article par article. Pour le travail d'intégration lui-même, voyez nos services en Tunisie.