الكتابات/tutorial/2026/08
Tutorial11 أغسطس 2026·32 دقيقة

بناء محرك احتساب اشتراكات التأمينات الاجتماعية ومطابقتها باستخدام TypeScript

تعمل المملكة اليوم بنظامَي تأمينات اجتماعية متوازيين، وأحدهما يرتفع كل شهر يوليو. يبني هذا الدليل العملي محرك نِسَب مؤرَّخًا بالتواريخ الفعالة بلغة TypeScript، ويحتسب الأجر الخاضع للاشتراك بسقف 45,000 ريال، ويوزّع اشتراك من التحق منتصف الشهر، ثم يطابق مسير رواتبك مع كشف التأمينات الشهري.

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

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

فالجزء الصعب في التأمينات لم يكن يومًا هو النقل. إنه الحساب — ومنذ 3 يوليو 2024 صار هذا الحساب يجري وفق نظامين في آنٍ واحد، أحدهما يرتفع كل يوليو حتى عام 2028. وفي 1 يوليو 2026، أي قبل خمسة أسابيع من كتابة هذا المقال، تحرّكت النسبة مجددًا. كل نظام رواتب في المملكة خزّن نسبة الاشتراك كقيمة ثابتة صار ينتج أرقامًا خاطئة الآن، ومعظمها لا يعلم بذلك بعد.

يبني هذا الدرس الجزء الذي تملكه فعلًا: محرك اشتراكات مؤرَّخ بالتواريخ الفعالة، وخدمة مطابقة تقارن ما احتسبته بما فوترته عليك التأمينات. أما طبقة النقل فهي مُهايئ صغير عند الحافة، صغيرٌ عن قصد، حتى يكون واجهة وسيط اليوم وملف CSV غدًا دون المساس بالمنطق.

المتطلبات المسبقة

قبل البدء، ينبغي أن يتوفر لديك:

  • Node.js 20+ و TypeScript 5.5+
  • معرفة عملية بالأنواع العامة (generics) والاتحادات المميّزة في TypeScript
  • بيانات رواتب تتضمن لكل موظف: الجنسية، وتاريخ التسجيل في التأمينات، والراتب الأساسي، وبدل السكن، وتواريخ الالتحاق والمغادرة
  • كشف تأمينات لشهر واحد للمطابقة عليه (تصدير PDF أو Excel من بوابة المنشأة يكفي للبداية)
  • إلمام بمكتبة zod أو ما يماثلها للتحقق وقت التشغيل

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

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

مكتبة من أربع طبقات، كل واحدة قابلة للاختبار باستقلال:

  1. جدول نِسَب زمني — نِسَب التأمينات كبيانات مؤرَّخة بتواريخ فعالة، لا كثوابت
  2. حاسبة الأجر الخاضع للاشتراك — الأساسي زائد السكن، بسقف، وموزَّعًا نسبيًا
  3. محرك الاشتراكات — لكل موظف ولكل شهر، مدركًا للنظام، بتقريب دقيق بالهللات
  4. خدمة المطابقة — أرقامك مقابل كشف التأمينات، مع تصنيف الفروقات

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

الخطوة 1: افهم النظامين

كل ما يأتي بعد ذلك يعتمد على ضبط هذا النموذج، لذا يستحق الأمر الدقة قبل كتابة أي سطر.

تُشغّل المملكة نظامَي تأمينات اجتماعية متوازيين، ويتحدد النظام الذي يقع تحته الموظف بـتاريخ أول تسجيل له في التأمينات — لا بصاحب عمله الحالي، ولا بتاريخ عقده، ولا بإعداد على مستوى الشركة.

النظام القائم — من سُجّل أول مرة قبل 3 يوليو 2024. النِسَب مجمّدة:

المكوّنصاحب العملالموظف
المعاشات (التقاعد)9%9%
الأخطار المهنية2%
ساند (التعطل عن العمل)0.75%0.75%
الإجمالي11.75%9.75%

المجموع: 21.5%. وهذا لا يتغير.

النظام الجديد — من سُجّل أول مرة في 3 يوليو 2024 أو بعده. يرتفع مكوّن المعاشات 0.5 نقطة مئوية على كل طرف في كل 1 يوليو، حتى 2028:

الفترةصاحب العملالموظفالمجموع
يناير–يونيو 202612.25%10.25%22.5%
من يوليو 202612.75%10.75%23.5%

أما مكوّنا الأخطار المهنية (2%) وساند (0.75% / 0.75%) فلم يتغيرا في أيٍّ من النظامين — المتحرك هو المعاشات وحده. وهذا التفكيك مهم، لأنه يخبرك أن الزيادة تنطبق على مكوّن واحد لا على الإجمالي، وينبغي أن يقول جدولك ذلك صراحةً.

غير السعوديين حالة ثالثة تمامًا: 2% أخطار مهنية على صاحب العمل فقط، دون خصم من الموظف، ودون معاشات، ودون ساند.

