الكتابات/tutorial/2026/10
● Tutorial7 أكتوبر 2026·26 دقيقة

مؤسسات الدفع: تطبيق منشور البنك المركزي التونسي 2026-10

دليل تقني لقواعد مسك الحسابات في منشور البنك المركزي التونسي عدد 2026-10 الخاص بمؤسسات الدفع: سقوف حسابات المستوى 1 و2 و3، حسابات التجار، السقوف لكل عملية، سجلات تُحفظ عشر سنوات، مطابقة الحساب الشامل، سجل أداء فتح الحساب عن بعد، ورزنامة التصاريح في الملحق 1 مكرر، بلغة TypeScript مع اختبارات.

يلغي منشور البنك المركزي التونسي عدد 2026-10 المؤرخ في 25 سبتمبر 2026 المنشور عدد 2018-16 ويعيد كتابة القواعد المنطبقة على كل مؤسسات الدفع في تونس. يدخل حيز التنفيذ بعد ثلاثة أشهر من نشره (الفصل 52)، أي في آخر ديسمبر 2026. ركّزت الصحافة على السقوف الجديدة للحسابات. أما بالنسبة إلى الفريق الذي يدير نظام الحسابات، فالسقوف هي الجزء السهل. فالمنشور يغيّر أيضاً ما يخضع للسقف، ويحدث حساب تاجر خارج السقوف، ويفرض حساباً شاملاً يساوي في كل لحظة مجموع الأرصدة، ويضع سجلاً قابلاً للقياس قبل تشغيل فتح الحسابات عن بعد، ويضيف ملحق تصاريح يضم أكثر من ثلاثين تصريحاً مرمّزاً.

يحوّل هذا الدرس تلك الفصول إلى وحدة TypeScript واحدة مع اختباراتها. كل قاعدة مربوطة بالفصل الذي جاءت منه، فيستطيع مسؤول الامتثال قراءة الشيفرة ويستطيع المطوّر قراءة المنشور.

ما الذي ستبنيه

وحدة واحدة، payment-institution.ts، تقوم بما يلي:

  1. تطبّق مستويات الحساب الواردة في الفصل 17 وقواعد الفتح في الفصلين 17 و21.
  2. تسجّل العمليات في حدود سقوف الرصيد والسقوف اليومية للسحب نقداً ومنع الرصيد المدين في الفصل 22 وقواعد حساب التاجر في الفصل 23.
  3. تطبّق السقوف لكل عملية في الفصل 3 وترفض أي عملية مسقّفة غير مبرّرة.
  4. تمسك سجلاً مسلسلاً بالبصمات يُحفظ عشر سنوات وفق الفصل 16.
  5. تطابق الحساب الشامل مع مجموع الأرصدة (الفصول 24 إلى 26) وتحسب آخر أجل للإيداع.
  6. تقيس إجراء فتح الحساب عن بعد في سجل أداء يتضمن نسبة القبول الخاطئ (الفصل 18) وتمنع التشغيل حين ينقص أي دليل.
  7. تحسب رزنامة التصاريح في الملحق 1 مكرر وتصاريح الحوادث الجسيمة وقائمة RAM06 حسب القناة.

المتطلبات

  • Node.js 20 أو أحدث وTypeScript 5 في الوضع الصارم
  • معرفة عملية بنظام لمسك الحسابات
  • نص المنشور مفتوحاً أمامك: المنشور عدد 2026-10 على موقع البنك المركزي. أرقام الفصول أدناه تتبع نسخة البنك المركزي، التي صحّحت خطأ ترقيم في النسخة التي تداولتها الصحافة يوم 25 سبتمبر.

من أين تأتي هذه الالتزامات

القاعدةالفصلما تفعله الشيفرة
ثلاثة مستويات لحساب الحريف، سقوف الرصيد والسحب نقداً17LIMITS، post
حساب حريف واحد لكل شخص21assertCanOpen
لا تمويل ولا رصيد مدين أبداً22post
حسابات التجار خارج السقوف، تُموَّل فقط من المدفوعات المقبولة23post
التحويل بتسليم نقود في حدود 3 000 دينار، والأموال الواردة من الخارج في حدود 20 000 دينار، مع التبرير3assertOperationCaps
سجلات تُحفظ عشر سنوات على الأقل16append، retainUntil
حساب شامل لدى بنك في أجل أقصاه يوم العمل الموالي، يساوي كل الأرصدة، مع المطابقة24 و25 و26reconcile، depositDeadline
فتح الحساب عن بعد، سجل الأداء، اختبارات الاختراق، تدقيق كل سنتين18performance، goLiveBlockers
ملحق التصاريح50 والملحق 1 مكررreturnsDue، incidentReturns، ram06

