Central Bank of Tunisia Circular No. 2026-10 of 25 September 2026 repeals Circular 2018-16 and rewrites the rules for every Tunisian payment institution. It enters into force three months after publication (Article 52), which puts it at the end of December 2026. Most of the press coverage was about the new account ceilings. For the team that runs the ledger, the ceilings are the easy part. The circular also changes what is capped, adds a merchant account that escapes the caps, requires a global account that matches the sum of all balances at any moment, puts a measurable test register in front of remote onboarding, and adds a reporting annex with more than thirty coded returns.
This tutorial turns those articles into one TypeScript module with tests. Every rule is tied to the article it comes from, so a compliance officer can read the code and a developer can read the circular.
What You'll Build
A single module, payment-institution.ts, that:
- Enforces the account levels of Article 17 and the opening rules of Articles 17 and 21.
- Posts operations under the balance ceilings, the daily cash withdrawal caps, the no-overdraft rule of Article 22 and the merchant account rules of Article 23.
- Applies the per-operation caps of Article 3 and refuses an unjustified capped operation.
- Keeps a hash-chained register with the ten-year retention of Article 16.
- Reconciles the global account against all balances (Articles 24 to 26) and computes the deposit deadline.
- Measures the remote onboarding process in a performance register with a false acceptance rate (Article 18) and blocks go-live when evidence is missing.
- Computes the Annex 1 bis reporting calendar, the serious-incident returns, and the RAM06 statement by channel.
Prerequisites
- Node.js 20 or later and TypeScript 5 in strict mode
- Working knowledge of a double-entry or account-based ledger
- The circular itself, open next to you: Circular No. 2026-10 on the BCT website. Article numbers below follow the BCT version, which corrected a numbering error in the copy circulated by the press on 25 September.
Where These Obligations Come From
| Rule | Article | What the code does |
|---|---|---|
| Three client account levels, balance and cash withdrawal ceilings | 17 | LIMITS, post |
| One client account per person | 21 | assertCanOpen |
| No credit, never a debit balance | 22 | post |
| Merchant accounts outside the ceilings, credited only by accepted payments | 23 | post |
| Cash-funded transfers at 3,000 TND, foreign receipts at 20,000 TND, both justified | 3 | assertOperationCaps |
| Registers kept at least ten years | 16 | append, retainUntil |
| Global account at a bank by the next working day, equal to all balances, reconciled | 24, 25, 26 | reconcile, depositDeadline |
| Remote onboarding, performance register, pen test, audit every two years | 18 | performance, goLiveBlockers |
| Reporting annex | 50 and Annex 1 bis | returnsDue, incidentReturns, ram06 |
Three readings in this tutorial are ours and are flagged where they appear: how the reliability rate is computed, how DR+N deadlines are counted, and what happens to a credit that would break a ceiling.
Step 1: Money in Millimes, Accounts by Level
The dinar has three decimals. Floating-point dinars produce a ceiling check that fails on 1,500.000 because the sum was 1,500.0000000002. Store every amount as a whole number of millimes and convert once, at the edge.
// 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}`);
}
}Two details from Article 17 that are easy to get wrong:
- Level 1 has no daily cash withdrawal cap in the text. Its only ceiling is the 1,500 dinar balance. Do not copy the level 2 cap across.
- The 2018 text capped total outflows per day (250, 500 and 1,000 dinars). The 2026 text caps cash withdrawals only: 3,000 dinars a day at level 2 and 10,000 at level 3. A transfer or a card payment no longer counts toward a daily limit. If your current engine sums all debits, it is now stricter than the regulation, and your clients will notice.
Step 2: Opening Rules
Article 17 reserves level 1 for natural persons. Levels 2 and 3 are open to natural and legal persons. Article 21 forbids more than one client payment account per person.
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 };
}We read Article 21 as covering client payment accounts, so a merchant can hold both a client account and a merchant account. The merchant account is a distinct category in Article 23, with its own identification form (Annex 4) and its own convention. If your legal team reads it more strictly, change isClient and the test that covers it.
A practical consequence of Article 21: upgrading a client from level 1 to level 2 is a change of level on the same account, not a second account. Model the upgrade as an update with a new identification form (Annexes 1, 2 and 3 of the circular differ by level), never as an opening.
Step 3: Operation Types and the Article 3 Caps
Article 3 caps two kinds of operation, each per operation:
- Transfers funded by handing over cash: 3,000 dinars.
- Funds received from abroad and paid out: the counter-value of 20,000 dinars.
Both must be "duly justified". The engine treats a missing justification reference as a refusal, not a warning.
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`);
}
}The channel field is not a regulatory requirement on the operation itself. It is there because the RAM06 return in Step 10 reports operations by channel, and the cheapest moment to record the channel is when the operation is created.
Step 4: Posting Under the Ceilings
This is the core of the ledger. A credit checks the balance ceiling and the merchant account rules. A debit checks the no-overdraft rule and, for cash withdrawals, the daily cap.
// 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 };
}Three decisions are encoded here.
The day is the Tunis day. A withdrawal at 00:30 local time belongs to the new day, even though it is still the previous day in UTC. Tunisia has been on UTC+1 all year since 2009, so a fixed offset is correct. If your servers run in UTC and you group by toISOString().slice(0, 10), a client who withdraws at 23:45 and again at 00:15 is blocked for a day they did not use.
A credit that would break the ceiling is refused. The circular caps the balance. It does not say what to do with an incoming transfer that would exceed it. Refusing it is our choice, because the alternative (accepting it and holding the excess somewhere) creates a balance that exists outside the account, which Article 25 makes hard to defend. Whatever you choose, make it explicit and tell the sender.
The merchant account is a one-way door. Article 23 says it is credited exclusively by the flows from payments the merchant accepted. A client transfer into it is refused. A merchant receipt into a client account is also refused, so receipts cannot be used to push a client account past its ceiling.
Step 5: The Register, Ten Years and Tamper Evidence
Article 16 requires registers of every operation listed in Article 2, kept for at least ten years from the date of execution. Article 12 adds traceability of every operation and real-time recording, including across the agent network. A hash chain gives you both: every entry commits to the one before it, so a changed amount breaks the chain from that point on.
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();Notes for production:
- Write the register entry in the same transaction as the balance change. A balance that moved without a register entry is exactly the gap an examiner looks for.
retainUntilis a floor, not a deletion date. Personal data rules may require you to keep less for some fields and AML rules may require more for others. Purge only when every applicable rule allows it.- An agent's terminal that queues operations offline breaks "real time" under Article 12. If your agents work offline, record the time of execution and the time of recording separately, and report the gap.
Step 6: The Global Account and Daily Reconciliation
Client and merchant funds do not belong to the institution. Article 24 requires them to be deposited in a single global account at a bank, no later than the working day after receipt. Commissions must not be booked to that account. Article 25 requires its balance to equal, at any time, the sum of all client and merchant balances. Article 26 requires a regular, documented reconciliation.
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);
}How to read a non-zero difference:
- Positive (more money at the bank than owed to holders): usually commissions that were left in the global account instead of being moved to the institution's own account. That is a breach of Article 24 even though nobody lost money.
- Negative (less at the bank than owed): funds received but not yet deposited, which is acceptable only until the deadline from
depositDeadline, or a real shortfall.
Public holidays are an input because the religious ones move with the lunar calendar. Load them every year from an official source rather than hard-coding them.
Step 7: The Remote Onboarding Performance Register
Article 18 opens remote onboarding at every level, provided the process verifies identity at least as well as an in-person check. Before going live, the process must be tested in pre-production and the results kept in a performance register that measures the reliability of the process, including the false acceptance rate.
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,
};
}The circular names the false acceptance rate but does not define reliability. We report three numbers: false acceptance (impostors let in), false rejection (genuine clients turned away) and the share of correct decisions. Keep all three in the register. A process tuned only for false acceptance can reject so many genuine clients that the remote channel stops working.
The test set matters more than the formula. Impostor trials should include printed photos, screen replays, documents belonging to someone else and expired documents. Keep the trial set versioned so the next audit can re-run it.
Step 8: The Go-Live Gate
Article 18 lists what the process must do (document checks, liveness, two-factor authentication, explicit consent, encryption, an automatically generated KYC record) and what must happen around it: a penetration test and a security audit by bodies approved by the National Cybersecurity Agency (ANCS), then an audit at least every two years and after any regulatory or technological change that could affect the process.
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;
}The function returns a list of blockers instead of a boolean, so a release pipeline can print exactly what is missing. The false acceptance threshold is a parameter because the circular does not set one. Set it in your risk policy, approved by the board, and keep the value used for each release.
Article 13 is separate and also applies: an annual security audit of the whole information system by an ANCS-certified firm, with the report sent to the BCT. That report is the RCIA250100 return in the next step.
Step 9: The Annex 1 bis Reporting Calendar
Article 50 adds Annex 1 bis to Circular 2017-6. Each return has a code, a frequency, a deadline and a format. Here are the ones a ledger team touches most:
| Code | Return | Frequency | Deadline | Format |
|---|---|---|---|---|
| RAM05 | Commercial indicators | Monthly | DR+15 days | XML |
| RAM06 | Operations by channel, count and value | Monthly | DR+15 days | XML |
| RCT03 / RCT04 | Balance sheet / income statement | Quarterly | DR+30 days | XML |
| RAT07 | Own branches and appointed agents | Quarterly | DR+30 days | XML |
| RROT390 | Quarterly incident statement | Quarterly | DR+30 days | XML |
| RCIA250100 | Annual information system security audit | Annual | DR+45 days | |
| RROI380 | Preliminary report of a serious incident | On incident | Day of the incident | XML |
| RROI381 | Closing report of a serious incident | On incident | Incident date + 10 days | not stated |
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) },
];
}What the code assumes, and why:
- DR is the reference date, the last day of the period being reported. The annex writes DR+15j. We count calendar days and do not move a deadline that falls on a weekend. If your reading of Circular 2017-6 differs, change
addDaysand the tests, not the table. - The annex leaves the format cell blank for RROI381, so the code returns
nullrather than guessing. - Three kinds of return are left out of the calendar on purpose. RROS370 (service disruption incidents) is semi-annual with a single deadline written as "end of August". The three statutory auditor reports (RCACA150, RCACA170, RCACA260) are due one month before the general meeting, so their date depends on your meeting, not on the period end. Add them by hand to your compliance calendar.
When a serious incident happens, Article 13 also requires you to inform the BCT and ANCS immediately. If the incident exposes personal data, the deadlines under Organic Law 2004-63 run in parallel; our breach notification deadline calculator lays them out.
Step 10: RAM06, Operations by Channel
RAM06 reports operations by channel, in number and in value, every month. The annex names the return but the circular does not list the channels or publish the XML schema, which the BCT distributes to reporting institutions through its data exchange system (SED). So the code produces the figures, and you plug in the serializer for the schema you receive.
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));
}Two things to check before the first filing:
- The month is the Tunis month. An operation at 00:30 on 1 February local time belongs to February.
- Sum in millimes and convert once. Rounding each line to dinars and then adding the lines gives a total that does not match your accounts.
If the SED is unavailable, Article 51 gives a fallback address for the returns: reporting.EP@bct.gov.tn.
Testing Your Implementation
The tests below run with Node's built-in test runner (node --import tsx --test payment-institution.test.ts). Each test is named after the article it proves.
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 },
]);
});The tests to keep in your CI no matter what else you change are the boundary tests: a balance of exactly 1,500.000 dinars is accepted and 1,500.001 is refused, a cash withdrawal at 00:30 Tunis time counts toward the new day, and a changed amount breaks the register chain at the right sequence number.
Troubleshooting
A client is blocked from a transfer after withdrawing cash. Your engine still sums all debits against the daily limit, as Circular 2018-16 did. Since 2026-10, only cash withdrawals count.
The reconciliation is off by exactly the month's fees. Commissions were left in the global account. Move them to the institution's own account; Article 24 forbids booking them in the global account.
Withdrawals near midnight are blocked or allowed on the wrong day. The day is computed in UTC. Use tunisDay.
The register chain breaks after a migration. A field was reformatted (for example dates written without milliseconds). Hash a canonical form, as digest does with toISOString, and migrate by appending correcting entries, never by rewriting old ones.
The onboarding audit is "valid" but the release was blocked. A material change (a new liveness vendor, a new SDK version) was recorded after the last audit. Article 18 requires a new audit after such a change, not only every two years.
Next Steps
- Read what Circular 2026-10 changes for payment institutions for the decision-level summary: governance, partnerships, products and the comparison with 2018-16.
- The same pattern of registers and coded returns appears in Circular 2026-08 on honour-loan platforms, with timestamps and SED filing.
- Add the remaining returns of Annex 1 bis (governance, AML and audit reports) to the same calendar so one list drives every reminder.
Conclusion
Circular 2026-10 gives payment institutions more room, with higher ceilings, remote onboarding at every level and merchant accounts. It also asks for proof. The ledger has to show the ceiling held, the register has to show nothing changed, the global account has to match to the millime, and the onboarding process has to come with a register that measures how often it lets the wrong person in. All of that fits in one module with tests named after the articles, written before the end of December rather than after the first inspection.
If you run a payment institution or are building one, and want an independent read of your ledger, onboarding flow and reporting pipeline against the circular before it takes effect, ask us for a diagnostic. We check the code and the data flows against the text, article by article. For the integration work itself, see our services in Tunisia.