ثلاث ملاحظات تشكّل التصميم:

  • الانتماء إلى النظام صفة للموظف نفسه، دائمة منذ أول تسجيل. فمن يغادر سوق العمل ثم يعود يحتفظ بنظامه الأصلي. وتخزين النظام كإعداد على مستوى الشركة هو أشيع خطأ نمذجة هنا على الإطلاق.
  • تعتمد النسبة على الشهر محل الاحتساب، لا على تاريخ اليوم. فإعادة احتساب مارس 2026 في أغسطس 2026 يجب أن تنتج نسبة مارس. وأي شفرة تقرأ «النسبة الحالية» خاطئة من أول تصحيح تجريه.
  • قد يحتوي مسير رواتب واحد في يوليو 2026 على ثلاثة ملامح نِسَب مشروعة: سعوديون على النظام القائم بـ21.5%، وسعوديون على النظام الجديد بـ23.5%، وغير سعوديين بـ2%.

تحقّق من النِسَب مقابل الجدول الرسمي للتأمينات لفترتك قبل الإطلاق للإنتاج. الأرقام أعلاه صحيحة لعام 2026 ونمط الزيادة محدد حتى 2028، لكن جداول النِسَب هي بالضبط نوع البيانات التي يجب أن تعيش في ملف إعدادات مراجَع وله مالك بالاسم — لا في ذاكرة مطوّر.

الخطوة 2: نمذجة النِسَب كبيانات مؤرَّخة

التصميم كله يدور حول هذه الخطوة. النِسَب ليست ثوابت؛ إنها سلسلة زمنية تستعلم عنها بتاريخ.

// src/rates/types.ts
 
export type Scheme = "existing" | "new" | "non-saudi";
 
export interface RateComponents {
  /** مكوّن المعاشات / التقاعد */
  annuitiesEmployer: number;
  annuitiesEmployee: number;
  /** الأخطار المهنية — على صاحب العمل فقط */
  hazardsEmployer: number;
  /** ساند للتأمين ضد التعطل */
  sanedEmployer: number;
  sanedEmployee: number;
}
 
export interface RateBand {
  scheme: Scheme;
  /** بداية النطاق شاملة، بصيغة ISO */
  effectiveFrom: string;
  /** النهاية غير شاملة. null = مفتوح */
  effectiveTo: string | null;
  components: RateComponents;
  /** المصدر — أي تعميم أو نظام حدّد هذا النطاق */
  source: string;
}

لاحظ الحقل source. حين يسأل مديرك المالي بعد ثمانية عشر شهرًا لماذا تختلف أرقام سبتمبر عن أغسطس، تريد أن يكون الجواب حقلًا في البيانات لا تنقيبًا أثريًا في تاريخ git.

والآن الجدول نفسه:

// src/rates/table.ts
import type { RateBand } from "./types";
 
const NONE = {
  annuitiesEmployer: 0,
  annuitiesEmployee: 0,
  hazardsEmployer: 0,
  sanedEmployer: 0,
  sanedEmployee: 0,
};
 
export const RATE_TABLE: readonly RateBand[] = [
  {
    scheme: "existing",
    effectiveFrom: "2000-01-01",
    effectiveTo: null,
    components: {
      annuitiesEmployer: 0.09,
      annuitiesEmployee: 0.09,
      hazardsEmployer: 0.02,
      sanedEmployer: 0.0075,
      sanedEmployee: 0.0075,
    },
    source: "النظام القائم، دون تغيير لمن سُجّل قبل 2024-07-03",
  },
  {
    scheme: "new",
    effectiveFrom: "2024-07-03",
    effectiveTo: "2025-07-01",
    components: {
      annuitiesEmployer: 0.09,
      annuitiesEmployee: 0.09,
      hazardsEmployer: 0.02,
      sanedEmployer: 0.0075,
      sanedEmployee: 0.0075,
    },
    source: "نظام التأمينات الجديد، السنة الأولى (21.5% مجموعًا)",
  },
  {
    scheme: "new",
    effectiveFrom: "2025-07-01",
    effectiveTo: "2026-07-01",
    components: {
      annuitiesEmployer: 0.095,
      annuitiesEmployee: 0.095,
      hazardsEmployer: 0.02,
      sanedEmployer: 0.0075,
      sanedEmployee: 0.0075,
    },
    source: "النظام الجديد، الزيادة الأولى 0.5 نقطة (22.5% مجموعًا)",
  },
  {
    scheme: "new",
    effectiveFrom: "2026-07-01",
    effectiveTo: "2027-07-01",
    components: {
      annuitiesEmployer: 0.1,
      annuitiesEmployee: 0.1,
      hazardsEmployer: 0.02,
      sanedEmployer: 0.0075,
      sanedEmployee: 0.0075,
    },
    source: "النظام الجديد، الزيادة الثانية (23.5% مجموعًا)",
  },
  {
    scheme: "non-saudi",
    effectiveFrom: "2000-01-01",
    effectiveTo: null,
    components: { ...NONE, hazardsEmployer: 0.02 },
    source: "الأخطار المهنية فقط للمشتركين غير السعوديين",
  },
];

