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

بوابة الرقم الضريبي للمورد في الحسابات الدائنة بـ TypeScript

ابنِ الفحص في مكانه الصحيح داخل دورة الحسابات الدائنة، لا في جدول ربع سنوي: قاعدة الصيغة BR-KSA-40 من هيئة الزكاة والضريبة والجمارك عند تسجيل المورد، واستعلام مؤرَّخ عن التسجيل قبل كل إقرار، وفكّ رمز QR في كل فاتورة واردة ومطابقته بالدفاتر، وبوابة لضريبة المدخلات تعلّق الفاتورة المشكوك فيها بدل أن تخسرها، لأن المادة 49(8) تترك خمس سنوات لخصمها.

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

القاعدة التي تقف وراء هذه الكلفة قصيرة. تجيز المادة 49(7) من اللائحة التنفيذية لنظام ضريبة القيمة المضافة خصم ضريبة المدخلات فقط إذا كان لدى الشخص الخاضع للضريبة الدليل الذي تحدده الاتفاقية الخليجية، وهو في الواقع فاتورة ضريبية صحيحة. وتعدّد المادة 53(5) ما يجب أن تتضمنه هذه الفاتورة، ومنه رقم التعريف الضريبي للمورد واسم وعنوان كلٍّ من المورد والعميل. الرقم الخاطئ، أو الرقم العائد لجهة غير مسجلة، يُسقط الخصم معه.

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

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

  • Node.js 20 أو أحدث، وTypeScript في الوضع الصارم
  • tsx لتشغيل الاختبارات (npm i -D tsx typescript @types/node)
  • نظام حسابات دائنة يمكنك الربط به: سجل موردين، وصندوق وارد للفواتير، وخطوة إعداد الإقرار. محرك إقرار ضريبة القيمة المضافة السعودي هو خطوة الإقرار التي تغذيها هذه البوابة
  • إلمام أساسي بالفوترة الإلكترونية. إذا كنت تُصدر فواتير، فإن درس تكامل المرحلة الثانية يغطي الجهة الأخرى من رمز QR الذي نقرؤه هنا

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

أربع وحدات، كل منها تجيب عن سؤال واحد:

  1. vatNumber.ts: هل هذا النص على شكل رقم ضريبي سعودي أصلًا؟ القاعدة هي BR-KSA-40 من الهيئة، ولا شيء أكثر.
  2. supplier.ts: هل هذا المورد مسجل، وبحسب أي مصدر، ومتى فُحص آخر مرة؟ وتُخرج أيضًا قائمة الأرقام التي تُلصق في الاستعلام الجماعي لدى الهيئة.
  3. qr.ts: ماذا يقول رمز QR في الفاتورة الواردة فعلًا؟ تفكّ حمولة TLV المعرّفة في معايير تطبيق ميزات الأمان الصادرة عن الهيئة.
  4. gate.ts: بناءً على كل ما سبق، هل تدخل ضريبة مدخلات هذه الفاتورة في الإقرار، أم تنتظر، أم لا تدخل أبدًا؟

مخرجات البوابة أحد ثلاثة قرارات: deduct أو hold أو no_deduction. الفرق بين الأخيرين هو الفكرة الأساسية في هذا الدرس، وتشرح الخطوة السادسة سبب أهميته.

الخطوة 1: القاعدة الوحيدة المنشورة

ينصّ معيار تطبيق الفاتورة الإلكترونية بصيغة XML الصادر عن الهيئة (الإصدار 1.2، مايو 2023) على قاعدة صيغة رقم البائع في BR-KSA-40، ونصّها في الإصدار الإنجليزي: «must contain 15 digits. The first and the last digits are "3"»، أي 15 رقمًا يكون أولها وآخرها 3. وتطبّق BR-KSA-44 القاعدة نفسها على رقم المشتري متى وُجد ولم تكن الفاتورة فاتورة تصدير.

هذا كل ما تنشره الهيئة عن الصيغة. ستجد مقالات تصف خانة تحقق ومقاطع داخلية للرقم، لكن الهيئة لا تنشر خوارزمية للتحقق، فأي شيفرة تدّعي التحقق منها تخمّن. المدقق أدناه يطبّق BR-KSA-40 ويتوقف عندها.

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

