On 1 October 2026, every Tunisian bank subject to Decree 148 of 2026 will have exactly one door through which honour-loan and micro-financing applications may arrive: its own electronic platform. That is the decision of Central Bank of Tunisia Circular to Banks No. 2026-08, dated 1 September 2026 — a short text of seven articles and three annexes that is, in practice, a complete technical specification.
The sentence that summarises the whole circular sits at the end of Article 3: no application submitted by any means other than the dedicated electronic platform is taken into account. A paper application handed over a branch counter is no longer an incomplete application; legally, it does not exist. Which means the platform is not a sign-up form. It is the official register from which the decision deadline is counted and from which priority is established.
This guide is for the engineering team that has to ship that platform before 1 October. The applicant-facing side — who qualifies, which ceiling applies, which documents to attach — is covered in Honour Loan Conditions and Documents, and what the circular changed in BCT Circular 2026-08.
What You'll Build
A single TypeScript service covering the four obligations the circular generates:
- A deposit gateway that electronically timestamps every application and issues an automatic receipt leaving a written trace.
- A priority-ordering engine driven by the timestamp's date and time rather than by an officer's data entry, with the ten-day clock counted from that same instant.
- A bridge to the BCT credit information registry: a mandatory check before disbursement, and a real-time declaration at disbursement using the Annex 1 codes.
- A monthly statement generator in the Annex 2 and Annex 3 templates, filed through the data-exchange system within fifteen days of month-end.
Prerequisites
- Node.js 20 or later and TypeScript 5.
- A transactional database (the snippets below target PostgreSQL).
- Access to the bank's timestamping service, and access to the credit-registry and data-exchange channels your institution uses.
- Familiarity with Decree 148 of 2026: the three applicant classes and their ceilings, repayment within two years at most, and a grace period not exceeding six months.
A note on scope. The circular names neither a technical standard for timestamping nor a file format for the data-exchange system. What we propose here (RFC 3161 for the stamp, statement generation from a single source) is an engineering choice consistent with the text, not a prescription in the circular. Always adopt the formats the Central Bank approves for your channel.
Where These Obligations Come From
Before writing a line, it helps to attach each component to the article that generates it. This table is the whole project map:
| Article | Obligation | What it becomes in code |
|---|---|---|
| Article 3 | Deposit exclusively via the platform, electronic timestamp, automatic receipt | Single deposit gateway + stamp table + traced notification |
| Article 3 | Priority ordering and the deadline counted from the stamp | Index on stamp time + deadline timer |
| Article 4 | Consulting the applicant's exposures before disbursement | Blocking check inside the disbursement path |
| Article 4 | Real-time declaration at disbursement under Annex 1 codes | Outbox pattern with retry |
| Article 5 | Two monthly statements via the data-exchange system within 15 days | Annex 2 and 3 generator + scheduler |
| Article 7 | Entry into force on 1 October 2026 | The delivery date |
Note that Article 2 keeps these loans subject to the bank's internal policies and to the rules on governance, internal control and classification of exposures. The platform does not create a circuit parallel to the banking system; it introduces a new category inside it.
Step 1: Model the Application — the Timestamp Is the Record
The first and costliest design mistake is treating the stamp time as an ordinary date field. Article 3 makes that time the thing which proves the date and hour of deposit, which allows applications to be ordered by priority, and from which the deadline runs. It is evidence, not metadata.
Evidence means two things: the binary token is stored exactly as the service returned it, and it is never derived from the server clock.
// src/domain/application.ts
export type ApplicantClass = 'individual' | 'small_project' | 'sme_or_community_company';
export interface TimestampToken {
/** رمز الختم كما ورد من خدمة ختم التوقيت، محفوظًا كما هو */
readonly token: Buffer;
/** التوقيت المستخرج من الرمز — لا من ساعة الخادم */
readonly genTime: Date;
readonly authority: string;
readonly serial: string;
}
export interface LoanApplication {
readonly id: string;
readonly applicantId: string;
readonly applicantClass: ApplicantClass;
/** المبلغ بالمليم، لتفادي حساب الفاصلة العائمة */
readonly amountMillimes: number;
readonly governorateCode: string;
readonly stamp: TimestampToken;
readonly receiptRef: string;
status: 'submitted' | 'under_review' | 'approved' | 'rejected' | 'disbursed';
}Ceilings belong in the model, not in the interface, because interfaces get bypassed:
const CEILING_MILLIMES: Record<ApplicantClass, number> = {
individual: 5_000_000, // خمسة آلاف دينار
small_project: 10_000_000, // عشرة آلاف دينار
sme_or_community_company: 25_000_000 // خمسة وعشرون ألف دينار
};
export function assertWithinCeiling(app: LoanApplication): void {
const ceiling = CEILING_MILLIMES[app.applicantClass];
if (app.amountMillimes > ceiling) {
throw new DomainError('CEILING_EXCEEDED', {
requested: app.amountMillimes,
ceiling,
applicantClass: app.applicantClass
});
}
}Step 2: One Door, and the Receipt Is Part of the Transaction
Article 3 requires the platform to issue the receipt upon deposit and automatically. "Automatically" means the receipt is neither a deferred job in a queue nor a message an officer sends later.
In practice: if the deposit succeeds and the receipt never leaves, the bank cannot prove it informed the applicant. If the receipt leaves and the transaction then fails, you have handed the applicant proof of an application that does not exist. The fix is to write the receipt inside the transaction itself and dispatch it immediately after commit, keeping the written trace.
// src/api/submit.ts
export async function submitApplication(input: SubmitInput): Promise<Receipt> {
assertWithinCeiling(toApplication(input));
// 1) الختم أولًا: التوقيت هو ما سيُرتّب المطلب ويحتسب منه الأجل
const stamp = await timestampService.stamp(canonicalDigest(input));
return db.transaction(async (tx) => {
const application = await tx.applications.insert({ ...input, stamp });
const receipt = await tx.receipts.insert({
applicationId: application.id,
reference: buildReceiptReference(application, stamp),
depositedAt: stamp.genTime,
channel: input.contactChannel,
body: renderReceiptText(application, stamp)
});
// إشعار مؤجّل داخل نفس المعاملة: لا يُرسل إلا إذا التزمت
await tx.outbox.insert({ topic: 'receipt.deliver', payload: { receiptId: receipt.id } });
return receipt;
});
}The receipt text must carry the deposit date and time as attested by the stamp. Add a reference the applicant can quote; an internal identifier they never see is worth nothing to them.
As for the "one door", it is enforced architecturally rather than by intention: every other entry path — file import, branch data entry, an internal API — must route through the same function or be closed. An application that comes in through another door is one the text does not take into account, and its presence in your database creates an obligation with nothing behind it.
Step 3: Priority Ordering and the Ten-Day Clock
Priority is computed from genTime alone. When two instants tie — entirely predictable in the first seconds after the platform opens — the tie-break must be deterministic and explainable to an examiner, never random.
export function byPriority(a: LoanApplication, b: LoanApplication): number {
const t = a.stamp.genTime.getTime() - b.stamp.genTime.getTime();
if (t !== 0) return t;
// فاصل حتمي عند التساوي: الرقم التسلسلي للختم من نفس السلطة
return a.stamp.serial.localeCompare(b.stamp.serial);
}The decision deadline — ten banking business days under Article 6 of Decree 148 of 2026 — runs from the stamp. It is a business-day deadline, not a calendar one, which means the calculation needs a banking-holiday calendar:
export function decisionDeadline(stampedAt: Date, calendar: BankingCalendar): Date {
let cursor = new Date(stampedAt);
let remaining = 10;
while (remaining > 0) {
cursor = addDays(cursor, 1);
if (calendar.isBankingDay(cursor)) remaining -= 1;
}
return cursor;
}Do not hard-code the holiday calendar. Religious holidays in Tunisia move every year, and non-working days can be added by decision. Make
BankingCalendara table you update, and record with each application which calendar version its deadline was computed against, so the result is still reproducible a year later.
Step 4: The Pre-Disbursement Credit-Registry Check
Article 4 does not make this check a matter of good practice: before disbursing, the bank must consult the applicant's exposures at the BCT credit information registry to verify that they do not already hold a loan or financing of the same category that has not been repaid in full.
Three points of drafting translate straight into code:
- The check is tied to the moment of disbursement, not approval. Approval can precede disbursement by days, and the applicant may borrow elsewhere in between.
- The condition is "the same category", so the check compares against the Annex 1 codes, not against the applicant's total indebtedness.
- "Not repaid in full" means any remaining balance, however small, blocks the new financing.
const HONOUR_LOAN_CODES = ['260', '261', '185', '3400'] as const;
export async function assertNoOutstandingSameCategory(
applicantId: string,
category: (typeof HONOUR_LOAN_CODES)[number]
): Promise<void> {
const exposures = await centraleClient.getExposures(applicantId);
const blocking = exposures.filter(
(e) => e.kfcred === category && e.outstandingMillimes > 0
);
if (blocking.length > 0) {
throw new DomainError('OUTSTANDING_SAME_CATEGORY', { category, blocking });
}
}Make this a lock inside the disbursement path itself, not a screen an officer looks at. And store the full response with its own timestamp: under examination the question will not be "did you check?" but "what did the answer say at the moment of disbursement?".
Step 5: The Real-Time Declaration at Disbursement
Article 4 requires declaring to the credit information registry in real time, at the moment the loan or financing is disbursed, using the Annex 1 codes:
| KFCRED code | Label |
|---|---|
| 260 | Short-term honour loan (decree 2026-148) |
| 261 | Short-term honour financing (decree 2026-148) |
| 185 | Principal arrears on a short-term honour loan |
| 3400 | Honour loan to individuals (decree 2026-148) |
The split between 260 and 261 is the split between a loan and a financing — that is, between the conventional form and the Islamic-finance form the circular cites explicitly in its recitals. Code 185 is not a category at origination but a later state: principal in arrears. Anyone who freezes their code table at disbursement, forgetting that 185 appears later in the life cycle, will discover this at the first default.
"Real time" does not mean "overnight". But it also does not mean a synchronous call that fails the disbursement when the channel goes down. The correct pattern is an outbox: the declaration message is written in the disbursement transaction, and an independent worker ships it immediately with backoff on retry.
export async function disburse(applicationId: string): Promise<void> {
const app = await repo.load(applicationId);
await assertNoOutstandingSameCategory(app.applicantId, categoryOf(app));
await db.transaction(async (tx) => {
await tx.applications.update(app.id, { status: 'disbursed', disbursedAt: new Date() });
await tx.ledger.recordDisbursement(app);
await tx.outbox.insert({
topic: 'centrale.declare',
payload: { applicationId: app.id, kfcred: categoryOf(app) },
availableAt: new Date() // فورًا
});
});
}The practical value of the outbox is that it makes "was every disbursed loan declared?" answerable with one query: any disbursement without a delivered message becomes a visible gap rather than a silent failure.
Step 6: Annex 2 — the Monthly Statement Ventilated by Governorate
Annex 2 asks for a statement of the volume of loans and financings granted from the resources of the honour-financing line account, broken down by governorate, showing collected and uncollected amounts. Each governorate takes a row carrying the count and amount for each beneficiary class — individuals, small project, small or medium enterprise, community company — then total loans granted, the governorate's share of amounts granted as a percentage, then the collected and uncollected amounts and their ratio.
The annex defines the two classes numerically itself: a small project is one whose cumulative investment does not exceed one hundred and fifty thousand dinars including working capital; a small or medium enterprise is one whose investments fall between one hundred and fifty thousand dinars and fifteen million dinars. Classify the beneficiary once, at origination, and store the class with the operation. A company that grows between two months must not rewrite a past month's statement.
The annex's unit is thousands of dinars, while your system holds millimes. This is where most implementations fail: each row is rounded separately, and the sum of the rows no longer lands on the total row.
const MILLIMES_PER_THOUSAND_DINARS = 1_000_000;
function toThousandDinars(millimes: number): number {
return Math.round((millimes / MILLIMES_PER_THOUSAND_DINARS) * 1000) / 1000;
}
export function buildAnnex2(rows: DisbursementRow[], period: Period): Annex2 {
const byGovernorate = new Map<string, Annex2Row>();
for (const row of rows) {
const g = byGovernorate.get(row.governorateCode) ?? emptyRow(row.governorateCode);
const bucket = g.classes[row.beneficiaryClass];
bucket.count += 1;
bucket.millimes += row.principalMillimes;
g.collectedMillimes += row.collectedMillimes;
byGovernorate.set(row.governorateCode, g);
}
const totalMillimes = sum([...byGovernorate.values()].map(totalOf));
return {
period,
rows: [...byGovernorate.values()].map((g) => ({
...g,
sharePercent: totalMillimes === 0 ? 0 : round2((totalOf(g) / totalMillimes) * 100),
uncollectedMillimes: totalOf(g) - g.collectedMillimes
})),
total: { millimes: totalMillimes, thousandDinars: toThousandDinars(totalMillimes) }
};
}The rule: add in millimes, round once at presentation, and compute percentages from the original amounts, never from rounded ones. Emit all twenty-four governorates, including those with no lending at all: a missing row reads as missing data, a zero row reads as information.
Step 7: Annex 3 — the Financing-Line Account Statement
Annex 3 has two parts. The first holds the account data: the date the ordinary general meeting of shareholders was held, the approved net accounting result, the volume of funds allocated to the financing line — which the annex explicitly describes as 8 percent of the accounting result — then the account opening date and the date the funds were credited.
These data do not change month to month, yet they are declared every month. Read them from a single authoritative source tied to the general meeting's decision, not from a manual entry repeated twelve times a year. A value re-keyed every month will eventually disagree with itself.
The second part is the statement of operations recorded on the account to month-end: opening balance (1), less drawdowns for the disbursement of loans and financings (2), giving the closing balance (3) = (1) − (2).
Watch an apparent divergence between the article and the annex. Article 5 asks for an inventory of all operations recorded on both the credit and debit sides, while the Annex 3 template shows only the opening balance, the drawdowns and the closing balance. Do not drop the credit side from your data model because the table has no column for it: keep the full inventory at the source and make the template a derived view. The day the detail is asked for — and the article's wording anticipates it — you will be able to produce it without a historical reconstruction.
The rule generalises: declare what the template asks for, keep what the text asks for.
Step 8: Filing Through the Data-Exchange System Within Fifteen Days
Both statements go to the Central Bank through the data-exchange system, within a maximum of fifteen days from the end of the month being declared. That is a short window once you account for a monthly close that may not finish before the tenth.
Make generation replayable and deterministic: the same month and the same data produce the same file, byte for byte. Run it early for review, and file it after the close.
// src/jobs/monthly-declaration.ts
export async function runMonthlyDeclaration(period: Period): Promise<void> {
const deadline = addDays(endOfMonth(period), 15);
const annex2 = buildAnnex2(await repo.disbursementsFor(period), period);
const annex3 = buildAnnex3(await repo.creditLineAccountFor(period), period);
const bundle = serializeForDataExchange({ annex2, annex3, period });
const digest = sha256(bundle);
await repo.declarations.upsert({
period,
digest,
deadline,
generatedAt: new Date(),
status: 'ready'
});
}Store a digest of every filing. When a correction comes later, the difference between two digests is what explains to an examiner what changed and why — far cheaper than reconstructing the month from memory.
Testing Your Implementation
Five tests cover the places where this kind of system actually breaks:
describe('امتثال المنشور عدد 8 لسنة 2026', () => {
it('يرتّب حسب توقيت الختم لا حسب توقيت الإدراج', async () => {
const late = await submit({ ref: 'A', stampedAt: '2026-10-01T08:00:02Z' });
const early = await submit({ ref: 'B', stampedAt: '2026-10-01T08:00:01Z' });
expect([late, early].sort(byPriority)[0].receiptRef).toBe(early.receiptRef);
});
it('يرفض الإيداع إذا فشل ختم التوقيت', async () => {
timestampService.failNext();
await expect(submit(validInput)).rejects.toThrow('TIMESTAMP_UNAVAILABLE');
expect(await repo.count()).toBe(0); // لا مطلب بلا ختم
});
it('يحجب الصرف عند وجود قرض من نفس الصنف غير مخلّص', async () => {
centraleClient.setExposures('CIN123', [{ kfcred: '260', outstandingMillimes: 1 }]);
await expect(disburse(appOfCategory('260'))).rejects.toThrow('OUTSTANDING_SAME_CATEGORY');
});
it('يحتسب الأجل بأيام العمل المصرفية لا بالأيام التقويمية', () => {
const stamped = new Date('2026-10-01T09:00:00Z'); // خميس
expect(decisionDeadline(stamped, calendarWithWeekends())).toEqual(new Date('2026-10-15T09:00:00Z'));
});
it('يساوي مجموع أسطر الملحق 2 سطرَ الإجمالي بعد التقريب', () => {
const annex2 = buildAnnex2(fixtureRows, period);
const sumOfRows = annex2.rows.reduce((acc, r) => acc + totalOf(r), 0);
expect(toThousandDinars(sumOfRows)).toBe(annex2.total.thousandDinars);
});
});The second test matters most. A platform that accepts an application and then tries to stamp it produces applications with nothing behind them, and it is the one failure no retroactive fix repairs: you cannot timestamp the past.
Troubleshooting
The receipt arrives but the application does not exist. Cause: the notification was sent outside the transaction. Fix: the outbox inside the same transaction, as in Step 2.
The platform's ordering disagrees with the branch's. Cause: using the database's created_at instead of genTime. Remove every sort that does not go through byPriority.
Governorate rows do not add up to the total. Row-level rounding. Add in millimes and round once.
A disbursed loan with no registry declaration. A message stuck in the outbox with nobody watching. Alert on the age of each message past a threshold, not only on a failure rate.
The registry was consulted at approval, not at disbursement. If more than a day separates approval from disbursement, re-run the check at disbursement: the text ties it explicitly to disbursement.
Next Steps
- Revisit the eligibility conditions, ceilings and documents in Honour Loan Conditions before freezing the validation rules in your interface.
- Use the honour-loan application generator as a reference for the fields and the customary wording when designing the platform's form, and amount in Arabic words to render the written amount in the receipt and in contract documents.
- Follow what Circular 2026-08 changed and the start of lending through bank platforms.
Conclusion
Circular 2026-08 does not ask banks for a sign-up form. It asks for four measurable things: a stamp that proves the instant, a receipt that proves notification, a check that precedes disbursement, and two statements that arrive within fifteen days. Build those four around a single source of truth — the operation in millimes, the class fixed at origination, the token kept exactly as returned — and you ship before 1 October and answer examination questions with a query instead of an investigation.
If you are building this platform, or the declaration layer on top of your existing core system, and want an independent technical read of the compliance gaps before the deadline, ask for a diagnostic — we check the model, the flows and the statements against the circular's text and its annexes.