ترميز مكوّن المعاشات صراحةً بدل تخزين employerTotal: 0.1275 يؤتي ثمرته فورًا: إضافة نطاقَي 2027 و2028 تصبح تعديلًا حسابيًا من سطر واحد على حقل واحد، ويبقى صفّا ساند والأخطار المهنية بلا مساس ظاهر للعين — وهذه بحد ذاتها إشارة صحّة أثناء المراجعة.

أما البحث فصارم عن عمد. غياب النطاق خطأ يُرمى، لا صفر صامت:

// src/rates/lookup.ts
import { RATE_TABLE } from "./table";
import type { RateBand, Scheme } from "./types";
 
export function resolveRateBand(scheme: Scheme, periodStart: string): RateBand {
  const band = RATE_TABLE.find(
    (b) =>
      b.scheme === scheme &&
      periodStart >= b.effectiveFrom &&
      (b.effectiveTo === null || periodStart < b.effectiveTo),
  );
 
  if (!band) {
    throw new Error(
      `لا يوجد نطاق نسب للتأمينات للنظام "${scheme}" بتاريخ ${periodStart}. ` +
        `الجدول يحتاج على الأرجح إلى تمديد — راجع جدول النِسَب المعتمد الحالي.`,
    );
  }
  return band;
}

هذا الخطأ المرمي ميزة لا عيب. ففي يناير 2029، ومع عدم وجود نطاق يغطي الفترة، سيتوقف هذا المحرك بدل أن يفوتر بهدوء بنِسَب 2028. والنظام الذي يفشل بصوت عالٍ عند حدٍّ معلوم يساوي أضعاف نظام يواصل إرجاع أرقام معقولة الشكل.

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

الخطوة 3: تحديد نظام الموظف

// src/domain/employee.ts
import { z } from "zod";
 
export const EmployeeSchema = z.object({
  employeeId: z.string().min(1),
  /** رقم الهوية الوطنية (سعودي) أو رقم الإقامة (غير سعودي) */
  identityNumber: z.string().regex(/^[12]\d{9}$/, "يجب أن يكون 10 أرقام تبدأ بـ 1 أو 2"),
  nationality: z.enum(["saudi", "non-saudi"]),
  /** تاريخ أول تسجيل في التأمينات على الإطلاق — لا تاريخ العقد الحالي */
  gosiRegistrationDate: z.string().date(),
  basicSalary: z.number().nonnegative(),
  housingAllowance: z.number().nonnegative(),
  joinedOn: z.string().date(),
  leftOn: z.string().date().nullable(),
});
 
export type Employee = z.infer<typeof EmployeeSchema>;

نمط identityNumber يستحق وقفة: تبدأ الهويات الوطنية السعودية بالرقم 1، وأرقام الإقامة بالرقم 2، وكلاهما عشرة أرقام. هذا التعبير النمطي وحده يلتقط نسبة مفاجئة من مشكلات البيانات الحقيقية — وأشيعها هوية وطنية لُصقت في عمود الإقامة أثناء ترحيل بيانات، فتنتج تعارضًا بين الجنسية والنظام يصعب جدًا رصده في المجاميع.

وقاعدة النظام تتبع مباشرةً:

// src/domain/scheme.ts
import type { Employee } from "./employee";
import type { Scheme } from "../rates/types";
 
const NEW_SCHEME_START = "2024-07-03";
 
export function resolveScheme(employee: Employee): Scheme {
  if (employee.nationality === "non-saudi") return "non-saudi";
  return employee.gosiRegistrationDate >= NEW_SCHEME_START ? "new" : "existing";
}

عشرة أسطر، وهي أكثر دالة أثرًا في المشروع كله. أخطئ في gosiRegistrationDate لموظف واحد وستشترك له بأقل أو أكثر مما يجب كل شهر حتى ينتبه أحد — وهو عمليًا حين تنتبه التأمينات.

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

الخطوة 4: احتساب الأجر الخاضع للاشتراك

الأجر الخاضع للاشتراك ليس إجمالي الراتب. إنه الراتب الأساسي زائد بدل السكن، ولا شيء غيرهما — لا نقل، ولا هاتف، ولا مكافأة، ولا عمل إضافي. ثم يخضع لسقف قدره 45,000 ريال شهريًا.

اعمل بالهللات، لا بالريالات ذات الفاصلة العائمة أبدًا:

// src/domain/wage.ts
import type { Employee } from "./employee";
 
/** 45,000 ريال معبَّرًا عنها بالهللات */
export const CONTRIBUTORY_WAGE_CEILING = 4_500_000;
 
export function toHalalas(riyals: number): number {
  return Math.round(riyals * 100);
}
 
export interface ContributoryWage {
  /** بالهللات، قبل السقف */
  declared: number;
  /** بالهللات، بعد السقف */
  capped: number;
  ceilingApplied: boolean;
}
 