// src/vatNumber.ts
// BR-KSA-40 (XML Implementation Standard v1.2): the seller VAT number
// "must contain 15 digits. The first and the last digits are '3'".
// That is the whole published rule. Nothing here checks more than that.
 
export type VatNumberResult =
  | { ok: true; value: string }
  | { ok: false; reason: VatNumberError; value: string };
 
export type VatNumberError =
  | "EMPTY"
  | "NOT_DIGITS"
  | "LOOKS_LIKE_CR" // 10 digits: almost always a commercial registration number
  | "WRONG_LENGTH"
  | "FIRST_OR_LAST_NOT_3";
 
const ARABIC_INDIC = "٠١٢٣٤٥٦٧٨٩";
const EXTENDED_ARABIC_INDIC = "۰۱۲۳۴۵۶۷۸۹";
 
export function normalizeVatNumber(raw: string): string {
  let out = "";
  for (const ch of raw.trim()) {
    const a = ARABIC_INDIC.indexOf(ch);
    const e = EXTENDED_ARABIC_INDIC.indexOf(ch);
    if (a >= 0) out += String(a);
    else if (e >= 0) out += String(e);
    else if (ch === " " || ch === "-" || ch === " ") continue;
    else out += ch;
  }
  return out;
}
 
export function checkVatNumber(raw: string): VatNumberResult {
  const value = normalizeVatNumber(raw);
  if (value === "") return { ok: false, reason: "EMPTY", value };
  if (!/^[0-9]+$/.test(value)) return { ok: false, reason: "NOT_DIGITS", value };
  if (value.length === 10) return { ok: false, reason: "LOOKS_LIKE_CR", value };
  if (value.length !== 15) return { ok: false, reason: "WRONG_LENGTH", value };
  if (value[0] !== "3" || value[14] !== "3") {
    return { ok: false, reason: "FIRST_OR_LAST_NOT_3", value };
  }
  return { ok: true, value };
}

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

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

الخطوة 2: سجل الموردين يرفض الأرقام الخاطئة

أرخص مكان لإيقاف رقم خاطئ هو النموذج الذي يُنشئ المورد. فبمجرد حفظ الرقم ترثه كل فاتورة من ذلك المورد، وتُقارن به كل الفحوص اللاحقة.

// src/supplier.ts
import { checkVatNumber, VatNumberError } from "./vatNumber";
 
// Only ZATCA's registration lookup can say a number is registered.
// The supplier record keeps what was checked, when, and how.
 
export type Registration =
  | { status: "unchecked" }
  | { status: "registered"; checkedOn: string; evidence: string }
  | { status: "not_registered"; checkedOn: string; evidence: string };
 
export interface Supplier {
  id: string;
  registeredName: string; // as shown by the ZATCA lookup
  vatNumber: string; // normalised, passed checkVatNumber
  registration: Registration;
}
 
export type CreateResult =
  | { ok: true; supplier: Supplier }
  | { ok: false; reason: VatNumberError };
 
// The gate at onboarding: a malformed number is never saved.
export function createSupplier(id: string, registeredName: string, rawVat: string): CreateResult {
  const vat = checkVatNumber(rawVat);
  if (!vat.ok) return { ok: false, reason: vat.reason };
  return {
    ok: true,
    supplier: { id, registeredName, vatNumber: vat.value, registration: { status: "unchecked" } },
  };
}
 
// Our policy, not a ZATCA rule: a registration check is fresh for the
// period being filed only if it was made on or after the period start.
export function needsLookup(s: Supplier, periodStart: string): boolean {
  return s.registration.status === "unchecked" || s.registration.checkedOn < periodStart;
}
 
// One column of numbers, to paste into the template ZATCA's lookup
// service offers for checking several establishments at once.
export function lookupBatch(suppliers: Supplier[], periodStart: string): string[] {
  const out = new Set<string>();
  for (const s of suppliers) if (needsLookup(s, periodStart)) out.add(s.vatNumber);
  return [...out].sort();
}
 
export interface LookupRow {
  vatNumber: string;
  registered: boolean;
}
 