ثلاث قراءات في هذا الدرس هي قراءاتنا نحن، وننبّه إليها في مواضعها: طريقة حساب نسبة الموثوقية، وطريقة احتساب آجال DR+N، ومصير عملية إيداع تتجاوز السقف.

الخطوة 1: المبالغ بالمليم، والحسابات حسب المستوى

للدينار ثلاثة أرقام عشرية. الدنانير بالفاصلة العائمة تجعل فحص السقف يفشل عند 1 500,000 لأن المجموع صار 1 500,0000000002. خزّن كل مبلغ عدداً صحيحاً من المليمات وحوّل مرة واحدة، عند حدود النظام.

// 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}`);
  }
}

تفصيلان في الفصل 17 يسهل الخطأ فيهما:

  • لا يوجد في النص سقف يومي للسحب نقداً في المستوى 1. سقفه الوحيد هو رصيد 1 500 دينار. لا تنسخ سقف المستوى 2 إليه.
  • كان نص 2018 يسقّف مجموع العمليات المدينة يومياً (250 و500 و1 000 دينار). أما نص 2026 فيسقّف السحب نقداً فقط: 3 000 دينار يومياً في المستوى 2 و10 000 دينار في المستوى 3. التحويل أو الدفع بالبطاقة لم يعد يُحتسب ضمن حد يومي. إن كان محرّكك ما زال يجمع كل العمليات المدينة، فهو الآن أشد من التنظيم، وسيلاحظ حرفاؤك ذلك.

الخطوة 2: قواعد فتح الحساب

يخصّص الفصل 17 المستوى 1 للأشخاص الطبيعيين. المستويان 2 و3 مفتوحان للأشخاص الطبيعيين والمعنويين. ويمنع الفصل 21 فتح أكثر من حساب دفع حريف واحد لفائدة الشخص نفسه.

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 };
}

نقرأ الفصل 21 على أنه يخص حسابات الدفع للحرفاء، فيمكن للتاجر أن يملك حساب حريف وحساب تاجر معاً. حساب التاجر صنف مستقل في الفصل 23، له بطاقة تعريف خاصة (الملحق 4) واتفاقية خاصة. إن كانت قراءة مصلحتكم القانونية أشد، فعدّلوا isClient والاختبار المرتبط بها.

نتيجة عملية للفصل 21: نقل حريف من المستوى 1 إلى المستوى 2 هو تغيير لمستوى الحساب نفسه، لا حساب ثانٍ. صمّم الترقية كتحديث مع بطاقة تعريف جديدة (الملاحق 1 و2 و3 من المنشور تختلف حسب المستوى)، لا كفتح جديد.

الخطوة 3: أنواع العمليات وسقوف الفصل 3

يسقّف الفصل 3 صنفين من العمليات، لكل عملية على حدة:

  • التحويلات التي تتم بتسليم نقود: 3 000 دينار.
  • وضع الأموال الواردة من الخارج على ذمة المستفيد: ما يعادل 20 000 دينار.