export function contributoryWage(employee: Employee): ContributoryWage {
  const declared = toHalalas(employee.basicSalary) + toHalalas(employee.housingAllowance);
  const capped = Math.min(declared, CONTRIBUTORY_WAGE_CEILING);
  return { declared, capped, ceilingApplied: capped < declared };
}

تحويل كل مكوّن على حدة قبل الجمع مقصود. فـtoHalalas(a + b) وtoHalalas(a) + toHalalas(b) يتباعدان حين يحمل كلا المدخلين خطأ عائمًا دون الهللة، وفروق الرواتب بمقدار هللة واحدة عبر أربعة آلاف موظف هي بالضبط نوع الفرق الذي يكلّف عصرًا كاملًا لتفسيره.

وإظهار ceilingApplied بدل ابتلاعه يمنحك إشارة مطابقة مفيدة لاحقًا: من بلغ السقف من كبار الموظفين ينبغي أن يُظهر اشتراكًا ثابتًا شهرًا بعد شهر، فأي حركة في رقمه مشكلة بيانات بحكم التعريف.

الخطوة 5: توزيع الأشهر الجزئية

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

// src/domain/proration.ts
import type { Employee } from "./employee";
 
export interface Period {
  /** أول يوم في شهر الرواتب، ISO */
  start: string;
  /** آخر يوم في شهر الرواتب، ISO */
  end: string;
}
 
export function daysInPeriod(period: Period): number {
  const [y, m] = period.start.split("-").map(Number);
  return new Date(Date.UTC(y, m, 0)).getUTCDate();
}
 
/** أيام تغطية التأمينات لهذا الموظف داخل الفترة. */
export function coveredDays(employee: Employee, period: Period): number {
  const total = daysInPeriod(period);
  const from = employee.joinedOn > period.start ? employee.joinedOn : period.start;
  const to =
    employee.leftOn !== null && employee.leftOn < period.end ? employee.leftOn : period.end;
 
  if (from > to) return 0;
 
  const fromDay = Number(from.slice(8, 10));
  const toDay = Number(to.slice(8, 10));
  return Math.min(toDay - fromDay + 1, total);
}
 
export function prorationFactor(employee: Employee, period: Period): number {
  return coveredDays(employee, period) / daysInPeriod(period);
}

سلوكان هنا مقصودان وكلاهما يستحق اختبارًا صريحًا. التغطية شاملة للطرفين — التحِق في الأول وغادِر في الثلاثين من شهر مدته 30 يومًا وتكون مغطى 30 يومًا لا 29. ومقارنة «البداية بعد النهاية» التي تُرجع صفرًا تعالج حالة من غادر قبل بدء الفترة أو التحق بعد انتهائها، وهي حالة تتكرر باستمرار في البيانات الحقيقية أثناء التصحيحات بأثر رجعي.

أما صيغة new Date(Date.UTC(y, m, 0)) فتعطيك آخر يوم في الشهر m لأن اليوم صفر من الشهر m+1 يتراجع يومًا واحدًا، والقيمة m مفهرسة من واحد أصلًا في سلسلة ISO. تبدو كخطأ وليست كذلك، ولهذا تستحق تعليقها.

الخطوة 6: محرك الاشتراكات

الآن تتركب القطع:

// src/engine/calculate.ts
import { resolveRateBand } from "../rates/lookup";
import { resolveScheme } from "../domain/scheme";
import { contributoryWage } from "../domain/wage";
import { prorationFactor, type Period } from "../domain/proration";
import type { Employee } from "../domain/employee";
 
export interface ContributionBreakdown {
  annuitiesEmployer: number;
  annuitiesEmployee: number;
  hazardsEmployer: number;
  sanedEmployer: number;
  sanedEmployee: number;
}
 
export interface ContributionResult {
  employeeId: string;
  period: string;
  scheme: string;
  contributoryWage: number;
  ceilingApplied: boolean;
  prorationFactor: number;
  breakdown: ContributionBreakdown;
  employerTotal: number;
  employeeTotal: number;
  grandTotal: number;
  rateSource: string;
}
 
/** حتمي: تقريب النصف بعيدًا عن الصفر، بالهللات. */
function applyRate(wageHalalas: number, rate: number, factor: number): number {
  return Math.round(wageHalalas * rate * factor);
}
 