export function applyLookup(
  suppliers: Supplier[],
  rows: LookupRow[],
  checkedOn: string,
  evidence: string,
): Supplier[] {
  const byNumber = new Map(rows.map((r) => [r.vatNumber, r.registered]));
  return suppliers.map((s) => {
    const registered = byNumber.get(s.vatNumber);
    if (registered === undefined) return s;
    return {
      ...s,
      registration: registered
        ? { status: "registered", checkedOn, evidence }
        : { status: "not_registered", checkedOn, evidence },
    };
  });
}

ثلاثة قرارات تصميمية تستحق الذكر:

  • createSupplier تُعيد خطأً بدل المورد. وبهذا يُجبر نظام الأنواع شاشةَ التسجيل على معالجة الفشل. لا يوجد مسار يُحفظ فيه رقم معيب «لتصحيحه لاحقًا».
  • registration دليل لا علامة. القيمة المنطقية isRegistered لا تقول للمدقق شيئًا. أما الحالة مع checkedOn وevidence (اسم ملف نتيجة الاستعلام، أو مرجع لصورة شاشة محفوظة) فتقول له ماذا فُحص، ومتى، وكيف.
  • حداثة الفحص سياستنا، لا قاعدة من الهيئة. تعتبر needsLookup الفحص ساريًا فقط إذا جرى في بداية الفترة التي يُعدّ إقرارها أو بعدها. التسجيل قد يُلغى، ففحص من العام الماضي لا يثبت شيئًا عن هذا الربع. إن كان حجم تعاملاتك صغيرًا يمكنك تخفيف ذلك، المهم أن تكتب ما اخترته.

الخطوة 3: التسجيل استعلام له تاريخ

تقبل خدمة «التحقق من التسجيل في ضريبة القيمة المضافة» لدى الهيئة ثلاثة أنواع من البحث: الرقم الضريبي، أو رقم شهادة ضريبة القيمة المضافة، أو الرقم الموحد للسجل التجاري. وللتحقق من أكثر من منشأة، تتيح الصفحة نفسها قالب xlsx يُنزَّل ويُعبّأ ثم يُرفع.

تُخرج lookupBatch عمود الأرقام لذلك القالب، ويشمل فقط الموردين الذين لم يُفحصوا في هذه الفترة. لا نعيد هنا إنتاج تخطيط القالب، فالهيئة تملكه ويمكنها تغييره، لذا خذ القالب الحالي من صفحة الخدمة في كل مرة.

عند عودة النتائج، تسجّلها applyLookup على كل مورد مع التاريخ واسم ملف الدليل. المورد الذي لا يظهر رقمه في النتائج يحتفظ بحالته السابقة، فغيابه عن دفعة ما ليس دليلًا على شيء.

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

الخطوة 4: قراءة رمز QR فيما تستلمه

كل فاتورة إلكترونية سعودية تحمل رمز QR. وتعرّفه معايير تطبيق ميزات الأمان الصادرة عن الهيئة (القسم 4): نص Base64 لسجلات Tag-Length-Value، الوسم فيها بايت واحد، والطول بايت واحد، والقيمة هي بايتات UTF-8 للحقل. ويحدد الجدول في ذلك القسم الوسوم:

الوسمالحقلإلزامي منذ
1اسم البائع4 ديسمبر 2021
2رقم تسجيل البائع في ضريبة القيمة المضافة4 ديسمبر 2021
3الطابع الزمني للفاتورة بصيغة ISO 86014 ديسمبر 2021
4إجمالي الفاتورة شاملًا الضريبة4 ديسمبر 2021
5إجمالي ضريبة القيمة المضافة4 ديسمبر 2021
6تجزئة (Hash) فاتورة XML1 يناير 2023
7توقيع ECDSA للتجزئة1 يناير 2023
8المفتاح العام ECDSA1 يناير 2023
9توقيع ختم الهيئة (للفواتير المبسطة وإشعاراتها)1 يناير 2023

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

// src/qr.ts
// Reads the QR payload of a Saudi e-invoice (Security Features
// Implementation Standards, section 4): Base64 of TLV records, one byte
// for the tag, one byte for the length, then the value.
// Tags 1-5 are UTF-8 text. Tags 6-9 (hash, signature, public key, ZATCA
// stamp signature) are kept as raw bytes: this reader does not verify them.
 
