دمج بوابة الدفع هو الجزء الذي يضعه الجميع في الميزانية. أما المطابقة فهي الجزء الذي يظهر بعد ثلاثة أشهر، حين يسأل الفريق المالي لماذا تقول لوحة التحكم 480,000 ريال بينما كشف البنك يقول 472,318 ريالاً.
كلا الرقمين صحيح. الفارق هو معدل الخصم التجاري وضريبة القيمة المضافة على ذلك المعدل، وعمليتا استحواذ لم تُسوَّيا، واسترداد سُوِّي في دفعة لاحقة لعملية البيع، ومعاملة طرفية لم يُسجلها أحد. حتى يُطابق شيء ما هذين السجلين تلقائياً، سيظل شخص في قسمك المالي يفعل ذلك في جدول بيانات في نهاية الشهر — والأخطاء التي يرتكبها غير مرئية حتى يكتشفها تدقيق.
هذا الدليل يبني ذلك الشيء.
هذا هو الجزء الثاني، ليس نقطة البداية. إن لم تكن قد بنيت الدفع بعد، ابدأ بـ دمج بوابات الدفع السعودية بـ TypeScript، الذي يغطي التفويض و3-D Secure والـ webhooks. هذا الدليل يبدأ من حيث انتهى ذاك.
ما ستبنيه
محرك مطابقة يأخذ مدخلين — سجل مدفوعاتك الداخلي وملفات التسوية التي يرسلها البنك المستحوذ ومزودو خدمات الدفع — وينتج تقريراً بثلاثة أقسام:
- الأزواج المتطابقة، مُوسومة بمستوى الثقة الذي أنتج التطابق.
- الخلافات، مُصنَّفة حسب السبب، كي يُحال كل خلاف إلى من يستطيع فعلاً إصلاحه.
- الإجماليات التي تربط إيراداتك الإجمالية بالنقد الصافي الذي وصل إلى البنك، مع الرسوم التي تفسر الفارق.
مبدأ التصميم طوال الدليل: المطابقة الغامضة هي خلاف. محرك يخمن أسوأ من عدم وجود محرك، لأنه ينتج تقريراً نظيفاً يكون هادئاً وخاطئاً.
المتطلبات المسبقة
- Node.js 20 أو أحدث، وTypeScript 5 مع تفعيل
strict - دمج دفع يعمل وينتج سجلاً بالمدفوعات المُحتجزة
- ملفات تسوية نموذجية من البنك المستحوذ ومزودي خدمات الدفع — الحقيقية، ليس أمثلة التوثيق
- Vitest لمجموعة الاختبارات
ثبِّت ما تحتاجه الأمثلة:
npm install -D typescript vitestملف tsconfig.json المستخدم للتحقق من كل مقتطف أدناه:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"skipLibCheck": true
}
}الخطوة 1: المال كأعداد صحيحة، محللة من نصوص
ملفات التسوية تصل كـ CSV. المبالغ تصل كنصوص عشرية. الخطأ الأكثر شيوعاً في هذا المجال بأكمله هو parseFloat(row.amount) * 100.
جرِّب: 8.29 * 100 تُقيَّم إلى 828.9999999999999 في IEEE 754. قرِّبها وستكون بخير؛ اقطعها وستكون قد فقدت هللة واحدة بصمت في كل صف متأثر. عبر 40,000 معاملة شهرياً، هذا تقرير خلافات لا يتوازن ولا أحد يستطيع شرحه.
حلِّل النص كنص.
/** A signed integer number of halalas. 1 SAR = 100 halalas. */
export type Halalas = number & { readonly __brand: unique symbol };
export function halalas(value: number): Halalas {
if (!Number.isSafeInteger(value)) {
throw new RangeError(`Halalas must be a safe integer, received: ${value}`);
}
return value as Halalas;
}
export function addHalalas(...values: Halalas[]): Halalas {
return halalas(values.reduce<number>((sum, v) => sum + v, 0));
}
/** Accepts "1234.56", "1,234.5", "-80", "0.07". Rejects anything else. */
const SAR_DECIMAL = /^(-)?(\d{1,3}(?:,\d{3})*|\d+)(?:\.(\d{1,2}))?$/;
export function parseSarToHalalas(raw: string): Halalas {
const trimmed = raw.trim();
const match = SAR_DECIMAL.exec(trimmed);
if (match === null) {
throw new TypeError(`Unparseable SAR amount: ${JSON.stringify(raw)}`);
}
const [, sign, whole = '0', fraction = ''] = match;
const units = Number.parseInt(whole.replace(/,/g, ''), 10);
const cents = Number.parseInt(fraction.padEnd(2, '0'), 10);
const magnitude = units * 100 + cents;
return halalas(sign === '-' ? -magnitude : magnitude);
}النوع المُبوَّب يؤدي عملاً حقيقياً. Halalas هو number في وقت التشغيل بدون أي تكلفة، لكن TypeScript لن يسمح لـ number خام — ريال عشري، نسبة مئوية، فهرس مصفوفة — بالتدفق إلى حقل يتوقع هللات دون المرور عبر halalas()، الذي يتحقق.
لاحظ ما يرفضه المحلل. المنزل العشري الثالث يرمي بدلاً من التقريب. في ملف التسوية، 1.005 ليست فرصة تقريب؛ بل تعني أنك تحلل عموداً تظنه ريال سعودي وهو شيء آخر. الفشل بصوت عالٍ عند الاستيعاب أرخص بكثير من اكتشافه في تقرير الخلافات.
ضريبة القيمة المضافة على الرسوم تستحق دالتها الخاصة، بسبب حالة الاسترداد:
/**
* VAT on the acquirer fee, rounded half-up on the absolute value so that a
* refund's fee VAT mirrors the sale's exactly instead of drifting by 1 halala.
*/
export function vatOnFee(fee: Halalas, ratePercent: number): Halalas {
const sign = fee < 0 ? -1 : 1;
const raw = (Math.abs(fee) * ratePercent) / 100;
return halalas(sign * Math.round(raw));
}تقريب القيمة الموقَّعة مباشرةً سيستخدم سلوك Math.round النصفي نحو اللانهاية الموجبة، وهو غير متماثل: رسم 11.5 هللة يُقرَّب إلى 12، وعكسه -11.5 يُقرَّب إلى -11. هللة واحدة محاصرة بشكل دائم، في كل عكس يصل إلى تلك الحدود. تقريب المقدار وإعادة تطبيق الإشارة يجعل العكوس دقيقة.
ضريبة القيمة المضافة تُطبَّق على الرسم، لا على المعاملة. الـ 15% تُفرض على معدل الخصم التجاري الذي يحتفظ به المستحوذ، وهي سطر منفصل في ملف التسوية. إن صمَّمتها كضريبة قيمة مضافة على مبلغ البيع، كل صف واحد سيكون غير متطابق.
الخطوة 2: تقويم يعرف أن عطلة نهاية الأسبوع الجمعة والسبت
مساعد أيام العمل الافتراضي في كل مكتبة تواريخ يفترض السبت والأحد. في المملكة العربية السعودية، العطلة الأسبوعية الجمعة والسبت، والأحد يوم عمل كامل.
أخطئ في هذا وسيكون مؤشر اتفاقية مستوى الخدمة للتسوية بعيداً يومين في الاتجاه المهم: ستُنبه على تسوية متأخرة كل صباح أحد، وتصمت على عمليات استحواذ الخميس التي لم تصل فعلاً.
/** Saudi Arabia's weekend is Friday and Saturday, not Saturday and Sunday. */
const FRIDAY = 5;
const SATURDAY = 6;
export type IsoDate = string; // YYYY-MM-DD
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
function toUtc(date: IsoDate): Date {
if (!ISO_DATE.test(date)) {
throw new TypeError(`Expected YYYY-MM-DD, received: ${JSON.stringify(date)}`);
}
const parsed = new Date(`${date}T00:00:00.000Z`);
// Date rolls impossible days over silently: '2026-02-30' becomes March 2nd
// rather than NaN. Only a round-trip comparison catches that.
if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== date) {
throw new TypeError(`Not a real calendar date: ${date}`);
}
return parsed;
}
export function isBusinessDay(date: IsoDate, holidays: ReadonlySet<IsoDate>): boolean {
const day = toUtc(date).getUTCDay();
return day !== FRIDAY && day !== SATURDAY && !holidays.has(date);
}
/** Walks forward `count` business days, skipping Fri/Sat and Eid closures. */
export function addBusinessDays(
start: IsoDate,
count: number,
holidays: ReadonlySet<IsoDate> = new Set(),
): IsoDate {
if (!Number.isInteger(count) || count < 0) {
throw new RangeError(`count must be a non-negative integer, received: ${count}`);
}
const cursor = toUtc(start);
let remaining = count;
while (remaining > 0) {
cursor.setUTCDate(cursor.getUTCDate() + 1);
if (isBusinessDay(cursor.toISOString().slice(0, 10), holidays)) remaining -= 1;
}
return cursor.toISOString().slice(0, 10);
}التحقق من رحلة الذهاب والإياب في toUtc ليس حشواً دفاعياً — بل اكتشف خطأً حقيقياً أثناء كتابة هذا الكود. new Date('2026-02-30T00:00:00.000Z') لا تُرجع تاريخاً غير صالح. تُرجع 2 مارس. تاريخ مشوَّه في ملف التسوية كان سيُزيح نافذة مؤشر اتفاقية مستوى الخدمة بأكملها دون رفع أي شيء.
الإجازات تُحقن بدلاً من ترميزها الصلب، لأن عيد الفطر وعيد الأضحى يتحركان مقابل التقويم الميلادي كل عام وأيام إغلاق البنوك تُعلَن، لا تُحسب. حمِّلها من التكوين وحدِّثها سنوياً.
كل شيء هنا يعمل بـ UTC على نصوص التاريخ فقط. لا تصل إلى المنطقة الزمنية المحلية: خادم يعمل بـ UTC وملف تسوية مختوم بتوقيت الرياض سيختلفان حول أي يوم تنتمي إليه عملية استحواذ في المساء المتأخر، وستمضي يوماً تلاحق خلافاً غير موجود.
الخطوة 3: نمذجة الجانبين بصدق
نوعان من السجلات، والانضباط هو أن لا أي منهما يتظاهر بمعرفة أي شيء لم يُخبره الجانب الآخر.
export type Provider = 'mada' | 'moyasar' | 'tabby';
export type EntryKind = 'sale' | 'refund' | 'chargeback' | 'adjustment';
/** What our own system believes happened. */
export interface LedgerEntry {
readonly paymentId: string;
readonly orderId: string;
readonly provider: Provider;
/** The PSP's own id, when we stored it. Null for terminal-only sales. */
readonly providerRef: string | null;
/** Retrieval Reference Number — the only id a mada acquirer file guarantees. */
readonly rrn: string | null;
readonly kind: Extract<EntryKind, 'sale' | 'refund'>;
/** Signed: sales positive, refunds negative. */
readonly grossHalalas: Halalas;
readonly capturedOn: IsoDate;
}
/** What the money actually did, according to the settlement file. */
export interface SettlementRow {
readonly fileId: string;
readonly rowId: string;
readonly provider: Provider;
readonly providerRef: string | null;
readonly rrn: string | null;
readonly kind: EntryKind;
readonly grossHalalas: Halalas;
/** What the acquirer kept. Positive on sales, zero on most refunds. */
readonly feeHalalas: Halalas;
readonly feeVatHalalas: Halalas;
/** Must equal gross - fee - feeVat. Verified on ingest. */
readonly netHalalas: Halalas;
readonly settledOn: IsoDate;
}LedgerEntry.kind مُضيَّق بـ Extract للبيع والاسترداد فقط. نظامك يمكنه بدء هذين الاثنين. لا يمكنه بدء رد المبلغ (chargeback) أو تسوية مخطط — هذه تأتي فقط من الخارج، لذا فقط SettlementRow يمكنه حملها. ترميز ذلك في النوع يعني أن إدخال دفتر مستحيل لن يُكمَّل.
اتفاقية الإشارة هي القرار الجوهري: المبيعات موجبة، والاستردادات وردود المبالغ سالبة، في كل مكان، بعد التطبيع. افعل هذا بشكل صحيح عند الحافة وكل مجموع في المنبع هو جمع بسيط.
مؤشر اتفاقية مستوى الخدمة والتسامح هما سياسة لكل مزود، ليسا ثوابت:
export interface ProviderPolicy {
/** Business days from capture to money-in-bank before we call it overdue. */
readonly settlementSlaBusinessDays: number;
/** Tolerance for rounding drift between our fee model and theirs. */
readonly feeToleranceHalalas: number;
}
export const DEFAULT_POLICIES: Readonly<Record<Provider, ProviderPolicy>> = {
// Card rails settle fast; anything past this is a real operational break.
mada: { settlementSlaBusinessDays: 2, feeToleranceHalalas: 2 },
moyasar: { settlementSlaBusinessDays: 3, feeToleranceHalalas: 2 },
// BNPL pays the merchant on its own cycle, unrelated to customer instalments.
tabby: { settlementSlaBusinessDays: 7, feeToleranceHalalas: 5 },
};عامل هذه الأرقام كعناصر نائبة واستبدلها بعقدك الخاص. جداول التسوية ومعدلات الخصم التجاري تُفاوَض لكل تاجر. القيم أعلاه صحيحة هيكلياً — البطاقات تُسوَّى في يوم عمل أو يومين، BNPL يستغرق وقتاً أطول — لكن الأرقام الدقيقة تنتمي إلى اتفاقية المستحوذ الخاصة بك، ومعدل افترضته بدلاً من قراءته هو معدل ستطابقه إلى الأبد.
تعليق BNPL هو النقطة التي تخطئها معظم عمليات الدمج. عندما يشتري عميل عبر تابي بأربعة أقساط، لا يُدفع للتاجر بأربعة أقساط. تابي يدفع للتاجر إجمالي الطلب مطروحاً منه العمولة، مرة واحدة، في دورة تسوية خاصة به، ثم يتحمل مخاطر ائتمان العميل بنفسه. إن نمذجت تسوية التاجر مقابل جدول أقساط العميل ستبني محرك مطابقة يبلغ عن ثلاثة خلافات وهمية لكل طلب BNPL تقبله.
الخطوة 4: التطبيع عند الحافة، التحقق عند الاستيعاب
ملفات المستحوذ تنشر الاستردادات كمبلغ موجب مع عمود نوع يقول REFUND. اجمع ذلك العمود بشكل ساذج وستنتفخ الاستردادات إيراداتك بدلاً من تقليلها.
اقلب الإشارة مرة واحدة، عند الحدود:
export interface RawMadaRow {
readonly RRN: string;
readonly AUTH_CODE: string;
readonly TXN_TYPE: string;
readonly TXN_AMOUNT: string;
readonly MDR_AMOUNT: string;
readonly MDR_VAT: string;
readonly NET_AMOUNT: string;
readonly SETTLEMENT_DATE: string;
}
export function normaliseMadaRow(fileId: string, index: number, raw: RawMadaRow): SettlementRow {
const isCredit = raw.TXN_TYPE === 'REFUND' || raw.TXN_TYPE === 'CHARGEBACK';
const sign = isCredit ? -1 : 1;
return {
fileId,
rowId: `${fileId}:${index}`,
provider: 'mada',
providerRef: null,
rrn: raw.RRN,
kind: raw.TXN_TYPE === 'CHARGEBACK' ? 'chargeback' : isCredit ? 'refund' : 'sale',
grossHalalas: halalas(sign * parseSarToHalalas(raw.TXN_AMOUNT)),
feeHalalas: halalas(sign * parseSarToHalalas(raw.MDR_AMOUNT)),
feeVatHalalas: halalas(sign * parseSarToHalalas(raw.MDR_VAT)),
netHalalas: halalas(sign * parseSarToHalalas(raw.NET_AMOUNT)),
settledOn: raw.SETTLEMENT_DATE,
};
}اكتب واحدة من هذه لكل مزود. بقية المحرك تعمل على SettlementRow ولا تعلم أبداً أن مدى وميسر وتابي يختلفون في أسماء الأعمدة وتنسيقات التواريخ واتفاقيات الإشارة. إضافة مزود رابع تعني إضافة مطبِّع، لا لمس المطابق.
الآن الثابت الذي يكتشف فساد الملف قبل وصوله إلى المطابق:
export class FeeInvariantError extends Error {
constructor(readonly row: SettlementRow, readonly expected: Halalas) {
super(
`Row ${row.rowId}: net ${row.netHalalas} != gross ${row.grossHalalas} ` +
`- fee ${row.feeHalalas} - vat ${row.feeVatHalalas} (expected ${expected})`,
);
this.name = 'FeeInvariantError';
}
}
export function assertFeeInvariant(row: SettlementRow): void {
const expected = addHalalas(
row.grossHalalas,
halalas(-row.feeHalalas),
halalas(-row.feeVatHalalas),
);
if (expected !== row.netHalalas) throw new FeeInvariantError(row, expected);
}كل صف تسوية يتحقق من حساباته الداخلية. إن لم يساوِ net القيمة gross - fee - feeVat، فقد رسمت عموداً بشكل خاطئ، أو يحتوي الملف على فئة خصم لا تعرفها بعد. في كلتا الحالتين، يجب ألا يدخل ذلك الصف التقرير بصمت.
للاستيعاب متطلب إضافي يفاجئ الناس: ملفات التسوية تُعاد إصدارها. يصل ملف مُصحَّح بنفس المعاملات ومبلغ واحد مُصلَح. لذا يجب أن تكون اللاتكرارية لكل صف، لا لكل ملف.
export interface IngestResult {
readonly accepted: readonly SettlementRow[];
readonly duplicates: readonly string[];
readonly rejected: readonly { readonly rowId: string; readonly reason: string }[];
}
export function ingest(
rows: readonly SettlementRow[],
alreadyIngested: ReadonlySet<string> = new Set(),
): IngestResult {
const seen = new Set(alreadyIngested);
const accepted: SettlementRow[] = [];
const duplicates: string[] = [];
const rejected: { rowId: string; reason: string }[] = [];
for (const row of rows) {
if (seen.has(row.rowId)) {
duplicates.push(row.rowId);
continue;
}
try {
assertFeeInvariant(row);
seen.add(row.rowId);
accepted.push(row);
} catch (error) {
rejected.push({
rowId: row.rowId,
reason: error instanceof Error ? error.message : String(error),
});
}
}
return { accepted, duplicates, rejected };
}لاحظ أن الصف السيئ يُعزل مع سبب، لا يُرمى عبر الدفعة بأكملها. صف واحد مشوَّه من أصل 12,000 لا ينبغي أن يمنعك من مطابقة الـ 11,999 الأخرى — لكنه يجب أن يظهر في مكان يقرأه إنسان.
في الإنتاج، احفظ rowId بقيد فريد في قاعدة بياناتك ودع القيد يكون ضمان اللاتكرارية الحقيقي. المجموعة في الذاكرة أعلاه هي نفس المنطق، جُعلت قابلة للاختبار.
الخطوة 5: مطابقة متدرجة ترفض التخمين
ثلاثة مستويات، تُجرَّب بالترتيب، كل منها أقل يقيناً من السابق.
المستوى 1 — مرجع مزود خدمة الدفع الخاص. إن حفظت moy_... أو tby_... وقت الاستحواذ وصف التسوية يحمل نفس المعرف، فهذا تطابق قاطع.
المستوى 2 — RRN مع المبلغ. ملف المستحوذ مدى كثيراً ما لا يحتوي على مرجع مزود خدمة دفع على الإطلاق؛ ما يضمنه هو رقم مرجع الاسترداد. RRN وحده غير كافٍ تماماً، لذا يُقرَن بمبلغ دقيق.
المستوى 3 — المبلغ مع نافذة زمنية. لمعاملات الطرفية بدون معرف قابل للاستخدام. مقبول فقط عندما تنجو مرشحة واحدة بالضبط من الفلتر.
المطالبة هي سجل واحد مع الصفوف التي استحوذ عليها:
interface Claim {
readonly entry: LedgerEntry;
readonly rows: SettlementRow[];
tier: 1 | 2 | 3;
}export function matchEntries(
ledger: readonly LedgerEntry[],
rows: readonly SettlementRow[],
policies: Readonly<Record<Provider, ProviderPolicy>>,
holidays: ReadonlySet<IsoDate>,
): { claims: Claim[]; unclaimed: SettlementRow[] } {
const available = new Map(rows.map((row) => [row.rowId, row]));
const claims: Claim[] = [];
const take = (candidates: SettlementRow[]): SettlementRow[] => {
for (const row of candidates) available.delete(row.rowId);
return candidates;
};
const remaining = (predicate: (row: SettlementRow) => boolean): SettlementRow[] =>
[...available.values()].filter(predicate);
for (const entry of ledger) {
const sameProvider = (row: SettlementRow): boolean => row.provider === entry.provider;
if (entry.providerRef !== null) {
const byRef = remaining((r) => sameProvider(r) && r.providerRef === entry.providerRef);
if (byRef.length > 0) {
claims.push({ entry, rows: take(byRef), tier: 1 });
continue;
}
}
if (entry.rrn !== null) {
const byRrn = remaining(
(r) => sameProvider(r) && r.rrn === entry.rrn && r.grossHalalas === entry.grossHalalas,
);
if (byRrn.length > 0) {
claims.push({ entry, rows: take(byRrn), tier: 2 });
continue;
}
}
const policy = policies[entry.provider];
const deadline = addBusinessDays(entry.capturedOn, policy.settlementSlaBusinessDays, holidays);
const byWindow = remaining(
(r) =>
sameProvider(r) &&
r.grossHalalas === entry.grossHalalas &&
r.settledOn >= entry.capturedOn &&
r.settledOn <= deadline,
);
// Exactly one, or we refuse — two identical amounts on the same day are a
// genuinely ambiguous pair and a human has to look at them.
if (byWindow.length === 1) {
claims.push({ entry, rows: take(byWindow), tier: 3 });
} else {
claims.push({ entry, rows: [], tier: 3 });
}
}
return { claims, unclaimed: [...available.values()] };
}ثلاث خصائص تستحق التسمية.
الصفوف تُطالَب بها، لا تُقرأ فقط. take() تزيل الصفوف المتطابقة من available، لذا لا يمكن لأي صف تسوية إرضاء إدخالين في دفتر الأستاذ. بدون هذا، تُطابق رسوم مكررة بشكل مثالي مقابل طلبين منفصلين وتختفي.
المزود دائماً جزء من الشرط. يمكن لمزودين بسهولة إنتاج نفس المبلغ في نفس اليوم. مطابقتهما العرضية تنتج تقريراً يتوازن وهو عديم المعنى.
المستوى 3 يرفض التعادل. byWindow.length === 1 مقصود. إن استوفت مبيعتا 1,150.00 ريال نفس اليوم ولم تكن لأي منهما معرف، المخرج الصادق هو خلافان، لا قلب عملة. بما أن نصوص تاريخ ISO تُرتَّب معجمياً، مقارنة النافذة هي مقارنة نص بسيطة — لا تحليل تاريخ في الحلقة الساخنة.
الخطوة 6: تصنيف الخلافات حسب من يصلحها
تقرير خلافات يقول "47 استثناء" لا يمكن تنفيذه. تقرير خلافات يفصل البنك متأخر عن فوترنا بمبلغ خاطئ يُحيل كل استثناء إلى الشخص الذي يمكنه إغلاقه.
export type BreakCode =
| 'MISSING_IN_SETTLEMENT'
| 'OVERDUE_IN_SETTLEMENT'
| 'MISSING_IN_LEDGER'
| 'AMOUNT_MISMATCH'
| 'DUPLICATE_SETTLEMENT'
| 'FEE_INVARIANT_BROKEN';| الرمز | معناه | من يملكه |
|---|---|---|
MISSING_IN_SETTLEMENT | مُحتجَز، لم يُسوَّ بعد، لا يزال ضمن مؤشر الخدمة | لا أحد — هذا طبيعي، لا تُنبِّه |
OVERDUE_IN_SETTLEMENT | تجاوز الموعد النهائي لمؤشر الخدمة ولم يُدفع بعد | العمليات، ثم المستحوذ |
MISSING_IN_LEDGER | وصلت أموال بدون طلب خلفها | الهندسة — عادةً webhook ضائع |
AMOUNT_MISMATCH | المبلغ المُسوَّى يختلف بما يتجاوز التسامح | المالية — نموذج رسوم خاطئ أو استحواذ جزئي |
DUPLICATE_SETTLEMENT | صفان تسوية، دفعة واحدة | نزاع مع المستحوذ |
FEE_INVARIANT_BROKEN | حساب الملف الداخلي لا يصمد | الهندسة — عمود مرسوم بشكل خاطئ |
التمييز بين الصفين الأول والثاني هو ما يجعل التقرير قابلاً للتحمل. استحواذ هذا الصباح لم يُسوَّ بعد ولم يكن ينبغي أن يكون. إن أصدرت تنبيهاً عنه، سينظر فريقك في مئات الأحداث غير الحقيقية يومياً وسيتوقف عن قراءة التقرير خلال أسبوع.
قطعتان صغيرتان يحتاجهما المحرك أولاً — دالة جمع مُنمَّطة وشكل مدخلاتها:
function sumBy(rows: readonly SettlementRow[], pick: (r: SettlementRow) => Halalas): Halalas {
return addHalalas(...rows.map(pick));
}
export interface ReconcileInput {
readonly asOf: IsoDate;
readonly ledger: readonly LedgerEntry[];
readonly settlement: readonly SettlementRow[];
readonly policies?: Readonly<Record<Provider, ProviderPolicy>>;
readonly holidays?: ReadonlySet<IsoDate>;
}export function reconcile(input: ReconcileInput): ReconciliationReport {
const policies = input.policies ?? DEFAULT_POLICIES;
const holidays = input.holidays ?? new Set<IsoDate>();
const { claims, unclaimed } = matchEntries(input.ledger, input.settlement, policies, holidays);
const breaks: Break[] = [];
const matched: MatchedPair[] = [];
let grossSettled = halalas(0);
let netSettled = halalas(0);
let fees = halalas(0);
let unsettled = halalas(0);
for (const claim of claims) {
const { entry, rows } = claim;
const policy = policies[entry.provider];
if (rows.length === 0) {
unsettled = addHalalas(unsettled, entry.grossHalalas);
const deadline = addBusinessDays(entry.capturedOn, policy.settlementSlaBusinessDays, holidays);
const overdue = input.asOf > deadline;
breaks.push({
code: overdue ? 'OVERDUE_IN_SETTLEMENT' : 'MISSING_IN_SETTLEMENT',
provider: entry.provider,
paymentId: entry.paymentId,
rowIds: [],
expectedHalalas: entry.grossHalalas,
actualHalalas: null,
detail: overdue
? `Captured ${entry.capturedOn}, due by ${deadline}, still unsettled on ${input.asOf}.`
: `Captured ${entry.capturedOn}, within SLA until ${deadline}.`,
});
continue;
}
const gross = sumBy(rows, (r) => r.grossHalalas);
const net = sumBy(rows, (r) => r.netHalalas);
const fee = addHalalas(
sumBy(rows, (r) => r.feeHalalas),
sumBy(rows, (r) => r.feeVatHalalas),
);
if (rows.length > 1) {
breaks.push({
code: 'DUPLICATE_SETTLEMENT',
provider: entry.provider,
paymentId: entry.paymentId,
rowIds: rows.map((r) => r.rowId),
expectedHalalas: entry.grossHalalas,
actualHalalas: gross,
detail: `${rows.length} settlement rows point at one payment.`,
});
} else if (Math.abs(gross - entry.grossHalalas) > policy.feeToleranceHalalas) {
breaks.push({
code: 'AMOUNT_MISMATCH',
provider: entry.provider,
paymentId: entry.paymentId,
rowIds: rows.map((r) => r.rowId),
expectedHalalas: entry.grossHalalas,
actualHalalas: gross,
detail: `Ledger and settlement disagree by ${gross - entry.grossHalalas} halalas.`,
});
}
grossSettled = addHalalas(grossSettled, gross);
netSettled = addHalalas(netSettled, net);
fees = addHalalas(fees, fee);
matched.push({
paymentId: entry.paymentId,
tier: claim.tier,
rowIds: rows.map((r) => r.rowId),
grossHalalas: gross,
netHalalas: net,
feeHalalas: fee,
});
}
for (const row of unclaimed) {
breaks.push({
code: 'MISSING_IN_LEDGER',
provider: row.provider,
paymentId: null,
rowIds: [row.rowId],
expectedHalalas: null,
actualHalalas: row.grossHalalas,
detail: `Settled ${row.settledOn} as ${row.kind}, no matching ledger entry.`,
});
}
return {
asOf: input.asOf,
matched,
breaks,
totals: {
grossSettledHalalas: grossSettled,
netSettledHalalas: netSettled,
feesHalalas: fees,
unsettledHalalas: unsettled,
},
};
}asOf هو مدخل صريح بدلاً من استدعاء new Date(). هذا ما يجعل المحرك بأكمله حتمياً: نفس دفتر الأستاذ ونفس الملفات تنتج دائماً نفس التقرير، لذا يمكنك إعادة تشغيل مطابقة الثلاثاء الماضي والحصول على إجابة الثلاثاء الماضي. قراءة الساعة داخل محرك مطابقة يجعله غير قابل للاختبار ويجعل إعادة المعالجة تكذب.
مقارنة التسامح هي Math.abs(gross - entry.grossHalalas) > policy.feeToleranceHalalas، لذا فارق تقريب هللة أو هللتين بين نموذج رسومك ونموذج المستحوذ يمر بصمت، بينما يُكتشف تباين المبلغ الحقيقي. اضبطها على عدة هللات، أبداً على نسبة مئوية.
الخطوة 7: الاسترداد الذي يُسوَّى في دفعة لاحقة
هذه هي الحالة التي تكسر التطبيقات الساذجة، لذا يستحق المرور عليها من البداية إلى النهاية.
عميل يشتري بـ 1,150.00 ريال في الثالث عشر. يُسوَّى في السابع عشر: إجمالي 115000، MDR 1150، ضريبة قيمة مضافة على MDR 173، صافي 113677. في التاسع عشر يُسترد بالكامل، ويُسوَّى ذلك الاسترداد في العشرين — ملف مختلف، دفعة مختلفة، فترة تقرير مختلفة.
يجب أن يكون أمران صحيحين لكي يكون التقرير صائباً.
يجب أن يُشبك الاسترداد مع البيع، عبر الدفعات. المطابقة صف بصف التي تعامل كل ملف بمعزل تبلغ عن البيع كائتمان غير مبرر والاسترداد كأموال غير متطابقة مغادرة. المطابقة هي مركز عبر الزمن، لا فرق لكل ملف.
معدل الخصم التجاري لا يُعاد. عندما تُرجع مبلغاً للعميل، المستحوذ عموماً لا يُعيد معدل الخصم الذي كسبه على البيع الأصلي. لذا الحالة النهائية الصحيحة هي: الإجمالي يشبك إلى صفر، صافي الوضع النقدي هو سالب 1,323 هللة، وهذه الـ 1,323 هي مصاريف رسوم تحملتها.
هذا هو التأكيد في مجموعة الاختبار:
it('nets a refund that settles in a later batch than its sale', () => {
const report = reconcile({
asOf: '2026-08-25',
ledger: [
entry({ paymentId: 'pay_1', providerRef: 'moy_a', provider: 'moyasar' }),
entry({
paymentId: 'pay_2',
providerRef: 'moy_a_r',
provider: 'moyasar',
kind: 'refund',
grossHalalas: halalas(-115000),
capturedOn: '2026-08-19',
}),
],
settlement: [
row({ providerRef: 'moy_a', provider: 'moyasar' }),
row({
rowId: 'MADA-20260820:0',
providerRef: 'moy_a_r',
provider: 'moyasar',
kind: 'refund',
grossHalalas: halalas(-115000),
feeHalalas: halalas(0),
feeVatHalalas: halalas(0),
netHalalas: halalas(-115000),
settledOn: '2026-08-20',
}),
],
});
expect(report.breaks).toHaveLength(0);
expect(report.totals.grossSettledHalalas).toBe(0);
// The sale's MDR is not returned when the customer is refunded.
expect(report.totals.netSettledHalalas).toBe(-1323);
expect(report.totals.feesHalalas).toBe(1323);
});إن أبلغ محركك عن صفر صافٍ في هذا السيناريو، فهو يمتص بصمت مصاريف رسوم ينبغي أن تكون مرئية في قائمة الدخل.
الخطوة 8: ربط المخرجات بالمحاسبة
التقرير هو هيكل بيانات. يصبح مفيداً عندما يقود قيود دفتر الأستاذ.
النمط هو حساب المقاصة. عند الاستحواذ تُقيِّد حساب مقاصة المدفوعات وتُقيِّد الإيراد. عند التسوية تُقيِّد النقد وتُقيِّد المقاصة وتُقيِّد مصاريف الرسوم مع الضريبة القابلة للاسترداد. رصيد حساب المقاصة في أي لحظة ينبغي أن يساوي totals.unsettledHalalas — الأموال التي كسبتها ولم تصل إلى البنك بعد.
هذه التساوية الوحيدة هي أقوى ضابط في النظام بأكمله. عندما يتباعد رصيد المقاصة وإجمالي المحرك غير المُسوَّى، هناك خطأ في أحدهما، وتكتشف ذلك في يوم بدلاً من تدقيق نهاية العام.
أصدر التقرير بجدول بعد وصول ملف كل مزود، وجِّه رموز الخلافات إلى وجهات مختلفة — التسويات المتأخرة للعمليات، والمفقودة في دفتر الأستاذ للهندسة — واحفظ كل تشغيل. التاريخ المحفوظ هو ما يتيح لك إظهار المدقق أن خلافاً اكتُشف في السابع عشر وأُغلق في التاسع عشر.
إجماليات الرسوم تهم أيضاً بما يتجاوز المحاسبة: totals.feesHalalas مقسوماً على totals.grossSettledHalalas، مُتتبَّعاً لكل مزود شهرياً، هو تكلفة قبول الدفع المُمزوجة الحقيقية. معظم التجار يستشهدون بالمعدل في عقدهم. قلة قليلة تعرف الرقم الذي يدفعونه فعلاً، والفارق بين الاثنين هو موقف تفاوضي.
اختبار تطبيقك
المطابقة هو المجال النادر الذي يكون فيه اختبار الوحدة الشامل رخيصاً فعلاً: دوال نقية، مدخلات صحيحة، مخرجات حتمية. المجموعة التي تدعم هذا الدليل هي 30 اختباراً عبر المال والتقويم والاستيعاب والمطابقة، وتعمل في أقل من 10 ميلي ثانية.
npx tsc --noEmit && npx vitest run ✓ src/recon.test.ts (30 tests) 6ms
Test Files 1 passed (1)
Tests 30 passed (30)
الحالات التي يستحق كتابتها أولاً، لأنها التي تفشل في الإنتاج:
it('treats Friday and Saturday as the weekend', () => {
expect(isBusinessDay('2026-08-14', new Set())).toBe(false); // Friday
expect(isBusinessDay('2026-08-15', new Set())).toBe(false); // Saturday
expect(isBusinessDay('2026-08-16', new Set())).toBe(true); // Sunday is a work day
});
it('skips the weekend when computing a T+2 deadline', () => {
// Thursday + 2 business days lands on Monday, not Saturday.
expect(addBusinessDays('2026-08-13', 2)).toBe('2026-08-17');
});
it('refuses to guess between two identical amounts', () => {
const report = reconcile({
asOf: '2026-08-18',
ledger: [entry({ rrn: null })],
settlement: [row({ rrn: null }), row({ rrn: null, rowId: 'MADA-20260817:1' })],
});
expect(report.breaks.map((b) => b.code).sort()).toEqual([
'MISSING_IN_LEDGER',
'MISSING_IN_LEDGER',
'OVERDUE_IN_SETTLEMENT',
]);
});
it('does not cross-match between providers', () => {
const report = reconcile({
asOf: '2026-08-18',
ledger: [entry({ provider: 'tabby', rrn: null, providerRef: null })],
settlement: [row({ rrn: null, providerRef: null })],
});
expect(report.breaks.map((b) => b.code).sort()).toEqual([
'MISSING_IN_LEDGER',
'MISSING_IN_SETTLEMENT',
]);
});الاختبار الثالث يُرمِّز مبدأ التصميم كتأكيد قابل للتنفيذ. ثلاثة خلافات من زوج غامض هو المخرج الصحيح، وكتابته كاختبار يمنع مساهماً مستقبلياً من "تحسين" المطابق إلى التخمين.
بما يتجاوز اختبارات الوحدة، شغِّل فترة ظل قبل أن تثق بالمحرك: طابق شهراً بالتوازي مع ما يفعله الفريق المالي يدوياً، وقارن. كل خلاف يعلمك شيئاً — عادةً فئة خصم أو صف تسوية لم يوثقه أحد. خصص ميزانية لفترة الظل.
استكشاف الأخطاء وإصلاحها
كل صف يكسر الثابت على الرسوم. رسم عمودك خاطئ، أو الملف يبلغ عن الصافي قبل خصم إضافي. اطبع صفاً خاماً واحداً جنباً إلى جنب مع شكله المطبَّع وافعل الحساب يدوياً.
كل شيء غير متطابق بمقدار صغير ثابت. أنت تطبق ضريبة القيمة المضافة على المعاملة بدلاً من الرسم، أو نسبتك في الرسم خاطئة. اقسم التباين على الإجمالي لاستعادة المعدل الذي يفرضه المستحوذ فعلاً.
تنبيهات تأخر كل أحد. مساعد أيام العمل في مكتبة التواريخ يعامل الأحد كعطلة. عطلات نهاية الأسبوع السعودية الجمعة والسبت.
المستوى 3 يطابق كل شيء، المستوى 1 لا يطابق شيئاً. أنت لا تحفظ مرجع المزود وقت الاستحواذ. احفظه في نفس المعاملة التي تسجل الدفعة — هو الفارق بين المطابقة اليقينية والاستنتاج.
طلبات BNPL تُظهر دائماً ثلاثة خلافات وهمية. أنت تطابق مقابل جدول أقساط العميل. التاجر يُدفع له مرة واحدة، بالكامل، مطروحاً منه العمولة.
التقرير صحيح لكن لا أحد يقرأه. أنت تُنبِّه على MISSING_IN_SETTLEMENT. فقط OVERDUE_IN_SETTLEMENT يستحق إشعاراً.
المبالغ تنجرف بهللة واحدة على العكوس. أنت تُقرِّب قيمة موقَّعة. قرِّب المقدار وأعد تطبيق الإشارة.
الخطوات التالية
- أضف مطبِّعاً لكل مزود إضافي — المطابق لا يتغير
- احفظ كل تشغيل حتى تصبح مدة الخلاف، لا عدده فقط، قابلة للقياس
- تتبع التكلفة المُمزوجة لقبول الدفع لكل مزود شهرياً من
totals.feesHalalas - أطعم التقرير في دمج فاتورة ZATCA المرحلة الثانية، حيث المبالغ المُسوَّاة يجب أن تتفق مع المُبلَّغ عنها
- قارن النهج بـ محرك مطابقة اشتراكات GOSI — نفس شكل المطابقة المتدرجة، مجال مختلف
الخلاصة
المطابقة تبدو مشكلة تقرير وهي في الحقيقة مشكلة نمذجة. بمجرد أن يكون المال عدداً صحيحاً، والتقويم يعرف أن عطلة نهاية الأسبوع الجمعة والسبت، والمزودون مطبَّعون عند الحافة، والمطابق يرفض التخمين، يكتب التقرير نفسه — وهو صحيح، وهي الخاصية الوحيدة التي تهم عندما يقرأه مدقق.
القرارات الثلاثة التي تحمل أكبر وزن: تحليل النصوص العشرية دون فاصلة عائمة، جعل asOf مدخلاً بدلاً من قراءة الساعة، ومعاملة المطابقة الغامضة كخلاف. كل شيء آخر هو محاسبة.
الجزء الأصعب نادراً ما يكون الخوارزمية. إنه اكتشاف، ملفاً بعد ملف، فئات الخصم وصفوف التسوية التي لم يوثقها أحد. خصص ميزانية لفترة الظل.
تُطابق يدوياً في نهاية الشهر؟ إن كان فريقك المالي يُطابق ملفات التسوية بالطلبات في جدول بيانات، يمكننا إخبارك في جلسة واحدة بما يحتاجه محرك كهذا للبناء مقابل مزوديك الفعليين وتنسيقات ملفاتهم — وأين تختبئ الخلافات في عمليتك الحالية. تواصل معنا.