export function calculateContribution(
  employee: Employee,
  period: Period,
): ContributionResult {
  const scheme = resolveScheme(employee);
  const band = resolveRateBand(scheme, period.start);
  const wage = contributoryWage(employee);
  const factor = prorationFactor(employee, period);
  const c = band.components;
 
  const breakdown: ContributionBreakdown = {
    annuitiesEmployer: applyRate(wage.capped, c.annuitiesEmployer, factor),
    annuitiesEmployee: applyRate(wage.capped, c.annuitiesEmployee, factor),
    hazardsEmployer: applyRate(wage.capped, c.hazardsEmployer, factor),
    sanedEmployer: applyRate(wage.capped, c.sanedEmployer, factor),
    sanedEmployee: applyRate(wage.capped, c.sanedEmployee, factor),
  };
 
  const employerTotal =
    breakdown.annuitiesEmployer + breakdown.hazardsEmployer + breakdown.sanedEmployer;
  const employeeTotal = breakdown.annuitiesEmployee + breakdown.sanedEmployee;
 
  return {
    employeeId: employee.employeeId,
    period: period.start.slice(0, 7),
    scheme,
    contributoryWage: wage.capped,
    ceilingApplied: wage.ceilingApplied,
    prorationFactor: factor,
    breakdown,
    employerTotal,
    employeeTotal,
    grandTotal: employerTotal + employeeTotal,
    rateSource: band.source,
  };
}

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

وإرجاع rateSource مع كل نتيجة هو المكسب الهادئ الآخر. كل سطر محتسب يحمل مصدر القاعدة التي أنتجته، فيصبح الرقم المتنازع عليه قابلًا للإجابة من المخرجات لا من الشفرة.

الخطوة 7: مُهايئ الوصول

هنا يُحتوى واقع الوصول إلى التأمينات عبر الوسطاء. لا تستطيع مناداة التأمينات مباشرة دون اعتماد، والقناة التي تنتهي إليها تحددها ترتيبات تجارية لا هندسية. لذا عرِّف ما تحتاجه ودَعِ القناة قابلة للاستبدال:

// src/access/port.ts
 
export interface GosiStatementLine {
  identityNumber: string;
  contributoryWage: number;
  employerAmount: number;
  employeeAmount: number;
}
 
export interface GosiStatement {
  establishmentId: string;
  period: string;
  lines: GosiStatementLine[];
  totalBilled: number;
}
 
/**
 * السطح الوحيد الذي يعتمد عليه النطاق. قد تكون التطبيقات واجهة وسيط،
 * أو تصديرًا من مزوّد نظام موارد بشرية معتمد، أو تنزيلًا محلَّلًا من البوابة.
 */
export interface GosiAccessPort {
  fetchStatement(establishmentId: string, period: string): Promise<GosiStatement>;
}

ابدأ بالتطبيق الذي لا يتطلب أي اعتماد على الإطلاق:

// src/access/csv-adapter.ts
import { parse } from "csv-parse/sync";
import { toHalalas } from "../domain/wage";
import type { GosiAccessPort, GosiStatement } from "./port";
 
export class CsvStatementAdapter implements GosiAccessPort {
  constructor(private readonly loadFile: (period: string) => Promise<string>) {}
 
  async fetchStatement(establishmentId: string, period: string): Promise<GosiStatement> {
    const raw = await this.loadFile(period);
    const rows = parse(raw, { columns: true, skip_empty_lines: true, bom: true });
 
    const lines = rows.map((r: Record<string, string>) => ({
      identityNumber: r["identity_number"].trim(),
      contributoryWage: toHalalas(Number(r["contributory_wage"])),
      employerAmount: toHalalas(Number(r["employer_amount"])),
      employeeAmount: toHalalas(Number(r["employee_amount"])),
    }));
 
    return {
      establishmentId,
      period,
      lines,
      totalBilled: lines.reduce(
        (sum, l) => sum + l.employerAmount + l.employeeAmount,
        0,
      ),
    };
  }
}

الخيار bom: true ليس زينة. فتصديرات الكشوف التي تمر عبر Excel تحمل روتينيًا علامة ترتيب بايت UTF-8، تفسد بصمت اسم العمود الأول وتحوّل identity_number إلى مفتاح لن تطابقه أبدًا. إنها جلسة تنقيح مدتها خمس عشرة دقيقة تستطيع ببساطة أن ترفض خوضها.

هذا المُهايئ مفيد فعلًا من اليوم الأول — يُنزّل أحدهم الكشف، ويطابقه المحرك، فيتحقق لك أثر قبل أن تبدأ أي محادثة تجارية حول الوصول البرمجي. وحين يصل الوصول المعتمد، تكتب صنفًا ثانيًا مقابل الواجهة نفسها وتغيّر سطر ربط واحدًا. ولا شيء في src/engine أو src/domain يعلم بالفرق.

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

الخطوة 8: خدمة المطابقة

هذه هي الطبقة التي تسدّد ثمنها. لديك أرقامك المحتسبة وأرقام التأمينات المفوترة. والقيمة ليست في «هل تتطابق المجاميع» — بل في أي موظف، ولماذا.

// src/recon/reconcile.ts
import type { ContributionResult } from "../engine/calculate";
import type { GosiStatement } from "../access/port";
import type { Employee } from "../domain/employee";
 
export type VarianceCode =
  | "MISSING_FROM_STATEMENT"
  | "MISSING_FROM_PAYROLL"
  | "WAGE_MISMATCH"
  | "SCHEME_MISMATCH"
  | "AMOUNT_MISMATCH"
  | "ROUNDING_ONLY";
 