export interface ZatcaQr {
  sellerName: string; // tag 1
  sellerVatNumber: string; // tag 2
  timestamp: string; // tag 3, ISO 8601
  totalWithVat: string; // tag 4
  vatTotal: string; // tag 5
  phase2Tags: number[]; // which of 6-9 are present
}
 
export class QrError extends Error {}
 
export function decodeZatcaQr(base64: string): ZatcaQr {
  const bytes = Buffer.from(base64.trim(), "base64");
  if (bytes.length === 0) throw new QrError("QR payload is empty or not Base64");
 
  const text = new Map<number, string>();
  const binary: number[] = [];
  let i = 0;
  while (i < bytes.length) {
    if (i + 2 > bytes.length) throw new QrError(`truncated header at byte ${i}`);
    const tag = bytes[i];
    const len = bytes[i + 1];
    const start = i + 2;
    const end = start + len;
    if (end > bytes.length) throw new QrError(`tag ${tag} runs past the end`);
    if (tag >= 1 && tag <= 5) {
      if (text.has(tag)) throw new QrError(`tag ${tag} appears twice`);
      text.set(tag, bytes.subarray(start, end).toString("utf8"));
    } else if (tag >= 6 && tag <= 9) {
      binary.push(tag);
    } else {
      throw new QrError(`unknown tag ${tag}`);
    }
    i = end;
  }
 
  for (const t of [1, 2, 3, 4, 5]) {
    if (!text.has(t)) throw new QrError(`mandatory tag ${t} is missing`);
  }
  const timestamp = text.get(3)!;
  if (Number.isNaN(Date.parse(timestamp))) {
    throw new QrError(`tag 3 is not a date: ${timestamp}`);
  }
 
  return {
    sellerName: text.get(1)!,
    sellerVatNumber: text.get(2)!,
    timestamp,
    totalWithVat: text.get(4)!,
    vatTotal: text.get(5)!,
    phase2Tags: binary,
  };
}
 
// "1150.00" -> 115000. Integer halalas, never floats.
export function toHalalas(amount: string): number {
  const m = /^(\d+)(?:\.(\d{1,2}))?$/.exec(amount.trim());
  if (!m) throw new QrError(`not an amount: ${amount}`);
  return Number(m[1]) * 100 + Number((m[2] ?? "").padEnd(2, "0"));
}

فاكّ الترميز صارم عمدًا. السجل المبتور، أو الوسم النصي المكرر، أو الوسم غير المعروف، يرمي QrError، وتحوّل البوابة هذا الخطأ إلى hold وليس إلى deduct أبدًا. الوسوم من 6 إلى 9 تُسجَّل على أنها موجودة أو غائبة لكن لا يُتحقق منها. التحقق من التوقيع يحتاج إلى سلسلة الشهادات، وتطبيق الهيئة لضريبة القيمة المضافة يقوم بذلك أصلًا عند مسح الرمز. ما يعطيك إياه هذا القارئ هو الحقول النصية الخمسة، التي يمكنك مطابقتها بالدفاتر آليًا.

تحوّل toHalalas المبالغ إلى هللات صحيحة دون المرور بأي عدد عشري. فالقيمة 1150.10 كعدد عشري ليست 1150.10 تمامًا، والمطابقة المبنية على الأعداد العشرية تنتج فروقًا لا يستطيع أحد تفسيرها.

الخطوة 5: البوابة

تقارن البوابة أربعة مصادر يُفترض أن تتطابق: الرقم المنقول من رأس الفاتورة، وسجل الموردين، ودليل التسجيل، ورمز QR. وكل ما يختلف يصبح سببًا مسمّى.

// src/gate.ts
import { checkVatNumber } from "./vatNumber";
import { decodeZatcaQr, QrError, toHalalas } from "./qr";
import { Supplier, needsLookup } from "./supplier";
 
export interface InboundInvoice {
  supplierId: string;
  kind: "tax" | "simplified";
  printedSellerVat: string; // as typed from the invoice header
  supplyDate: string; // YYYY-MM-DD
  totalWithVat: number; // halalas, as booked
  vat: number; // halalas, as booked
  qr?: string; // Base64 payload, if one was scanned
}
 