ويجب أن تكون الاثنتان «مبرّرة على النحو الواجب». يعتبر المحرّك غياب مرجع التبرير رفضاً، لا مجرد تنبيه.

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`);
  }
}

الحقل channel ليس شرطاً تنظيمياً على العملية في حد ذاتها. وجوده سببه أن تصريح RAM06 في الخطوة 10 يوزّع العمليات حسب القناة، وأقل لحظة كلفة لتسجيل القناة هي لحظة إنشاء العملية.

الخطوة 4: التسجيل في حدود السقوف

هذا هو قلب النظام. عملية الإيداع تتحقق من سقف الرصيد ومن قواعد حساب التاجر. وعملية السحب تتحقق من منع الرصيد المدين، ومن السقف اليومي إن كانت سحباً نقدياً.

// 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 };
}

ثلاثة قرارات مكتوبة هنا.

اليوم هو يوم تونس. السحب على الساعة 00:30 بالتوقيت المحلي يتبع اليوم الجديد، ولو كان ما يزال اليوم السابق بالتوقيت العالمي. تونس على UTC+1 طوال السنة منذ 2009، فالفارق الثابت صحيح. إن كانت خوادمك تعمل بالتوقيت العالمي وتجمّع حسب toISOString().slice(0, 10)، فإن الحريف الذي يسحب على الساعة 23:45 ثم على الساعة 00:15 يُمنع في يوم لم يستعمله.

الإيداع الذي يتجاوز السقف يُرفض. المنشور يسقّف الرصيد، ولا يقول ماذا نفعل بتحويل وارد يتجاوزه. رفضه هو اختيارنا، لأن البديل (قبوله ووضع الفائض في مكان آخر) يخلق رصيداً خارج الحساب، وهو ما يصعب الدفاع عنه أمام الفصل 25. أياً كان اختيارك، اجعله صريحاً وأعلم المرسل.

حساب التاجر باب في اتجاه واحد. ينص الفصل 23 على أنه يُموَّل حصرياً من التدفقات الناتجة عن المدفوعات التي قبلها التاجر. التحويل من حريف إليه مرفوض. وتحويل مقبوضات التاجر إلى حساب حريف مرفوض أيضاً، حتى لا تُستعمل المقبوضات لتجاوز سقف حساب الحريف.

الخطوة 5: السجل، عشر سنوات ودليل عدم التلاعب

يفرض الفصل 16 مسك سجلات لكل العمليات الواردة في الفصل 2، تُحفظ عشر سنوات على الأقل بداية من تاريخ التنفيذ. ويضيف الفصل 12 تتبّع كل عملية وتسجيلها في الوقت الحقيقي، بما في ذلك شبكة الوكلاء. سلسلة البصمات تحقق الأمرين: كل مدخل مرتبط بالذي قبله، فأي تعديل في مبلغ يكسر السلسلة من تلك النقطة.

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();

ملاحظات للإنتاج:

  • اكتب مدخل السجل في المعاملة نفسها التي يتغير فيها الرصيد. رصيد تحرّك دون مدخل في السجل هو بالضبط الثغرة التي يبحث عنها المراقب.
  • retainUntil حد أدنى، لا تاريخ حذف. قد تفرض قواعد المعطيات الشخصية الاحتفاظ بأقل لبعض الحقول، وقد تفرض قواعد مكافحة غسل الأموال الاحتفاظ بأكثر لحقول أخرى. لا تحذف إلا حين تسمح كل القواعد المنطبقة بذلك.
  • جهاز الوكيل الذي يخزّن العمليات دون اتصال يخرق «الوقت الحقيقي» في الفصل 12. إن كان وكلاؤك يعملون دون اتصال، سجّل وقت التنفيذ ووقت التسجيل منفصلين، وصرّح بالفارق.

الخطوة 6: الحساب الشامل والمطابقة اليومية

أموال الحرفاء والتجار ليست ملكاً للمؤسسة. يفرض الفصل 24 إيداعها في حساب شامل وحيد لدى بنك، في أجل أقصاه يوم العمل الموالي لاستلامها. ولا يجوز تسجيل العمولات في هذا الحساب. ويشترط الفصل 25 أن يساوي رصيده في كل وقت مجموع أرصدة حسابات الحرفاء والتجار. ويفرض الفصل 26 مطابقة منتظمة وموثّقة.

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);
}

كيف تقرأ فارقاً غير صفري:

  • موجب (أموال في البنك أكثر مما هو مستحق لأصحاب الحسابات): في الغالب عمولات تُركت في الحساب الشامل بدل نقلها إلى الحساب الخاص بالمؤسسة. وهذا خرق للفصل 24 حتى لو لم يخسر أحد شيئاً.
  • سالب (أموال في البنك أقل مما هو مستحق): أموال استُلمت ولم تُودع بعد، وهذا مقبول فقط إلى الأجل الذي تعطيه depositDeadline، أو عجز حقيقي.

العطل الرسمية مُدخل في الدالة لأن الأعياد الدينية تتبع التقويم القمري. حمّلها كل سنة من مصدر رسمي بدل كتابتها داخل الشيفرة.

الخطوة 7: سجل أداء فتح الحساب عن بعد

يفتح الفصل 18 فتح الحساب عن بعد في كل المستويات، شرط أن يتحقق الإجراء من الهوية بمستوى يعادل على الأقل الحضور الشخصي. وقبل التشغيل، يجب اختبار الإجراء في بيئة ما قبل الإنتاج وتدوين النتائج في سجل أداء يقيس نسبة موثوقية الإجراء، بما في ذلك نسبة القبول الخاطئ.

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,
  };
}

يذكر المنشور نسبة القبول الخاطئ لكنه لا يعرّف الموثوقية. نحن نقدّم ثلاثة أرقام: القبول الخاطئ (منتحلون قُبلوا)، والرفض الخاطئ (حرفاء حقيقيون رُفضوا)، ونسبة القرارات الصحيحة. احتفظ بالثلاثة في السجل. الإجراء المضبوط فقط ضد القبول الخاطئ قد يرفض عدداً كبيراً من الحرفاء الحقيقيين حتى تتوقف القناة عن بعد عن العمل.

مجموعة الاختبار أهم من المعادلة. يجب أن تشمل محاولات الانتحال صوراً مطبوعة، وإعادة عرض على شاشة، ووثائق تخص شخصاً آخر، ووثائق منتهية الصلاحية. احفظ مجموعة المحاولات بإصدارات حتى يستطيع التدقيق القادم إعادة تشغيلها.

الخطوة 8: بوابة التشغيل

يعدّد الفصل 18 ما يجب أن يقوم به الإجراء (التحقق من الوثائق، إثبات الحياة، المصادقة بعاملين على الأقل، الموافقة الصريحة، التشفير، بطاقة KYC مولّدة آلياً) وما يجب أن يحيط به: اختبارات اختراق وتدقيقات أمنية من هيئات مصادق عليها من الوكالة الوطنية للسلامة السيبرنية، ثم تدقيق مرة كل سنتين على الأقل وعند كل تغيير تنظيمي أو تقني قد يؤثر في الإجراء.

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;
}

تعيد الدالة قائمة بالموانع بدل قيمة منطقية، حتى تعرض سلسلة النشر بالضبط ما ينقص. عتبة القبول الخاطئ معامل لأن المنشور لا يحددها. حدّدها في سياسة المخاطر لديك، بمصادقة مجلس الإدارة، واحتفظ بالقيمة المستعملة لكل إصدار.

الفصل 13 مستقل وينطبق كذلك: تدقيق أمني سنوي لكامل منظومة المعلومات من مكتب مصادق عليه من الوكالة الوطنية للسلامة السيبرنية، مع إرسال التقرير إلى البنك المركزي. هذا التقرير هو التصريح RCIA250100 في الخطوة الموالية.

الخطوة 9: رزنامة التصاريح في الملحق 1 مكرر

يضيف الفصل 50 الملحق 1 مكرر إلى المنشور عدد 2017-6. لكل تصريح رمز ودورية وأجل وصيغة. هذه أكثرها صلة بفريق مسك الحسابات:

الرمزالتصريحالدوريةالأجلالصيغة
RAM05المؤشرات التجاريةشهريةDR+15 يوماًXML
RAM06العمليات حسب القناة، عدداً ومبلغاًشهريةDR+15 يوماًXML
RCT03 / RCT04الموازنة / قائمة النتائجثلاثيةDR+30 يوماًXML
RAT07الفروع الخاصة والوكلاء المفوّضونثلاثيةDR+30 يوماًXML
RROT390التصريح الثلاثي بالحوادثثلاثيةDR+30 يوماًXML
RCIA250100التدقيق السنوي لأمن منظومة المعلوماتسنويةDR+45 يوماًPDF
RROI380البطاقة الأولية لحادث جسيمعند الحادثيوم الحادثXML
RROI381بطاقة غلق الحادث الجسيمعند الحادثتاريخ الحادث + 10 أيامغير محددة
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) },
  ];
}

ما تفترضه الشيفرة، ولماذا:

  • DR هو تاريخ المرجع، أي آخر يوم في الفترة المصرّح بها. يكتب الملحق DR+15j. نحن نحتسب الأيام التقويمية ولا نؤجل أجلاً يقع في عطلة نهاية الأسبوع. إن كانت قراءتك للمنشور 2017-6 مختلفة، فعدّل addDays والاختبارات، لا الجدول.
  • يترك الملحق خانة الصيغة فارغة بالنسبة إلى RROI381، لذلك تعيد الشيفرة null بدل التخمين.
  • ثلاثة أنواع من التصاريح خارج الرزنامة عمداً. RROS370 (حوادث انقطاع الخدمة) نصف سنوي بأجل واحد مكتوب «نهاية أوت». وتقارير مراقبي الحسابات الثلاثة (RCACA150 وRCACA170 وRCACA260) مستحقة قبل شهر من انعقاد الجلسة العامة العادية، فتاريخها مرتبط بموعد جلستك لا بنهاية الفترة. أضفها يدوياً إلى رزنامة الامتثال.

عند وقوع حادث جسيم، يفرض الفصل 13 أيضاً إعلام البنك المركزي والوكالة الوطنية للسلامة السيبرنية فوراً. وإن كشف الحادث معطيات شخصية، فإن آجال القانون الأساسي عدد 63 لسنة 2004 تسري بالتوازي؛ وحاسبة مهلة الإبلاغ عن تسريب البيانات تعرضها بالتفصيل.

الخطوة 10: RAM06، العمليات حسب القناة

يصرّح RAM06 كل شهر بالعمليات حسب القناة، عدداً ومبلغاً. يذكر الملحق التصريح، لكن المنشور لا يعدّد القنوات ولا ينشر مخطط XML، الذي يرسله البنك المركزي إلى المؤسسات المصرّحة عبر منظومة تبادل المعطيات (SED). لذلك تُنتج الشيفرة الأرقام، وتربط أنت أداة التسلسل الموافقة للمخطط الذي تتسلمه.

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));
}

أمران تتحقق منهما قبل أول إرسال:

  • الشهر هو شهر تونس. عملية على الساعة 00:30 يوم 1 فيفري بالتوقيت المحلي تتبع شهر فيفري.
  • اجمع بالمليم وحوّل مرة واحدة. تقريب كل سطر إلى الدينار ثم جمع الأسطر يعطي مجموعاً لا يطابق محاسبتك.

إن تعطلت منظومة SED، ينص الفصل 51 على عنوان بديل لإرسال التصاريح: reporting.EP@bct.gov.tn.

اختبار التنفيذ

تعمل الاختبارات أدناه بأداة الاختبار المدمجة في Node (node --import tsx --test payment-institution.test.ts). يحمل كل اختبار اسم الفصل الذي يثبته.

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 },
  ]);
});

الاختبارات التي يجب أن تبقى في التكامل المستمر مهما غيّرت غيرها هي اختبارات الحدود: رصيد يساوي 1 500,000 دينار بالضبط مقبول و1 500,001 مرفوض، والسحب النقدي على الساعة 00:30 بتوقيت تونس يُحتسب في اليوم الجديد، والمبلغ المعدّل يكسر سلسلة السجل عند رقم التسلسل الصحيح.

حل المشكلات

حريف يُمنع من تحويل بعد سحب نقدي. محرّكك ما زال يجمع كل العمليات المدينة ضمن الحد اليومي كما في المنشور 2018-16. منذ المنشور 2026-10، لا يُحتسب إلا السحب نقداً.

المطابقة مختلة بقيمة عمولات الشهر بالضبط. بقيت العمولات في الحساب الشامل. انقلها إلى الحساب الخاص بالمؤسسة؛ فالفصل 24 يمنع تسجيلها في الحساب الشامل.

عمليات سحب قرب منتصف الليل تُمنع أو تُقبل في اليوم الخطأ. اليوم يُحسب بالتوقيت العالمي. استعمل tunisDay.

سلسلة السجل تنكسر بعد ترحيل. أُعيد تنسيق حقل ما (مثل تواريخ كُتبت دون أجزاء الثانية). احسب البصمة على صيغة موحّدة، كما تفعل digest مع toISOString، وصحّح بإضافة مدخلات جديدة، لا بإعادة كتابة القديمة.

تدقيق فتح الحساب «ساري» لكن الإصدار مُنع. سُجّل تغيير جوهري (مزوّد جديد لإثبات الحياة، إصدار جديد من SDK) بعد آخر تدقيق. يفرض الفصل 18 تدقيقاً جديداً بعد تغيير كهذا، لا فقط كل سنتين.

الخطوات التالية

الخلاصة

يمنح المنشور 2026-10 مؤسسات الدفع هامشاً أوسع: سقوف أعلى، وفتح حسابات عن بعد في كل المستويات، وحسابات للتجار. لكنه يطلب أيضاً أدلة. على نظام الحسابات أن يثبت أن السقف صمد، وعلى السجل أن يثبت أن شيئاً لم يتغير، وعلى الحساب الشامل أن يطابق حتى آخر مليم، وعلى إجراء فتح الحساب أن يأتي بسجل يقيس كم مرة يُدخل الشخص الخطأ. كل ذلك يتسع في وحدة واحدة تحمل اختباراتها أسماء الفصول، تُكتب قبل آخر ديسمبر لا بعد أول تفقد.

إن كنت تدير مؤسسة دفع أو تبنيها، وتريد قراءة مستقلة لنظام حساباتك ومسار فتح الحسابات وسلسلة التصاريح مقارنة بالمنشور قبل دخوله حيز التنفيذ، اطلب منا تشخيصاً. نراجع الشيفرة وتدفقات المعطيات مقابل النص، فصلاً بفصل. ولأعمال التكامل نفسها، اطّلع على خدماتنا في تونس.