export interface Variance {
  code: VarianceCode;
  identityNumber: string;
  employeeId: string | null;
  calculated: number | null;
  billed: number | null;
  deltaHalalas: number;
  explanation: string;
}
 
/** الفروق عند هذا الحد أو دونه ضجيج، لا نتائج. */
const ROUNDING_TOLERANCE = 2;
 
export function reconcile(
  employees: Employee[],
  calculated: ContributionResult[],
  statement: GosiStatement,
): Variance[] {
  const calcByIdentity = new Map<string, ContributionResult>();
  for (const c of calculated) {
    const emp = employees.find((e) => e.employeeId === c.employeeId);
    if (emp) calcByIdentity.set(emp.identityNumber, c);
  }
 
  const variances: Variance[] = [];
  const seen = new Set<string>();
 
  for (const line of statement.lines) {
    seen.add(line.identityNumber);
    const calc = calcByIdentity.get(line.identityNumber);
 
    if (!calc) {
      variances.push({
        code: "MISSING_FROM_PAYROLL",
        identityNumber: line.identityNumber,
        employeeId: null,
        calculated: null,
        billed: line.employerAmount + line.employeeAmount,
        deltaHalalas: line.employerAmount + line.employeeAmount,
        explanation:
          "التأمينات تفوتر مشتركًا غائبًا عن مسير الرواتب هذا. " +
          "غالبًا موظف غادر ولم يُلغَ تسجيله، أو تسجيل تحت منشأة خاطئة.",
      });
      continue;
    }
 
    if (calc.contributoryWage !== line.contributoryWage) {
      variances.push({
        code: "WAGE_MISMATCH",
        identityNumber: line.identityNumber,
        employeeId: calc.employeeId,
        calculated: calc.contributoryWage,
        billed: line.contributoryWage,
        deltaHalalas: calc.contributoryWage - line.contributoryWage,
        explanation:
          "الأجر الخاضع للاشتراك مختلف. التأمينات تحتفظ بآخر أجر بُلّغت به؛ " +
          "والزيادة المطبَّقة في الرواتب ولم تُرفع إلى التأمينات تظهر بهذا الشكل تمامًا.",
      });
      continue;
    }
 
    const billed = line.employerAmount + line.employeeAmount;
    const delta = calc.grandTotal - billed;
 
    if (delta === 0) continue;
 
    if (Math.abs(delta) <= ROUNDING_TOLERANCE) {
      variances.push({
        code: "ROUNDING_ONLY",
        identityNumber: line.identityNumber,
        employeeId: calc.employeeId,
        calculated: calc.grandTotal,
        billed,
        deltaHalalas: delta,
        explanation: "ضمن حدود التقريب. لا إجراء مطلوبًا.",
      });
      continue;
    }
 
    // الأجر نفسه ومبلغ مختلف جوهريًا: النسبة المطبَّقة مختلفة،
    // وهذا يعني غالبًا أن الطرفين يختلفان حول النظام.
    variances.push({
      code: "SCHEME_MISMATCH",
      identityNumber: line.identityNumber,
      employeeId: calc.employeeId,
      calculated: calc.grandTotal,
      billed,
      deltaHalalas: delta,
      explanation:
        `أجر خاضع للاشتراك متطابق مع فرق قدره ${(delta / 100).toFixed(2)} ريال. ` +
        `احتُسب تحت نظام "${calc.scheme}" — تحقق من تاريخ أول تسجيل في التأمينات.`,
    });
  }
 
  for (const calc of calculated) {
    const emp = employees.find((e) => e.employeeId === calc.employeeId);
    if (!emp || seen.has(emp.identityNumber)) continue;
    if (calc.grandTotal === 0) continue;
 
    variances.push({
      code: "MISSING_FROM_STATEMENT",
      identityNumber: emp.identityNumber,
      employeeId: calc.employeeId,
      calculated: calc.grandTotal,
      billed: null,
      deltaHalalas: calc.grandTotal,
      explanation:
        "احتُسب اشتراك لشخص لا تفوتره التأمينات. " +
        "عادةً موظف جديد غير مسجَّل — وهذه مخاطرة تسجيل متأخر، لا وفر.",
    });
  }
 
  return variances;
}

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

وترتيب الفحوص حامل للحمل أيضًا: يُقارَن الأجر قبل المبلغ، لأن اختلاف الأجر يفسّر اختلاف المبلغ، والإبلاغ عنهما معًا يحتسب سببًا جذريًا واحدًا مرتين. وحين يتطابق الأجران ويبقى المبلغان مختلفين، لا يتبقى متغير سوى النسبة، ولهذا فإن SCHEME_MISMATCH هو التشخيص النهائي الصحيح بدلًا من AMOUNT_MISMATCH العام.

الخطوة 9: اختبر الحدود

تتجمّع علل هذا النطاق عند حدود التواريخ، فهناك تذهب الاختبارات.