export type Decision =
  | { action: "deduct"; notes: string[] }
  | { action: "hold"; reasons: string[] }
  | { action: "no_deduction"; reasons: string[] };
 
// IR Art. 49(8): no deduction "in any period falling after five years
// from the calendar year in which the supply took place". Our reading:
// the last eligible period must end on or before 31 December of year + 5.
export function lastDeductibleDay(supplyDate: string): string {
  const year = Number(supplyDate.slice(0, 4));
  return `${year + 5}-12-31`;
}
 
// Heuristic, ours: enough to catch a different company, not a legal test.
export function sameName(a: string, b: string): boolean {
  const norm = (s: string) =>
    s
      .normalize("NFKC")
      .replace(/[ً-ْـ]/g, "") // harakat and tatweel
      .replace(/[أإآ]/g, "ا")
      .replace(/ة/g, "ه")
      .replace(/ى/g, "ي")
      .toLowerCase()
      .replace(/[^\p{L}\p{N}]+/gu, " ")
      .trim();
  return norm(a) === norm(b);
}
 
export function assessInvoice(
  inv: InboundInvoice,
  supplier: Supplier,
  periodStart: string,
  periodEnd: string,
): Decision {
  if (periodEnd > lastDeductibleDay(inv.supplyDate)) {
    return { action: "no_deduction", reasons: ["OUT_OF_TIME_ART_49_8"] };
  }
  if (supplier.registration.status === "not_registered") {
    return { action: "no_deduction", reasons: ["SUPPLIER_NOT_REGISTERED"] };
  }
 
  const reasons: string[] = [];
  const notes: string[] = [];
 
  const printed = checkVatNumber(inv.printedSellerVat);
  if (!printed.ok) reasons.push(`PRINTED_VAT_${printed.reason}`);
  else if (printed.value !== supplier.vatNumber) reasons.push("PRINTED_VAT_NOT_SUPPLIER_VAT");
 
  if (needsLookup(supplier, periodStart)) reasons.push("REGISTRATION_NOT_CHECKED_THIS_PERIOD");
 
  if (!inv.qr) {
    reasons.push("NO_QR_SCANNED");
  } else {
    try {
      const qr = decodeZatcaQr(inv.qr);
      if (qr.sellerVatNumber !== supplier.vatNumber) reasons.push("QR_VAT_NOT_SUPPLIER_VAT");
      if (!sameName(qr.sellerName, supplier.registeredName)) reasons.push("QR_NAME_NOT_REGISTERED_NAME");
      if (toHalalas(qr.totalWithVat) !== inv.totalWithVat) reasons.push("QR_TOTAL_NOT_BOOKED_TOTAL");
      if (toHalalas(qr.vatTotal) !== inv.vat) reasons.push("QR_VAT_NOT_BOOKED_VAT");
      if (qr.phase2Tags.length === 0) notes.push("QR_HAS_NO_PHASE2_TAGS");
    } catch (e) {
      if (!(e instanceof QrError)) throw e;
      reasons.push(`QR_UNREADABLE: ${e.message}`);
    }
  }
 
  if (inv.kind === "simplified") notes.push("SIMPLIFIED_INVOICE_ART_49_7_A");
 
  return reasons.length > 0 ? { action: "hold", reasons } : { action: "deduct", notes };
}

كيف تقرر:

  • no_deduction يحدث في حالتين فقط. المورد فُحص وتبيّن أنه غير مسجل، فلا توجد فاتورة ضريبية صحيحة. أو أن الفترة تتجاوز مهلة المادة 49(8). ولا يمكن إصلاح أيٍّ منهما بتصحيح حقل.
  • hold يشمل كل ما قد يكون خطأ كتابة، أو خللًا في قالب، أو فحصًا قديمًا: رقم في الرأس لا يجتاز BR-KSA-40 أو لا يطابق المورد، أو رمز QR يختلف فيه رقم البائع أو الإجمالي أو الضريبة عمّا سُجّل، أو عدم مسح أي رمز، أو عدم فحص التسجيل في هذه الفترة.
  • deduct يعني أن المصادر الأربعة متطابقة. وقد يحمل ملاحظات مع ذلك. QR_HAS_NO_PHASE2_TAGS تنبّه إلى رمز لا يحمل سوى حقول المرحلة الأولى الخمسة. وSIMPLIFIED_INVOICE_ART_49_7_A تنبّه إلى فاتورة مبسطة، وهي دليل بديل تقبله المادة 49(7)(أ) متى صدرت بشكل صحيح. وتعدّد المادة 53(8) ما يجب أن تتضمنه، ومنه اسم المورد وعنوانه ورقم تعريفه الضريبي.

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

