الشركة السعودية التي تدفع لموردين في الخارج تتعامل عادةً مع ضريبة الاستقطاع بإحدى طريقتين: إما أن يطبّق محاسب نسبة 5% يدوياً على كل حوالة صادرة، وإما أن يحتفظ أحدهم بجدول بيانات لـ«الحالات الخاصة» لا يفهمه غيره. والطريقتان تنكسران في المواضع نفسها: بند ترخيص يُعامَل كخدمة، وإعفاء بموجب اتفاقية يُطبَّق بشهادة إقامة ضريبية منتهية، ومورد قضى فريقه سبعة أشهر في موقع العميل دون أن ينتبه أحد، ونموذج يُقدَّم في الرابع عشر بدل العاشر.
يشرح مقالنا عن ضريبة الاستقطاع على خدمات المورد الأجنبي القواعد لقارئ من الإدارة المالية. أما هذا الدرس فهو للمهندس الذي عليه أن يضع هذه القواعد داخل نظام المدفوعات. سنبني محركاً صغيراً بـ TypeScript يستقبل بنود الفواتير وبيانات الموردين، ويعيد لكل بند النسبة والضريبة والسند النظامي وأي تنبيه، ثم يجمع الشهر في الأرقام التي يطلبها نموذج الاستقطاع الشهري لدى هيئة الزكاة والضريبة والجمارك.
كل ما فيه يمكن ردّه إلى نص منشور: المادة 68 من نظام ضريبة الدخل، والمادة 63 من لائحته التنفيذية بعد تعديلها بالقرار الوزاري رقم 25 وتاريخ 1445/1/8هـ، وتعميم الهيئة الصادر في يناير 2025 بشأن تطبيق ضريبة الاستقطاع وفق اتفاقيات تجنب الازدواج الضريبي، واتفاقية تجنب الازدواج الضريبي بين المملكة وتونس الموقعة في 8 يوليو 2010. وحيث تترك النصوص خياراً مفتوحاً، يجعله الكود صريحاً ويذكره النص.
ماذا ستبني
- جدول نسب يرمّز الفقرة 1 من المادة 63 بنقاط الأساس، دون أي أعداد عشرية.
- جدول اتفاقيات معبأ لتونس، يعرف أي المدفوعات أرباح أعمال وأيها له سقف.
- اختبار المنشأة الدائمة للخدمات، يجد أكثر نافذة من اثني عشر شهراً ازدحاماً في فترات وجود المورد.
- محرك بنود يختار بين ثلاثة مسارات: النسبة المحلية، أو الإعفاء من المصدر بموجب الاتفاقية، أو النسبة المحلية مع طلب استرداد يجب تقديمه.
- وحدة تقويم لتاريخ الاستحقاق وغرامة 1% عن كل 30 يوماً من التأخير.
- تقرير شهري وأداة سطر أوامر صغيرة تطبعه، و13 اختباراً تثبّت كل رقم ورد في المقال المرافق.
المتطلبات
- Node.js الإصدار 20 أو أحدث
- TypeScript 5 وأداة
tsxلتشغيل الملفات مباشرة - إلمام بأنواع TypeScript و
node:test - فكرة تقريبية عن طريقة تخزين نظام المدفوعات لديك للموردين والدفعات
mkdir saudi-wht && cd saudi-wht
npm init -y
npm install -D typescript tsx @types/node
mkdir src test dataالنطاق. هذا محرك لـ«الشخص المكلف بالاستقطاع»، أي الدافع السعودي. لا يقدّم أي شيء إلى الهيئة، ولا يغني عن قيد ضريبة القيمة المضافة بالاحتساب العكسي على الفاتورة نفسها، وليس استشارة ضريبية. يرمّز النصوص المنشورة حتى 16 أكتوبر 2026، وكل موضع اضطررنا فيه إلى التفسير موسوم بعبارة «قراءتنا».
الخطوة 1: نمذجة الدفعات بالهللات
القرار الأول هو الذي يسبب أغلب الأخطاء لاحقاً: تُخزَّن المبالغ أعداداً صحيحة بالهللة، والريال 100 هللة. رفع المبلغ بنسبة 5% على 200,000 ريال ينتج 210,526.315... ريالاً، والأعداد العشرية ستختلف مع النموذج يوماً ما بهللة واحدة. الأعداد الصحيحة لا تفعل.
أنواع الدفعات هي الفئات الواردة في الفقرة 1 من المادة 63. يحمل كل بند فاتورة نوعه الخاص، لأن العقد الواحد كثيراً ما يجمع خدمة وترخيصاً، والنسبة تتبع البند لا العقد.
// src/types.ts
// Payment types follow Article 63(1) of the Income Tax Implementing Regulations,
// as amended by Ministerial Decision 25 of 8/1/1445H.
export type PaymentType =
| "management_fees"
| "royalties"
| "technical_consulting"
| "rent"
| "air_tickets_freight"
| "intl_telecom"
| "dividends"
| "loan_returns"
| "insurance_premiums"
| "other";
// All money is integer halalas (1 SAR = 100 halalas). Never floats.
export type Halalas = number;
// ISO date "YYYY-MM-DD", always read as UTC.
export type IsoDate = string;
export interface Stay {
from: IsoDate; // first day the vendor's staff is in the Kingdom
to: IsoDate; // last day, inclusive
}
export interface TreatyPaperwork {
trcValidUntil?: IsoDate; // tax residency certificate from the vendor's tax authority
legalised: boolean; // Saudi embassy authentication or an apostille
atSourceApprovalRef?: string; // ZATCA's approval of the "benefit at source" request
}
export interface Vendor {
id: string;
name: string;
residence: string; // ISO 3166 alpha-2, e.g. "TN"
relatedParty: boolean;
stays: Stay[]; // presence in the Kingdom, for the service-PE test
paperwork?: TreatyPaperwork;
}
export interface InvoiceLine {
vendorId: string;
description: string;
type: PaymentType;
amount: Halalas;
netOfTax: boolean; // true when the contract says the vendor receives `amount` in full
}
export interface Payment {
id: string;
paidOn: IsoDate;
lines: InvoiceLine[];
}ثلاثة حقول تستحق التوضيح. netOfTax يمثّل بند «الأسعار صافية من أي ضرائب»: على المورد أن يتسلم المبلغ المكتوب كاملاً، فتُحسب الضريبة على أساس مرفوع. وstays سجل الأيام التي يقضيها موظفو المورد في المملكة، ويحتاجه اختبار الاتفاقية. وpaperwork يحفظ ما تطلبه الهيئة قبل أن تسمح بتطبيق الاتفاقية من المصدر.
الخطوة 2: ترميز جدول النسب المحلية
أعاد القرار الوزاري رقم 25 وتاريخ 1445/1/8هـ صياغة الفقرة 1 من المادة 63. ويُظهر الجدول في تعميم الهيئة لعام 2025 النتيجة، والتغيير الأهم لمشتري البرمجيات أن الخدمات الفنية والاستشارية تخضع لـ 5% سواء كان المورد طرفاً مرتبطاً أم لا. نسبة 15% القديمة على خدمات الأطراف المرتبطة لم تعد قائمة.
// src/rates.ts
import type { PaymentType } from "./types";
// Domestic rates in basis points (500 = 5%). Since Decision 25/1445H the
// related-party 15% on technical services is gone: same rate either way.
export const DOMESTIC_BPS: Record<PaymentType, number> = {
management_fees: 2000,
royalties: 1500,
technical_consulting: 500,
rent: 500,
air_tickets_freight: 500,
intl_telecom: 500,
dividends: 500,
loan_returns: 500,
insurance_premiums: 500,
other: 1500,
};
export function domesticBps(type: PaymentType): number {
return DOMESTIC_BPS[type];
}لاحظ أمرين. النسب بنقاط الأساس، فـ 5% تساوي 500، ولا تحدث أي قسمة قبل التقريب الأخير. ويبقى حقل relatedParty في سجل المورد رغم أن جدول النسب لم يعد يستخدمه: ستحتاجه لأسعار التحويل، ولليوم الذي تتغير فيه القاعدة مرة أخرى.
ويذكر التعميم أيضاً أن الخدمات الفنية والاستشارية تخضع للاستقطاع «حتى لو أُدّيت بالكامل خارج المملكة». لذلك لا يوجد في هذا الجدول فرع «عمل منجز خارج المملكة»، وذلك عن قصد.
الخطوة 3: ترميز الاتفاقية، دولة دولة
النظام المحلي هو الأصل. والاتفاقية النافذة مع دولة إقامة المورد قد تخفّض النسبة أو تلغيها، ويقول تعميم الهيئة إن الاتفاقيات تسمو على النظام المحلي. والسؤال الذي تجيب عنه الاتفاقية ليس «ما النسبة؟» بل «هل يحق للمملكة أصلاً أن تفرض الضريبة على هذا الدخل؟».
في حالة تونس تحسم الأمرَ ثلاثةُ أحكام، كلها مقروءة في النص العربي المنشور على موقع الهيئة:
- المادة 7: أرباح المشروع التونسي تخضع للضريبة في تونس فقط، ما لم تكن له منشأة دائمة في المملكة.
- البروتوكول، البند 2(ب): تشمل «أرباح الأعمال» صراحةً توفير الخدمات. وليس في الاتفاقية مادة مستقلة لأتعاب الخدمات الفنية، فتبقى الخدمات تحت المادة 7.
- المادة 12(2): يجوز أن تخضع الإتاوات للضريبة في المملكة، لكن دون أن تزيد على 5% من المبلغ الإجمالي إذا كان المالك المنتفع مقيماً في تونس.
// src/treaties.ts
import type { PaymentType } from "./types";
export interface TreatyRule {
country: string;
signed: string;
// Payment types the treaty treats as business profits (Article 7):
// taxable in the Kingdom only through a permanent establishment.
businessProfits: PaymentType[];
// Source-state caps in basis points, e.g. Article 12 royalties.
caps: Partial<Record<PaymentType, number>>;
// Article 5(3)(b): services through staff for more than six months in any
// twelve-month period create a permanent establishment.
servicePeDays: number;
}
// Fill this table from the treaty texts on zatca.gov.sa, one country at a time.
// Only Tunisia is filled here, from the Arabic text signed on 8 July 2010.
export const TREATIES: Record<string, TreatyRule> = {
TN: {
country: "Tunisia",
signed: "2010-07-08",
// Protocol item 2(b): business profits include "the provision of services".
// No technical-services article in this treaty.
businessProfits: ["technical_consulting"],
caps: { royalties: 500 }, // Article 12(2): 5% of the gross royalties
servicePeDays: 183, // our reading of "more than six months", see the text
},
};
export function treatyFor(country: string): TreatyRule | undefined {
return TREATIES[country];
}الجدول بيانات لا كود. إضافة دولة أخرى تعني قراءة اتفاقيتها وكتابة مدخل واحد. لا تنسخ مدخل تونس لدولة تتضمن اتفاقيتها مادة للخدمات الفنية: في تلك الحالة تخضع الخدمات للضريبة من المصدر حتى السقف المحدد، وهي بالضبط الحالة التي يعالجها تعميم الهيئة في الفقرة 3.2.11. المحرك الذي يخمّن أسوأ من محرك يعود إلى النسبة المحلية ويقول ذلك صراحة، وهذا ما يفعله محركنا مع أي نوع لا يذكره الجدول.
الخطوة 4: دوال التواريخ
كل قاعدة في هذا المحرك مقارنة تواريخ: انتهاء الشهادة، ونافذة الاثني عشر شهراً، وتاريخ الاستحقاق، وأيام التأخير. بضع دوال بتوقيت UTC تبقيها متسقة وبعيدة عن انحراف المناطق الزمنية.
// src/dates.ts
import type { IsoDate } from "./types";
const DAY = 86_400_000;
export function toDay(d: IsoDate): number {
const [y, m, dd] = d.split("-").map(Number);
return Date.UTC(y, m - 1, dd) / DAY;
}
export function fromDay(n: number): IsoDate {
return new Date(n * DAY).toISOString().slice(0, 10);
}
export function addMonths(d: IsoDate, months: number): IsoDate {
const [y, m, dd] = d.split("-").map(Number);
const t = new Date(Date.UTC(y, m - 1 + months, 1));
const last = new Date(Date.UTC(t.getUTCFullYear(), t.getUTCMonth() + 1, 0)).getUTCDate();
t.setUTCDate(Math.min(dd, last));
return t.toISOString().slice(0, 10);
}
export function daysBetween(from: IsoDate, to: IsoDate): number {
return toDay(to) - toDay(from);
}الدالة addMonths تقف عند آخر يوم في الشهر، فإضافة اثني عشر شهراً إلى 29 فبراير تعطي 28 فبراير. وهذا يهم نافذة المنشأة الدائمة في الخطوة التالية.
الخطوة 5: اختبار المنشأة الدائمة للخدمات (ستة أشهر)
الإعفاء عن الخدمات بموجب الاتفاقية مشروط بعدم وجود منشأة دائمة. والمادة 5(3)(ب) من الاتفاقية السعودية التونسية تنشئ منشأة دائمة عندما يقدّم المشروع خدمات، بما فيها الخدمات الاستشارية، من خلال موظفين أو عاملين آخرين في المملكة «لمدة أو مدد تزيد في مجموعها على ستة أشهر خلال أي مدة اثني عشر شهراً»، للمشروع نفسه أو لمشروع مرتبط به.
تفصيلان يجعلان الأمر أصعب من جمع الأيام في كل سنة. النافذة هي أي اثني عشر شهراً لا السنة الميلادية، فأربعة أشهر في آخر سنة وثلاثة في أول السنة التالية قد تتجاوز الحد. ومهندسان في الموقع في اليوم نفسه يمثّلان يوم وجود واحداً للمشروع لا يومين.
// src/pe.ts
import type { Stay } from "./types";
import { addMonths, fromDay, toDay } from "./dates";
export interface PeCheck {
maxDays: number; // most days present in any twelve-month window
windowStart?: string;
windowEnd?: string;
exceeded: boolean;
}
// Day-level count. Overlapping stays (two engineers on the same day) count once,
// because the test is about the enterprise's presence, not head-days.
export function servicePe(stays: Stay[], thresholdDays: number): PeCheck {
const present = new Set<number>();
for (const s of stays) {
for (let d = toDay(s.from); d <= toDay(s.to); d++) present.add(d);
}
if (present.size === 0) return { maxDays: 0, exceeded: false };
// The busiest window either starts on a first day of presence
// or ends on a last day of presence. Try both.
const windows: Array<[number, number]> = [];
for (const d of present) {
if (!present.has(d - 1)) {
windows.push([d, toDay(addMonths(fromDay(d), 12)) - 1]);
}
if (!present.has(d + 1)) {
windows.push([toDay(addMonths(fromDay(d + 1), -12)), d]);
}
}
let best: PeCheck = { maxDays: 0, exceeded: false };
for (const [start, end] of windows) {
let n = 0;
for (const d of present) if (d >= start && d <= end) n++;
if (n > best.maxDays) {
best = { maxDays: n, windowStart: fromDay(start), windowEnd: fromDay(end), exceeded: false };
}
}
best.exceeded = best.maxDays > thresholdDays;
return best;
}تحوّل الدالة فترات الوجود إلى مجموعة أيام، ثم تختبر كل نافذة تبدأ بأول يوم من فترة وجود أو تنتهي بآخر يوم منها. أكثر نافذة ازدحاماً تأخذ دائماً أحد هذين الشكلين، فتجد الدالة الحد الأقصى الحقيقي دون اختبار 365 نقطة بداية.
قراءتنا. تقول الاتفاقية «تزيد على ستة أشهر» ولا تعرّف الشهر بالأيام. جعلنا الحد 183 يوماً في جدول الاتفاقيات. والفريق الذي يخطط للبقاء قرب الحد لا ينبغي أن يعتمد على الرقم الدقيق: يعيد الكود عدد الأيام والنافذة ليقرر إنسان. سجّل فترات الوجود المخطط لها لا الماضية فقط، لأن منشأة دائمة تنشأ بنهاية المشروع تؤثر في الدفعات التي سُددت في بدايته.
الخطوة 6: محرك البنود
هذا هو القلب. لكل بند فاتورة يحسب النتيجة المحلية، ويسأل جدول الاتفاقيات إن كانت هناك نسبة أقل، ثم يتحقق مما إذا كانت المستندات تسمح بتطبيق تلك النسبة من المصدر.
// src/engine.ts
import type { Halalas, InvoiceLine, IsoDate, Vendor } from "./types";
import { domesticBps } from "./rates";
import { treatyFor } from "./treaties";
import { servicePe } from "./pe";
import { toDay } from "./dates";
export type Route = "domestic" | "treaty-at-source" | "refund-candidate";
export interface LineResult {
vendorId: string;
description: string;
type: InvoiceLine["type"];
bps: number;
base: Halalas;
tax: Halalas;
vendorReceives: Halalas;
route: Route;
basis: string;
refundable: Halalas; // tax a refund claim could recover, 0 if none
flags: string[];
}
export function amounts(amount: Halalas, netOfTax: boolean, bps: number) {
if (!Number.isInteger(amount) || amount < 0) {
throw new Error(`amount must be a non-negative integer in halalas, got ${amount}`);
}
if (netOfTax) {
// Gross-up: the vendor must receive `amount`, so tax is computed on base.
const base = Math.round((amount * 10_000) / (10_000 - bps));
return { base, tax: base - amount, vendorReceives: amount };
}
const tax = Math.round((amount * bps) / 10_000);
return { base: amount, tax, vendorReceives: amount - tax };
}
function treatyBps(line: InvoiceLine, vendor: Vendor, flags: string[]) {
const rule = treatyFor(vendor.residence);
if (!rule) return undefined;
if (rule.businessProfits.includes(line.type)) {
const pe = servicePe(vendor.stays, rule.servicePeDays);
if (pe.exceeded) {
flags.push(
`service PE: ${pe.maxDays} days in the Kingdom between ${pe.windowStart} and ${pe.windowEnd}, ` +
`more than ${rule.servicePeDays}. Treaty zero not applied; review with an adviser.`,
);
return undefined;
}
return { bps: 0, basis: `${rule.country} treaty, Art. 7 (no PE, ${pe.maxDays} days)` };
}
const cap = rule.caps[line.type];
if (cap !== undefined) {
return { bps: cap, basis: `${rule.country} treaty, capped at ${cap / 100}%` };
}
flags.push(`${rule.country} treaty: no rule modelled for "${line.type}", domestic rate kept.`);
return undefined;
}
function missingPaperwork(vendor: Vendor, paidOn: IsoDate): string[] {
const p = vendor.paperwork;
const missing: string[] = [];
if (!p?.trcValidUntil) missing.push("tax residency certificate");
else if (toDay(p.trcValidUntil) < toDay(paidOn)) {
missing.push(`tax residency certificate (expired ${p.trcValidUntil})`);
}
if (!p?.legalised) missing.push("embassy authentication or apostille");
if (!p?.atSourceApprovalRef) missing.push("ZATCA approval of the benefit-at-source request");
return missing;
}
export function computeLine(line: InvoiceLine, vendor: Vendor, paidOn: IsoDate): LineResult {
const flags: string[] = [];
const dom = domesticBps(line.type);
const domestic = amounts(line.amount, line.netOfTax, dom);
const head = { vendorId: vendor.id, description: line.description, type: line.type };
const treaty = treatyBps(line, vendor, flags);
if (!treaty || treaty.bps >= dom) {
return {
...head, bps: dom, ...domestic, route: "domestic",
basis: "Income Tax Regs Art. 63(1)", refundable: 0, flags,
};
}
const relieved = amounts(line.amount, line.netOfTax, treaty.bps);
const missing = missingPaperwork(vendor, paidOn);
if (missing.length === 0) {
return {
...head, bps: treaty.bps, ...relieved, route: "treaty-at-source",
basis: treaty.basis, refundable: 0, flags,
};
}
flags.push(`withheld at the domestic rate; missing: ${missing.join(", ")}`);
return {
...head, bps: dom, ...domestic, route: "refund-candidate",
basis: "Income Tax Regs Art. 63(1)",
refundable: domestic.tax - relieved.tax, flags,
};
}للقرار ثلاث نتائج، وكل واحدة تقابل شيئاً يصفه التعميم:
domestic. لا اتفاقية، أو نوع لا يغطيه جدول الاتفاقيات، أو منشأة دائمة، أو نسبة الاتفاقية ليست أقل. يُخضَع البند للفقرة 1 من المادة 63.treaty-at-source. تمنح الاتفاقية نسبة أقل، ويملك الدافع كل ما يتطلبه إجراء «الاستفادة من المصدر»: شهادة إقامة ضريبية سارية في تاريخ الدفع، وتصديق السفارة السعودية أو شهادة أبوستيل، وموافقة الهيئة على الطلب المقدم عبر بوابتها. ويذكر التعميم مستنداً آخر هو تعهّد الدافع على النموذج Q/7C بسداد أي ضريبة أو غرامة تنشأ عن معلومات خاطئة أو سوء تفسير للاتفاقية؛ وهو جزء من الطلب، لذلك نعدّ رقم الموافقة دليلاً على تقديمه.refund-candidate. تمنح الاتفاقية نسبة أقل لكن المستندات ناقصة. والتعميم صريح في أن الدافع الذي لا يستوفي متطلبات الاستفادة من المصدر يستقطع وفق النظام المحلي ثم يطلب الاسترداد. وهذا بالضبط ما يفعله المحرك، مع تسجيل المبلغ الذي يمكن استرداده.
رفع المبلغ في amounts هو الموضع الذي يصل إليه بند «صافية من الضرائب». إذا وجب أن يتسلم المورد 200,000 ريال والنسبة 5%، فالأساس 200,000 مقسوماً على 0.95، أي 210,526.32 ريالاً، والضريبة هي الفرق: 10,526.32 ريالاً. حساب الضريبة على أنها base - amount لا base * rate يضمن أن يتسلم المورد المبلغ التعاقدي بالضبط بعد التقريب.
لاحظ ما يرفض المحرك فعله. لا يطبّق الاتفاقية لأن عنوان المورد في دولة متعاقدة، بل حين تكون الشهادة والتصديق وموافقة الهيئة محفوظة. وتحدد الفقرة 4.2.2.2 من التعميم أيضاً مدة خمس سنوات لتقديم طلب الاستفادة من الاتفاقية أو للمراجعة؛ وبعدها لا يمكن المطالبة بها، وهذا سبب وجيه لتشغيل تقرير الاسترداد كل شهر لا مرة في السنة.
الخطوة 7: تاريخ الاستحقاق وغرامة التأخير
الضريبة المستقطعة من دفعة مستحقة السداد قبل العاشر من الشهر التالي؛ وتكرر الهيئة ذلك في إشعاراتها الشهرية (نماذج ديسمبر 2024 كانت مستحقة حتى 10 يناير 2025). وتحدد الإشعارات نفسها غرامة التأخير بـ 1% من الضريبة غير المسددة عن كل 30 يوماً من التأخير بعد تاريخ الاستحقاق.
// src/calendar.ts
import type { Halalas, IsoDate } from "./types";
import { daysBetween } from "./dates";
// Tax withheld in a month is due by the 10th of the following month.
export function dueDate(paidOn: IsoDate): IsoDate {
const [y, m] = paidOn.split("-").map(Number);
const next = new Date(Date.UTC(y, m, 10)); // month index m = the next month
return next.toISOString().slice(0, 10);
}
// "started": a partial 30-day period counts as a full one (prudent for provisioning).
// "completed": only full 30-day periods count. ZATCA's notice says "for every 30 days
// of delay" and does not settle partial periods; pick one and write down why.
export type PeriodCount = "started" | "completed";
export function lateFine(tax: Halalas, due: IsoDate, remittedOn: IsoDate, count: PeriodCount) {
const daysLate = Math.max(0, daysBetween(due, remittedOn));
const periods = count === "started" ? Math.ceil(daysLate / 30) : Math.floor(daysLate / 30);
return { daysLate, periods, fine: Math.round((tax * periods) / 100) };
}قراءتنا، بشكل صريح. لا يقول الإشعار ما يحدث مع الفترة الجزئية. هل التأخر يوماً واحداً غرامة 1% أم لا غرامة بعد؟ تأخذ الدالة الجواب وسيطاً إلزامياً بدل أن تخفيه. استخدم
startedللتحوّط في المخصصات، وcompletedإن كانت هذه قراءة مستشارك. وعند المضاعفات الكاملة لـ 30 يوماً تتفق القراءتان: التأخر 90 يوماً على 10,000 ريال غرامته 300 ريال في الحالتين.
السداد في يوم العاشر في الموعد. والعدّ يبدأ من اليوم التالي.
الخطوة 8: تجميع الشهر
يطلب نموذج الاستقطاع الشهري لدى الهيئة المبالغ بحسب نوع الدفعة والنسبة. يجمّع التقرير البنود على هذا المفتاح بالضبط، ويجمع الضريبة، ويحصر طلبات الاسترداد المحتملة، ويمرر كل تنبيه مع رقم الدفعة واسم المورد.
// src/monthly.ts
import type { Halalas, Payment, Vendor } from "./types";
import { computeLine, type LineResult } from "./engine";
import { dueDate } from "./calendar";
import { toDay } from "./dates";
export interface FormRow {
type: LineResult["type"];
bps: number;
base: Halalas;
tax: Halalas;
lines: number;
}
export interface MonthReport {
month: string; // "YYYY-MM"
dueDate: string;
rows: FormRow[];
totalTax: Halalas;
refundCandidates: Array<{ paymentId: string; vendorId: string; refundable: Halalas }>;
flags: string[];
}
export function buildMonth(month: string, payments: Payment[], vendors: Vendor[]): MonthReport {
const byId = new Map(vendors.map((v) => [v.id, v]));
const rows = new Map<string, FormRow>();
const report: MonthReport = {
month, dueDate: dueDate(`${month}-01`), rows: [], totalTax: 0, refundCandidates: [], flags: [],
};
for (const p of payments.filter((x) => x.paidOn.startsWith(month))) {
for (const line of p.lines) {
const vendor = byId.get(line.vendorId);
if (!vendor) throw new Error(`payment ${p.id}: unknown vendor ${line.vendorId}`);
const r = computeLine(line, vendor, p.paidOn);
const key = `${r.type}:${r.bps}`;
const row = rows.get(key) ?? { type: r.type, bps: r.bps, base: 0, tax: 0, lines: 0 };
row.base += r.base;
row.tax += r.tax;
row.lines += 1;
rows.set(key, row);
report.totalTax += r.tax;
if (r.refundable > 0) {
report.refundCandidates.push({ paymentId: p.id, vendorId: vendor.id, refundable: r.refundable });
}
for (const f of r.flags) report.flags.push(`${p.id} / ${vendor.name}: ${f}`);
}
}
report.rows = [...rows.values()].sort((a, b) => b.tax - a.tax);
return report;
}
// Certificates that will lapse before the next payment run.
export function expiringCertificates(vendors: Vendor[], asOf: string, withinDays: number) {
return vendors
.filter((v) => v.paperwork?.trcValidUntil)
.map((v) => ({ id: v.id, name: v.name, validUntil: v.paperwork!.trcValidUntil! }))
.filter((v) => toDay(v.validUntil) - toDay(asOf) <= withinDays);
}الدالة expiringCertificates أرخص رقابة في النظام كله. شهادة إقامة تنتهي بين دفعتين تنقل المورد بصمت من صفر إلى 5%، ولا ينتبه الدافع إلا حين يسأل المورد لماذا نقصت فاتورته. خمسة وأربعون يوماً من الإنذار تكفي لطلب الشهادة التالية.
الخطوة 9: أداة سطر الأوامر
تقرأ الأداة ملفي JSON وتطبع الشهر. تأخذ شهر التقرير وتاريخ اليوم وسيطين، فيعطي التشغيل نفسه بعد أسبوع النتيجة نفسها للبيانات نفسها.
// src/cli.ts
import { readFileSync } from "node:fs";
import type { Payment, Vendor } from "./types";
import { buildMonth, expiringCertificates } from "./monthly";
const [month, asOf] = process.argv.slice(2);
if (!/^\d{4}-\d{2}$/.test(month ?? "") || !/^\d{4}-\d{2}-\d{2}$/.test(asOf ?? "")) {
console.error("usage: tsx src/cli.ts YYYY-MM YYYY-MM-DD (month to report, today's date)");
process.exit(1);
}
const vendors: Vendor[] = JSON.parse(readFileSync("data/vendors.json", "utf8"));
const payments: Payment[] = JSON.parse(readFileSync("data/payments.json", "utf8"));
const sar = (h: number) => (h / 100).toFixed(2);
const rep = buildMonth(month, payments, vendors);
console.log(`Withholding for ${rep.month}, due by ${rep.dueDate}`);
for (const r of rep.rows) {
console.log(` ${r.type.padEnd(22)} ${String(r.bps / 100).padStart(3)}% base ${sar(r.base).padStart(12)} tax ${sar(r.tax).padStart(10)}`);
}
console.log(` total tax ${sar(rep.totalTax)}`);
for (const c of rep.refundCandidates) console.log(` refund candidate ${c.paymentId} ${c.vendorId}: ${sar(c.refundable)}`);
for (const f of rep.flags) console.log(` ! ${f}`);
for (const e of expiringCertificates(vendors, asOf, 45)) {
console.log(` certificate for ${e.name} lapses ${e.validUntil}: request the next one now`);
}بيانات تجريبية: مورد تونسي مستنداته مكتملة، وآخر مستنداته ناقصة، ومورد في دولة ليس لها مدخل في جدول الاتفاقيات:
data/vendors.json
[
{
"id": "v-tn", "name": "Tunis Dev SARL", "residence": "TN", "relatedParty": false,
"stays": [{ "from": "2026-09-14", "to": "2026-09-25" }],
"paperwork": { "trcValidUntil": "2026-11-30", "legalised": true, "atSourceApprovalRef": "ZATCA-123" }
},
{
"id": "v-tn2", "name": "Sfax Data SA", "residence": "TN", "relatedParty": false,
"stays": [], "paperwork": { "legalised": false }
},
{ "id": "v-xx", "name": "Elsewhere Ltd", "residence": "XX", "relatedParty": true, "stays": [] }
]data/payments.json
[
{
"id": "PAY-1041", "paidOn": "2026-10-05",
"lines": [
{ "vendorId": "v-tn", "description": "custom development, milestone 2", "type": "technical_consulting", "amount": 15000000, "netOfTax": false },
{ "vendorId": "v-tn", "description": "reporting licence, 12 months", "type": "royalties", "amount": 5000000, "netOfTax": false }
]
},
{
"id": "PAY-1042", "paidOn": "2026-10-12",
"lines": [
{ "vendorId": "v-tn2", "description": "data migration", "type": "technical_consulting", "amount": 4000000, "netOfTax": true },
{ "vendorId": "v-xx", "description": "group management fee", "type": "management_fees", "amount": 3000000, "netOfTax": false }
]
}
]npx tsx src/cli.ts 2026-10 2026-10-16Withholding for 2026-10, due by 2026-11-10
management_fees 20% base 30000.00 tax 6000.00
royalties 5% base 50000.00 tax 2500.00
technical_consulting 5% base 42105.26 tax 2105.26
technical_consulting 0% base 150000.00 tax 0.00
total tax 10605.26
refund candidate PAY-1042 v-tn2: 2105.26
! PAY-1042 / Sfax Data SA: withheld at the domestic rate; missing: tax residency certificate, embassy authentication or apostille, ZATCA approval of the benefit-at-source request
certificate for Tunis Dev SARL lapses 2026-11-30: request the next one nowاقرأها سطراً سطراً. مرحلة التطوير من المورد التونسي صفر بموجب المادة 7. والترخيص من المورد نفسه 5% لا 15%، لأن المادة 12 تضع له سقفاً. وترحيل البيانات للمورد التونسي الثاني مرفوع المبلغ وخاضع لـ 5% لأن لا شيء محفوظ في ملفه، والمبلغ نفسه 2,105.26 ريالاً يظهر طلبَ استرداد محتملاً. ورسوم الإدارة للمجموعة 20% ولا اتفاقية للنظر فيها. وشهادة المورد الأول ستنتهي قبل دورة ديسمبر.
الخطوة 10: اختبار كل رقم
تثبّت الاختبارات أرقام المقال المرافق (10,000 و10,526.32 وصفر و2,500 و300 ريال) والحالات الحدّية التي تخطئها جداول البيانات اليدوية.
// test/engine.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { amounts, computeLine } from "../src/engine";
import { dueDate, lateFine } from "../src/calendar";
import { servicePe } from "../src/pe";
import { buildMonth } from "../src/monthly";
import type { Vendor } from "../src/types";
const SAR = (n: number) => Math.round(n * 100);
const tunis: Vendor = {
id: "v-tn", name: "Tunis Dev SARL", residence: "TN", relatedParty: false, stays: [],
paperwork: { trcValidUntil: "2026-12-31", legalised: true, atSourceApprovalRef: "ZATCA-123" },
};
const noTreaty: Vendor = { id: "v-xx", name: "Elsewhere Ltd", residence: "XX", relatedParty: true, stays: [] };
test("domestic 5% on technical services, price includes the tax", () => {
const r = computeLine(
{ vendorId: "v-xx", description: "build", type: "technical_consulting", amount: SAR(200_000), netOfTax: false },
noTreaty, "2026-10-05",
);
assert.equal(r.tax, SAR(10_000));
assert.equal(r.vendorReceives, SAR(190_000));
assert.equal(r.route, "domestic");
});
test("related party pays the same 5% since Decision 25/1445H", () => {
const r = computeLine(
{ vendorId: "v-xx", description: "support", type: "technical_consulting", amount: SAR(1_000), netOfTax: false },
noTreaty, "2026-10-05",
);
assert.equal(r.bps, 500);
});
test("net-of-tax contract is grossed up", () => {
const a = amounts(SAR(200_000), true, 500);
assert.equal(a.base, SAR(210_526.32));
assert.equal(a.tax, SAR(10_526.32));
assert.equal(a.vendorReceives, SAR(200_000));
});
test("Tunisian vendor, no PE, paperwork approved: zero", () => {
const r = computeLine(
{ vendorId: "v-tn", description: "build", type: "technical_consulting", amount: SAR(200_000), netOfTax: false },
tunis, "2026-10-05",
);
assert.equal(r.tax, 0);
assert.equal(r.route, "treaty-at-source");
});
test("licence line is capped at 5% under the treaty", () => {
const r = computeLine(
{ vendorId: "v-tn", description: "licence", type: "royalties", amount: SAR(50_000), netOfTax: false },
tunis, "2026-10-05",
);
assert.equal(r.tax, SAR(2_500));
});
test("expired certificate: domestic rate, refund candidate", () => {
const v: Vendor = { ...tunis, paperwork: { ...tunis.paperwork!, trcValidUntil: "2026-09-30" } };
const r = computeLine(
{ vendorId: "v-tn", description: "build", type: "technical_consulting", amount: SAR(200_000), netOfTax: false },
v, "2026-10-05",
);
assert.equal(r.route, "refund-candidate");
assert.equal(r.tax, SAR(10_000));
assert.equal(r.refundable, SAR(10_000));
assert.match(r.flags[0], /expired 2026-09-30/);
});
test("seven months on site: service PE, treaty zero withdrawn", () => {
const v: Vendor = { ...tunis, stays: [{ from: "2026-01-01", to: "2026-07-31" }] };
const r = computeLine(
{ vendorId: "v-tn", description: "on-site rollout", type: "technical_consulting", amount: SAR(100_000), netOfTax: false },
v, "2026-08-05",
);
assert.equal(r.tax, SAR(5_000));
assert.equal(r.route, "domestic");
assert.match(r.flags[0], /service PE: 212 days/);
});
test("PE window catches stays split across two calendar years", () => {
const pe = servicePe(
[{ from: "2025-09-01", to: "2025-12-31" }, { from: "2026-03-01", to: "2026-05-15" }],
183,
);
assert.equal(pe.maxDays, 122 + 76);
assert.equal(pe.exceeded, true);
});
test("overlapping stays count each day once", () => {
const pe = servicePe(
[{ from: "2026-01-01", to: "2026-03-31" }, { from: "2026-02-01", to: "2026-04-30" }],
183,
);
assert.equal(pe.maxDays, 120);
assert.equal(pe.exceeded, false);
});
test("due date is the 10th of the next month, across the year end", () => {
assert.equal(dueDate("2026-10-05"), "2026-11-10");
assert.equal(dueDate("2026-12-31"), "2027-01-10");
});
test("1% per 30 days: 90 days late on SAR 10,000 is SAR 300", () => {
const f = lateFine(SAR(10_000), "2026-11-10", "2027-02-08", "completed");
assert.equal(f.daysLate, 90);
assert.equal(f.fine, SAR(300));
});
test("partial period: the two readings differ", () => {
assert.equal(lateFine(SAR(10_000), "2026-11-10", "2026-11-11", "started").fine, SAR(100));
assert.equal(lateFine(SAR(10_000), "2026-11-10", "2026-11-11", "completed").fine, 0);
assert.equal(lateFine(SAR(10_000), "2026-11-10", "2026-11-10", "started").fine, 0);
});
test("monthly report groups by type and rate", () => {
const rep = buildMonth(
"2026-10",
[{
id: "PAY-1", paidOn: "2026-10-05",
lines: [
{ vendorId: "v-tn", description: "build", type: "technical_consulting", amount: SAR(150_000), netOfTax: false },
{ vendorId: "v-tn", description: "licence", type: "royalties", amount: SAR(50_000), netOfTax: false },
{ vendorId: "v-xx", description: "support", type: "technical_consulting", amount: SAR(20_000), netOfTax: false },
],
}, { id: "PAY-2", paidOn: "2026-11-02", lines: [] }],
[tunis, noTreaty],
);
assert.equal(rep.dueDate, "2026-11-10");
assert.equal(rep.totalTax, SAR(2_500 + 1_000));
assert.equal(rep.rows.length, 3);
assert.deepEqual(rep.rows.map((r) => [r.type, r.bps]), [
["royalties", 500], ["technical_consulting", 500], ["technical_consulting", 0],
]);
});npx tsx --test test/*.test.tsتنجح الاختبارات الثلاثة عشر كلها. وإذا غيّرت نسبة أو مدخل اتفاقية، يجب أن يفشل واحد منها على الأقل؛ وهذا هو الغرض من تثبيت الأرقام بدل اختبار أن «رقماً ما يخرج».
ربطه بنظام المدفوعات
المحرك نقي عن قصد: لا قاعدة بيانات، ولا HTTP، ولا ساعة. وهذا يجعل استدعاءه سهلاً من أي موضع تُعتمد فيه الدفعات.
- عند إصدار أمر الشراء، اشترط نوع دفعة لكل بند. البند غير المصنف أصل معظم الأخطاء، والمحرك لا يستطيع تخمينه.
- عند اعتماد الدفعة، استدعِ
computeLineواحفظ النتيجة كاملة، بما فيهاbasisوflags، بجانب الدفعة. حين تسأل الهيئة لماذا خضع بند لنسبة صفر، يكون الجواب سجلاً محفوظاً لا ذاكرة شخص. - في نهاية الشهر، شغّل
buildMonthوقدّم النموذج من صفوفه قبل العاشر. وقدّم طلبات الاسترداد منrefundCandidates. - أسبوعياً، شغّل
expiringCertificatesوأرسل القائمة إلى المسؤول عن تسجيل الموردين.
إذا كان النظام ERP يصعب تعديله، يمكن أن يعمل المحرك نفسه خلف واجهة API صغيرة يستدعيها الـ ERP أثناء الاعتماد. وهو النمط الذي استخدمناه للفوترة الإلكترونية في درس تكامل المرحلة الثانية من فاتورة بـ TypeScript.
حل المشكلات
الإجمالي يختلف عن الحوالة البنكية بهللة. شيء ما حوّل إلى ريالات قبل التقريب. أبقِ الهللات من البداية إلى النهاية ولا تنسّق إلا عند العرض، كما تفعل أداة سطر الأوامر.
مورد مشمول باتفاقية يخضع لـ 5%. اقرأ التنبيه. السبب غالباً شهادة منتهية، أو أبوستيل ناقص، أو لا رقم موافقة بعد. والضريبة ليست ضائعة: البند طلب استرداد محتمل.
مورد مشمول باتفاقية يخضع لـ 5% مع تنبيه «service PE». مجموع فترات الوجود يتجاوز الحد في نافذة ما من اثني عشر شهراً. تحقق من النافذة التي يطبعها التنبيه؛ قد تمتد عبر نهاية سنة. وإن كانت الفترات مسجلة خطأً، فأصلح البيانات لا الحد.
ترخيص يخضع لـ 15%. دولة المورد ليست في جدول الاتفاقيات، أو مدخلها بلا سقف للإتاوات. أضفه من نص الاتفاقية.
رسوم إدارة من مورد تونسي تبقى عند 20%. هذا صحيح في هذا المحرك: لم ننمذج معاملة الاتفاقية لرسوم الإدارة، فيبقي الكود النسبة المحلية ويقول ذلك. احسم هذه الحالة مع مستشار قبل إضافتها إلى businessProfits.
غرامة التأخير تبدو خاطئة لتأخير بضعة أيام. تحقق من قيمة PeriodCount التي مررتها. القراءتان لا تفترقان إلا في الفترات الجزئية.
ما لا يفعله هذا المحرك
- ضريبة القيمة المضافة. الاحتساب العكسي بنسبة 15% على خدمة مستوردة قيد منفصل لا تمسه أي اتفاقية لضريبة الدخل. استخدم حاسبة الضريبة للتحقق من المبالغ.
- اتفاقيات أخرى. تونس وحدها معبأة. وكل دولة إضافية عمل قراءة لا عمل برمجة.
- التقديم. ينتج الأرقام؛ ويبقى تقديم النموذج عبر بوابة الهيئة.
- الغرامات القديمة. لنماذج الاستقطاع المتأخرة القائمة، تحقق مما تشمله مبادرة الإعفاء من الغرامات حتى 31 ديسمبر 2026 قبل حساب ما عليك.
الخلاصة
ضريبة الاستقطاع على الموردين الأجانب مسألة تصنيف في ثوب نسبة مئوية. جدول النسب يتسع لعشرة أسطر. وما يخطئ هو كل ما حوله: نوع البند، والاتفاقية، وأيام الوجود، والمستندات وتاريخ انتهائها، والتقويم. وضع ذلك في محرك محدد الأنواع مع اختبارات مثبّتة يحوّل النموذج الشهري إلى تقرير تشغّله، ويحوّل كل بند بنسبة صفر إلى سجل تستطيع الدفاع عنه.
تريد هذا داخل مسار مدفوعاتك؟ أرسل لنا قائمة بالموردين غير المقيمين ودولهم وما يوردونه، وسنريك كيف يصنّف المحرك كل واحد منهم وما ينقص لتطبيق الاتفاقية. هذا من صميم عملنا في تكامل الأنظمة وواجهات API. تشخيص مجاني دون التزام، عبر صفحة التواصل.
المصادر: تعميم الهيئة: تطبيق ضريبة الاستقطاع وفق اتفاقيات تجنب الازدواج الضريبي، الإصدار 1، يناير 2025 · قرارات وتعاميم ضريبة الاستقطاع، ومنها القرار الوزاري رقم 25 وتاريخ 1445/1/8هـ · اتفاقية تجنب الازدواج الضريبي بين المملكة وتونس، الهيئة · إشعار الهيئة بموعد الاستقطاع وغرامة التأخير، 5 يناير 2025
يرمّز هذا الدرس النصوص المنشورة حتى 16 أكتوبر 2026. وهو إرشاد هندسي لا استشارة ضريبية في حالة بعينها. يتوقف تصنيف أي دفعة على عقدها ووقائعها.