// src/engine/calculate.test.ts
import { describe, it, expect } from "vitest";
import { calculateContribution } from "./calculate";
import type { Employee } from "../domain/employee";
 
const base: Employee = {
  employeeId: "E-001",
  identityNumber: "1012345678",
  nationality: "saudi",
  gosiRegistrationDate: "2020-01-15",
  basicSalary: 10_000,
  housingAllowance: 2_500,
  joinedOn: "2020-01-15",
  leftOn: null,
};
 
const june2026 = { start: "2026-06-01", end: "2026-06-30" };
const july2026 = { start: "2026-07-01", end: "2026-07-31" };
 
describe("اختيار النظام", () => {
  it("يبقي من سُجّل قبل 2024-07-03 على النظام القائم عبر الزيادة", () => {
    const june = calculateContribution(base, june2026);
    const july = calculateContribution(base, july2026);
    expect(june.grandTotal).toBe(july.grandTotal);
    expect(july.scheme).toBe("existing");
    // 12,500 ريال × 21.5% = 2,687.50 ريال
    expect(july.grandTotal).toBe(268_750);
  });
 
  it("يطبّق زيادة يوليو 2026 على تسجيلات النظام الجديد", () => {
    const newJoiner = { ...base, gosiRegistrationDate: "2025-03-01" };
    const june = calculateContribution(newJoiner, june2026);
    const july = calculateContribution(newJoiner, july2026);
    // من 22.5% إلى 23.5% على 12,500 ريال = 125 ريالًا زيادة
    expect(july.grandTotal - june.grandTotal).toBe(12_500);
    expect(july.grandTotal).toBe(293_750);
  });
 
  it("يعامل 2024-07-03 نفسه كنظام جديد", () => {
    const boundary = { ...base, gosiRegistrationDate: "2024-07-03" };
    expect(calculateContribution(boundary, july2026).scheme).toBe("new");
  });
 
  it("يحمّل غير السعوديين الأخطار المهنية فقط", () => {
    const expat = { ...base, nationality: "non-saudi" as const, identityNumber: "2012345678" };
    const r = calculateContribution(expat, july2026);
    expect(r.employeeTotal).toBe(0);
    expect(r.employerTotal).toBe(25_000); // 12,500 × 2%
  });
});
 
describe("السقف والتوزيع النسبي", () => {
  it("يحدّ الأجر الخاضع للاشتراك عند 45,000 ريال", () => {
    const exec = { ...base, basicSalary: 60_000, housingAllowance: 15_000 };
    const r = calculateContribution(exec, july2026);
    expect(r.ceilingApplied).toBe(true);
    expect(r.contributoryWage).toBe(4_500_000);
  });
 
  it("يوزّع من التحق منتصف الشهر توزيعًا شاملًا للطرفين", () => {
    const joiner = { ...base, joinedOn: "2026-07-17" };
    const r = calculateContribution(joiner, july2026);
    expect(r.prorationFactor).toBeCloseTo(15 / 31); // من 17 إلى 31 شاملًا
  });
 
  it("يُرجع صفرًا لمن غادر قبل الفترة", () => {
    const leaver = { ...base, leftOn: "2026-05-30" };
    expect(calculateContribution(leaver, july2026).grandTotal).toBe(0);
  });
});

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

ثبّت الأرقام الذهبية كقيم حرفية بدل إعادة احتسابها داخل الاختبار. فالقيمة 268_750 المعاد احتسابها من جدول النِسَب نفسه الذي يستخدمه التطبيق لا تثبت إلا أن الشفرة تتفق مع نفسها. أما المكتوبة يدويًا من النسبة المنشورة فتثبت أن الشفرة تتفق مع النظام.

الخطوة 10: اربط الأجزاء

// src/run.ts
import { readFile } from "node:fs/promises";
import { calculateContribution } from "./engine/calculate";
import { CsvStatementAdapter } from "./access/csv-adapter";
import { reconcile } from "./recon/reconcile";
import { EmployeeSchema, type Employee } from "./domain/employee";
 
export async function runMonthlyReconciliation(
  establishmentId: string,
  period: { start: string; end: string },
  rawEmployees: unknown[],
) {
  const employees: Employee[] = rawEmployees.map((r) => EmployeeSchema.parse(r));
 
  const calculated = employees.map((e) => calculateContribution(e, period));
 
  const adapter = new CsvStatementAdapter((p) =>
    readFile(`./statements/${establishmentId}-${p}.csv`, "utf8"),
  );
  const statement = await adapter.fetchStatement(establishmentId, period.start.slice(0, 7));
 
  const variances = reconcile(employees, calculated, statement);
 
  const calculatedTotal = calculated.reduce((s, c) => s + c.grandTotal, 0);
  const actionable = variances.filter((v) => v.code !== "ROUNDING_ONLY");
 
  return {
    period: period.start.slice(0, 7),
    headcount: employees.length,
    calculatedTotal,
    billedTotal: statement.totalBilled,
    difference: calculatedTotal - statement.totalBilled,
    actionable,
    summary: actionable.reduce<Record<string, number>>((acc, v) => {
      acc[v.code] = (acc[v.code] ?? 0) + 1;
      return acc;
    }, {}),
  };
}

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