هناك فحص تُرك خارج الشيفرة عمدًا. تشترط المادة 53(5)(هـ) أن تحمل الفاتورة اسمك وعنوانك أيضًا، ورمز QR لا يرمّز المشتري. مكان هذا الفحص هو لحظة إدخال الفاتورة، مقابل شهادة تسجيلك أنت. أضفه هناك لا هنا.

الخطوة 6: المعلَّق لا يضيع

سبب فصل hold عن no_deduction هو المادة 49(8): للشخص الخاضع للضريبة خصم ضريبة المدخلات في فترة ضريبية لاحقة للفترة التي تشمل تاريخ التوريد، شريطة أن يكون له الحق في الخصم، لكن «لا يجوز خصم ضريبة المدخلات في أي فترة تقع بعد خمس سنوات من السنة التقويمية التي وقع فيها التوريد».

قراءتنا، التي تصرّح بها الشيفرة في lastDeductibleDay: آخر فترة يمكن أن تحمل الخصم يجب أن تنتهي في 31 ديسمبر من سنة التوريد مضافًا إليها خمس سنوات أو قبله. توريد في أكتوبر 2026 يمكن خصمه في إقرار عن فترة تنتهي في 31 ديسمبر 2031.

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

عند رفع التعليق عن فاتورة، تدخل في إقرار الفترة التي رُفع فيها التعليق. يضعها محرك الإقرار في خانة المشتريات الخاضعة للنسبة الأساسية لتلك الفترة، لا بإعادة فتح الفترة التي جاءت منها.

الخطوة 7: اختبار التنفيذ

// tests/gate.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { checkVatNumber } from "../src/vatNumber";
import { decodeZatcaQr, QrError, toHalalas } from "../src/qr";
import { createSupplier, lookupBatch, applyLookup, Supplier } from "../src/supplier";
import { assessInvoice, lastDeductibleDay, InboundInvoice } from "../src/gate";
 
function qr(fields: [number, string][]): string {
  const parts = fields.map(([tag, value]) => {
    const v = Buffer.from(value, "utf8");
    return Buffer.concat([Buffer.from([tag, v.length]), v]);
  });
  return Buffer.concat(parts).toString("base64");
}
 
const VAT = "310122393500003";
const NAME = "شركة المورد للتجارة";
const goodQr = qr([
  [1, NAME],
  [2, VAT],
  [3, "2026-10-12T09:30:00Z"],
  [4, "1150.00"],
  [5, "150.00"],
]);
 
function supplier(): Supplier {
  const r = createSupplier("S1", NAME, VAT);
  if (!r.ok) throw new Error(r.reason);
  return applyLookup([r.supplier], [{ vatNumber: VAT, registered: true }], "2026-10-02", "lookup-2026-10-02.xlsx")[0];
}
 
const invoice: InboundInvoice = {
  supplierId: "S1",
  kind: "tax",
  printedSellerVat: VAT,
  supplyDate: "2026-10-12",
  totalWithVat: 115000,
  vat: 15000,
  qr: goodQr,
};
 
test("BR-KSA-40: 15 digits, first and last are 3", () => {
  assert.equal(checkVatNumber("310122393500003").ok, true);
  assert.equal(checkVatNumber("٣١٠١٢٢٣٩٣٥٠٠٠٠٣").ok, true);
  assert.equal(checkVatNumber("310 1223 9350 0003").ok, true);
  assert.deepEqual(checkVatNumber("1010123456"), { ok: false, reason: "LOOKS_LIKE_CR", value: "1010123456" });
  assert.equal((checkVatNumber("31012239350000") as { reason: string }).reason, "WRONG_LENGTH");
  assert.equal((checkVatNumber("310122393500001") as { reason: string }).reason, "FIRST_OR_LAST_NOT_3");
  assert.equal((checkVatNumber("31012239350000O") as { reason: string }).reason, "NOT_DIGITS");
});
 
test("a malformed number is never saved to the supplier master", () => {
  assert.deepEqual(createSupplier("S2", "x", "7001234567"), { ok: false, reason: "LOOKS_LIKE_CR" });
});
 
test("QR: reads tags 1-5 and notes phase 2 tags", () => {
  const withHash = qr([[1, NAME], [2, VAT], [3, "2026-10-12T09:30:00Z"], [4, "1150.00"], [5, "150.00"], [6, "x".repeat(44)]]);
  const d = decodeZatcaQr(withHash);
  assert.equal(d.sellerName, NAME);
  assert.equal(d.sellerVatNumber, VAT);
  assert.deepEqual(d.phase2Tags, [6]);
});
 
test("QR: missing tag, truncation and bad Base64 are errors", () => {
  assert.throws(() => decodeZatcaQr(qr([[1, NAME], [2, VAT], [3, "2026-10-12T09:30:00Z"], [4, "1150.00"]])), QrError);
  const cut = Buffer.from(goodQr, "base64").subarray(0, 20).toString("base64");
  assert.throws(() => decodeZatcaQr(cut), QrError);
  assert.throws(() => decodeZatcaQr(""), QrError);
});
 
test("amounts are compared in halalas", () => {
  assert.equal(toHalalas("1150.00"), 115000);
  assert.equal(toHalalas("1150.5"), 115050);
  assert.equal(toHalalas("1150"), 115000);
  assert.throws(() => toHalalas("1,150.00"), QrError);
});
 
test("lookup batch lists only numbers not checked this period", () => {
  const s = supplier();
  assert.deepEqual(lookupBatch([s], "2026-10-01"), []);
  assert.deepEqual(lookupBatch([s], "2027-01-01"), [VAT]);
});
 
test("a clean invoice is deducted", () => {
  const d = assessInvoice(invoice, supplier(), "2026-10-01", "2026-12-31");
  assert.equal(d.action, "deduct");
});
 
test("QR seller VAT that differs from the supplier is held", () => {
  const other = qr([[1, NAME], [2, "300000000000003"], [3, "2026-10-12T09:30:00Z"], [4, "1150.00"], [5, "150.00"]]);
  const d = assessInvoice({ ...invoice, qr: other }, supplier(), "2026-10-01", "2026-12-31");
  assert.deepEqual(d, { action: "hold", reasons: ["QR_VAT_NOT_SUPPLIER_VAT"] });
});
 
test("booked VAT that differs from the QR is held", () => {
  const d = assessInvoice({ ...invoice, vat: 15100 }, supplier(), "2026-10-01", "2026-12-31");
  assert.deepEqual(d, { action: "hold", reasons: ["QR_VAT_NOT_BOOKED_VAT"] });
});
 
test("no QR, stale lookup and a CR number in the header all hold", () => {
  const d = assessInvoice({ ...invoice, qr: undefined, printedSellerVat: "1010123456" }, supplier(), "2027-01-01", "2027-03-31");
  assert.equal(d.action, "hold");
  assert.deepEqual((d as { reasons: string[] }).reasons, [
    "PRINTED_VAT_LOOKS_LIKE_CR",
    "REGISTRATION_NOT_CHECKED_THIS_PERIOD",
    "NO_QR_SCANNED",
  ]);
});
 
test("names are compared after Arabic normalisation", () => {
  const variant = qr([[1, "شركه المورد  للتجاره"], [2, VAT], [3, "2026-10-12T09:30:00Z"], [4, "1150.00"], [5, "150.00"]]);
  assert.equal(assessInvoice({ ...invoice, qr: variant }, supplier(), "2026-10-01", "2026-12-31").action, "deduct");
});
 
test("an unregistered supplier gives no right to deduct", () => {
  const s = applyLookup([supplier()], [{ vatNumber: VAT, registered: false }], "2026-11-02", "lookup-2026-11-02.xlsx")[0];
  assert.deepEqual(assessInvoice(invoice, s, "2026-10-01", "2026-12-31"), {
    action: "no_deduction",
    reasons: ["SUPPLIER_NOT_REGISTERED"],
  });
});
 