أما عدّاد summary فهو ما يقرأه مسؤول الرواتب فعليًا. اثنتا عشرة نتيجة MISSING_FROM_STATEMENT تعني اثني عشر موظفًا غير مسجَّل ومخاطرة غرامة حقيقية؛ وأربعون نتيجة ROUNDING_ONLY لا تعني شيئًا على الإطلاق، ولهذا تُرشَّح قبل بناء التقرير.

استكشاف الأخطاء

كل الموظفين يظهرون بفرق صغير في الاتجاه نفسه. استراتيجية التقريب لديك تختلف عن التأمينات. تأكد أنك تقرّب لكل مكوّن لا على الإجمالي، وأنك تقرّب النصف بعيدًا عن الصفر.

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

موظف واحد مفوتر بنسبة لم تحتسبها أبدًا. تاريخ أول تسجيله في سجلاتك يخالف تاريخ التأمينات. وتاريخ التأمينات هو المرجع؛ صحّح تاريخك.

المجاميع تتطابق والأسطر الفردية لا. موظفان تبادلا مواقعهما، غالبًا عبر رقم هوية مكرر أو مقلوب. وفحص البادئة [12] بعشرة أرقام يلتقط معظم هذه الحالات عند الإدخال.

أرقام يوليو قفزت للجميع. ثابت نسبة واحد في مكان ما، مطبَّق دون اعتبار للنظام. وهذا هو الإخفاق الذي وُجد هذا التصميم كله لمنعه — ابحث عن أي قيم نِسَب حرفية خارج RATE_TABLE.

المحرك يرمي No GOSI rate band. يعمل كما صُمم. مدّد الجدول بالنِسَب المنشورة الحالية وسجّل مصدرها.

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

  • أضف نطاقَي 2027 و2028 الآن، ونمط الزيادة أمامك، بدل اكتشاف الفجوة في يوليو المقبل
  • احفظ كل تشغيل حتى تستطيع المقارنة شهرًا بشهر — فأجر خاضع للاشتراك يتحرك دون زيادة مقابلة هو نتيجة تتعلق بسلامة البيانات
  • أصدر المطابقة كتقرير مفهرس بالموظف لا بالإجمالي، ليُسلَّم إلى الموارد البشرية ويُعمل به مباشرة
  • وسّع GosiAccessPort بتطبيق ثانٍ حين يصل الوصول المعتمد، واحتفظ بمُهايئ CSV بديلًا للاختبار
  • طابق حالة التسجيل في التأمينات مع ملف حماية الأجور — فالموظف الموجود في أحدهما دون الآخر نتيجة في كلا النظامين

قراءات ذات صلة على هذا الموقع:

الخاتمة

غياب واجهة برمجية عامة للتأمينات يُقرأ كعائق، وهو في الحقيقة توضيح. فهو يخبرك أين لا تكمن القيمة الهندسية: في النقل، وهو ترتيب تجاري سيبيعه لك غيرك. ويخبرك أين تكمن: في منطق النِسَب، وقواعد الأجر، والمطابقة — وكلها تملكها ملكية تامة، وتستطيع اختبارها دون اتصال، وبناءها قبل أن تبدأ أي محادثة اعتماد.

والتصميم المترتب على ذلك صغير. النِسَب بيانات مؤرَّخة بتواريخ فعالة ومصدر مسجَّل، لا ثوابت. والنظام صفة دائمة للموظف تُحلّ من تاريخ أول تسجيل له. والمال هللات صحيحة، تُقرَّب لكل مكوّن. والوصول واجهة بدالة واحدة عند الحافة مع تطبيق CSV يعمل اليوم. والمطابقة تمشي على الجانبين، لأن الموظف الغائب عن الكشف هو من يكلّفك المال.

نظامان يعملان بالتوازي، ونسبة تتحرك كل يوليو حتى 2028، وتعريف للأجر يتجاهل معظم ما يسميه مسير رواتبك راتبًا — كان هذا دائمًا مسألة حساب. وزيادة يوليو 2026 قد حدثت بالفعل. والسؤال الجدير بالإجابة هذا الشهر هو: هل كانت أرقام يوليو لديك صحيحة؟ والمطابقة أعلاه تجيب عنه في فترة بعد الظهر.


إذا كانت أرقام التأمينات وأرقام رواتبك قد توقّفت بهدوء عن التطابق، فالتشخيص قصير: بيانات رواتب شهر واحد، وكشف تأمينات واحد، وتشغيل مطابقة. تحدّث إلينا عن مراجعة تكامل — وسنخبرك إن كانت المشكلة في منطق النِسَب لديك، أو في بيانات التسجيل، أو في تعريف الأجر قبلهما.