test("Art. 49(8): a held invoice can still be deducted until year + 5", () => {
  assert.equal(lastDeductibleDay("2026-10-12"), "2031-12-31");
  assert.equal(assessInvoice(invoice, supplier(), "2026-10-01", "2031-12-31").action, "deduct");
  assert.deepEqual(assessInvoice(invoice, supplier(), "2032-01-01", "2032-03-31"), {
    action: "no_deduction",
    reasons: ["OUT_OF_TIME_ART_49_8"],
  });
});

شغّلها بالأمر:

npx tsx --test tests/*.test.ts

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

أرقام الاختبار توضيحية. 310122393500003 صحيح الشكل ومختلَق للاختبارات، فلا تقرأه على أنه تسجيل أي جهة. استخدم بيانات اختبار من مورديك أنت، بأرقام استعلم عنها فريقك، للاختبار على بيانات حقيقية.

أين تقع هذه البوابة في نظامك

اللحظةالاستدعاءما الذي تمنعه
موظف المشتريات يُنشئ موردًاcreateSupplierدخول رقم سجل تجاري أو رقم من 14 خانة أو خطأ كتابة إلى السجل
قبل كل إقرارlookupBatch ثم الرفع إلى الهيئة ثم applyLookupالخصم بناءً على تسجيل أُلغي منذ آخر فحص
وصول فاتورةdecodeZatcaQr ثم assessInvoiceتسجيل إجمالي أو ضريبة لم يقدّمها البائع
إعداد الإقرارقرارات deduct وحدها تصل إلى خانات ضريبة المدخلاتفاتورة مشكوك فيها داخل الإقرار بدل قائمة التعليق

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

استكشاف الأخطاء وإصلاحها

يُفكّ رمز QR، لكن اسم البائع يظهر رموزًا عشوائية. قُرئت الحمولة بترميز Latin-1 في مرحلة سابقة، غالبًا في تطبيق ماسح أو مكتبة PDF. خذ نص Base64 الخام من الماسح ومرّره مباشرة إلى decodeZatcaQr. الوسوم من 1 إلى 5 بترميز UTF-8 بحسب المعيار.

tag 6 runs past the end أو unknown tag. الحمولة لا تتبع تخطيط وسم من بايت وطول من بايت، إما لأنها بُترت عند طباعة الرمز أو لأن المولّد يخالف المعيار. يرفضها فاكّ الترميز وتعلّق البوابة الفاتورة. اطلب من المورد فحص رمزه بتطبيق الهيئة لضريبة القيمة المضافة، ولا تخفّف صرامة المحلل لقبوله.

الرقم الضريبي في رمز QR يختلف عن الرأس بخانة واحدة. هذه بالضبط الحالة التي وُجدت البوابة من أجلها. رمز QR في الفاتورة الضريبية المعتمدة يأتي من الهيئة، فالأرجح أن الرأس هو الخاطئ. اطلب فاتورة مصححة قبل الخصم.

المورد مجموعة ضريبية. تجيز BR-KSA-39 أن تحمل الفاتورة رقم التسجيل الضريبي للمجموعة. احفظ رقم المجموعة في سجل كل عضو. وقد يكون الاسم المسجل الذي يعيده الاستعلام عندئذٍ للعضو الممثل للمجموعة لا للجهة التي تتعامل معها، فتعلّق مقارنة الاسم تلك الفواتير حتى يؤكدها أحدهم مرة واحدة.

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

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

الخلاصة

قاعدة الصيغة تتسع في سطر واحد: 15 خانة، الأولى والأخيرة 3. العمل كله في وضعها في مكانها. عند تسجيل المورد تُبقي الأرقام الخاطئة خارج السجل. وقبل كل إقرار يثبت استعلام مؤرَّخ التسجيل لتلك الفترة. وفي كل فاتورة واردة يُطابَق رمز QR بالدفاتر. والبوابة تعلّق ما لا تستطيع تأكيده بدل أن تخصمه، لأن المادة 49(8) تترك خمس سنوات لخصم فاتورة مصححة، وإعادة التقييم لا تترك شيئًا.

ابدأ بأرخص جزء: افحص صيغة الرقم الضريبي للمورد الآن، مجانًا وفي متصفحك، ثم أتبعه باستعلام الهيئة نفسها.

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