Une entreprise saoudienne qui paie des fournisseurs étrangers gère généralement la retenue à la source de deux façons. Soit un comptable applique 5 % à la main sur chaque virement sortant, soit quelqu'un tient un tableur de « cas particuliers » que personne d'autre ne comprend. Les deux cassent au même endroit : une ligne de licence taxée comme un service, une exonération conventionnelle appliquée avec un certificat de résidence expiré, un fournisseur dont l'équipe a passé sept mois sur site sans que personne ne le remarque, un formulaire déposé le 14 au lieu du 10.
Notre article sur la retenue à la source saoudienne sur les services d'un prestataire étranger explique les règles pour un lecteur financier. Ce tutoriel s'adresse à l'ingénieur qui doit les intégrer dans le système de paiement des fournisseurs. Nous allons construire un petit moteur TypeScript qui reçoit les lignes de facture et les fiches fournisseurs et renvoie, pour chaque ligne, le taux, l'impôt, le fondement juridique et les éventuelles alertes, puis agrège le mois en chiffres pour le formulaire mensuel de retenue de la ZATCA (l'autorité saoudienne de la zakat, des impôts et des douanes).
Tout y est rattaché à un texte publié : l'article 68 de la loi sur l'impôt sur le revenu, l'article 63 de son règlement d'application modifié par la décision ministérielle n° 25 du 8/1/1445H, la circulaire de la ZATCA de janvier 2025 sur l'application de la retenue à la source dans le cadre des conventions fiscales, et la convention de non-double imposition entre l'Arabie saoudite et la Tunisie signée le 8 juillet 2010. Là où les textes laissent un choix ouvert, le code le rend explicite et le texte le signale.
Ce que vous allez construire
- Une table des taux qui code l'article 63, paragraphe 1, en points de base, sans aucun nombre flottant.
- Une table des conventions, remplie pour la Tunisie, qui sait quels paiements sont des bénéfices d'entreprise et lesquels sont plafonnés.
- Un test d'établissement stable de services qui trouve la fenêtre de douze mois la plus chargée dans les séjours d'un fournisseur.
- Un moteur par ligne qui choisit entre trois voies : taux interne, avantage conventionnel à la source, ou taux interne avec une demande de remboursement à déposer.
- Un module de calendrier pour l'échéance et la pénalité de 1 % par période de 30 jours.
- Un rapport mensuel et une petite CLI qui l'affiche, plus 13 tests qui figent chaque chiffre de l'article associé.
Prérequis
- Node.js 20 ou plus récent
- TypeScript 5 et
tsxpour exécuter les fichiers directement - Une bonne aisance avec les types TypeScript et
node:test - Une idée de la façon dont votre système stocke fournisseurs et paiements
mkdir saudi-wht && cd saudi-wht
npm init -y
npm install -D typescript tsx @types/node
mkdir src test dataPérimètre. Ce moteur sert la personne tenue de retenir, c'est-à-dire le payeur saoudien. Il ne dépose rien auprès de la ZATCA, il ne remplace pas l'écriture de TVA en autoliquidation sur la même facture, et ce n'est pas un conseil fiscal. Il code les textes publiés au 16 octobre 2026, et chaque endroit où nous avons dû interpréter est signalé par « notre lecture ».
Étape 1 : modéliser les paiements en halalas
La première décision est celle qui cause le plus de bugs par la suite : les montants sont stockés en entiers de halalas, un riyal valant 100 halalas. Une majoration à 5 % sur 200 000 SAR donne 210 526,315... SAR, et les flottants finiront par différer du formulaire d'une halala. Les entiers, non.
Les types de paiement sont les catégories de l'article 63, paragraphe 1. Chaque ligne de facture porte son propre type, car un même contrat mêle souvent un service et une licence, et le taux suit la ligne, pas le contrat.
// 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[];
}Trois champs méritent un mot. netOfTax traduit la clause « prix nets de toute taxe » : le fournisseur doit recevoir le montant écrit, donc l'impôt est calculé sur une base majorée. stays enregistre les jours passés par le personnel du fournisseur dans le Royaume, dont le test conventionnel a besoin. paperwork contient ce que la ZATCA exige avant d'autoriser l'application d'une convention à la source.
Étape 2 : coder la table des taux internes
La décision ministérielle n° 25 du 8/1/1445H a réécrit le paragraphe 1 de l'article 63. Le tableau de la circulaire de 2025 en donne le résultat, et le changement important pour un acheteur de logiciel est que les services techniques et de conseil sont à 5 %, que le fournisseur soit lié ou non. L'ancien taux de 15 % sur les services entre parties liées n'existe plus.
// 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];
}Deux remarques. Les taux sont en points de base, 5 % vaut donc 500, et aucune division n'intervient avant l'arrondi final. Et relatedParty reste dans la fiche fournisseur même si la table ne s'en sert plus : vous en aurez besoin pour les prix de transfert, et pour le jour où la règle changera encore.
La circulaire précise aussi que les services techniques et de conseil sont soumis à la retenue « même lorsqu'ils sont entièrement exécutés à l'étranger ». Il n'y a donc volontairement aucune branche « travail réalisé hors du Royaume » dans cette table.
Étape 3 : coder la convention, pays par pays
Le droit interne est la règle par défaut. Une convention en vigueur avec le pays de résidence du fournisseur peut le réduire ou le supprimer, et la circulaire de la ZATCA indique que les conventions priment sur le droit interne. La question à laquelle répond une convention n'est pas « quel taux ? » mais « le Royaume a-t-il seulement le droit d'imposer ce revenu ? ».
Pour la Tunisie, trois dispositions tranchent, toutes lisibles dans le texte arabe publié sur le site de la ZATCA :
- Article 7 : les bénéfices d'une entreprise tunisienne ne sont imposables qu'en Tunisie, sauf si elle a un établissement stable dans le Royaume.
- Le protocole, point 2(b) : les « bénéfices des entreprises » incluent expressément la fourniture de services. La convention n'a pas d'article distinct sur les rémunérations de services techniques, donc les services restent sous l'article 7.
- Article 12, paragraphe 2 : les redevances peuvent être imposées dans le Royaume, mais sans dépasser 5 % du montant brut lorsque le bénéficiaire effectif réside en Tunisie.
// 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];
}La table est une donnée, pas du code. Ajouter un pays, c'est lire sa convention et écrire une entrée. Ne recopiez pas l'entrée tunisienne pour un pays dont la convention comporte un article sur les services techniques : dans ce cas, les services sont imposables à la source jusqu'au taux plafonné, exactement la situation que la circulaire traite au paragraphe 3.2.11. Un moteur qui devine est pire qu'un moteur qui revient au taux interne et le dit, et c'est ce que fait le nôtre pour tout type absent de la table.
Étape 4 : fonctions de dates
Chaque règle de ce moteur est une comparaison de dates : expiration du certificat, fenêtre de douze mois, échéance, jours de retard. Quelques fonctions en UTC les gardent cohérentes et à l'abri des décalages de fuseau.
// 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 s'arrête au dernier jour du mois, donc douze mois après le 29 février tombent le 28 février. Cela compte pour la fenêtre de l'établissement stable à l'étape suivante.
Étape 5 : le test de l'établissement stable de services (six mois)
L'avantage conventionnel sur les services suppose l'absence d'établissement stable. L'article 5, paragraphe 3(b), de la convention saoudo-tunisienne en crée un lorsqu'une entreprise fournit des services, y compris de conseil, par l'intermédiaire de salariés ou d'autre personnel dans le Royaume « pendant une ou des périodes représentant un total de plus de six mois au cours de toute période de douze mois », pour le même projet ou un projet connexe.
Deux détails compliquent l'affaire par rapport à une simple somme de jours par an. La fenêtre est n'importe quelle période de douze mois, pas l'année civile : quatre mois en fin d'année et trois en début de l'année suivante peuvent franchir le seuil. Et deux ingénieurs présents le même jour font un jour de présence pour l'entreprise, pas deux.
// 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;
}La fonction transforme les séjours en ensemble de jours, puis teste chaque fenêtre qui commence un premier jour de présence ou se termine un dernier jour de présence. La fenêtre la plus chargée a toujours l'une de ces deux formes : on trouve ainsi le vrai maximum sans tester 365 points de départ.
Notre lecture. La convention dit « plus de six mois » sans définir le mois en jours. Nous avons fixé le seuil à 183 jours dans la table des conventions. Une équipe qui prévoit de rester près du seuil ne doit pas se fier au chiffre exact : le code renvoie le nombre de jours et la fenêtre pour qu'un humain décide. Enregistrez les séjours prévus autant que les séjours passés, car un établissement stable constitué à la fin d'un projet affecte les paiements faits à son début.
Étape 6 : le moteur par ligne
C'est le cœur. Pour chaque ligne, il calcule le résultat interne, demande à la table des conventions si un taux inférieur s'applique, puis vérifie si les justificatifs permettent de l'appliquer à la source.
// 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,
};
}La décision a trois issues, chacune correspondant à ce que décrit la circulaire :
domestic. Pas de convention, un type que la table ne couvre pas, un établissement stable, ou un taux conventionnel qui n'est pas inférieur. La ligne est imposée selon l'article 63, paragraphe 1.treaty-at-source. La convention donne un taux inférieur et le payeur détient tout ce qu'exige la procédure d'« avantage à la source » : un certificat de résidence fiscale valide à la date du paiement, la légalisation par l'ambassade saoudienne ou une apostille, et l'approbation par la ZATCA de la demande déposée sur son portail. La circulaire cite un document de plus, l'engagement du payeur sur le formulaire Q/7C de payer tout impôt ou pénalité résultant d'informations erronées ou d'une mauvaise interprétation de la convention ; il fait partie de la demande, nous traitons donc la référence d'approbation comme preuve de son dépôt.refund-candidate. La convention donne un taux inférieur, mais le dossier est incomplet. La circulaire est explicite : le payeur qui ne peut satisfaire aux exigences de l'avantage à la source retient selon le droit interne, puis demande le remboursement. Le moteur fait exactement cela et enregistre le montant récupérable.
La majoration dans amounts est l'endroit où atterrit la clause « nets de taxes ». Si le fournisseur doit recevoir 200 000 SAR et que le taux est de 5 %, la base vaut 200 000 divisé par 0,95, soit 210 526,32 SAR, et l'impôt est la différence : 10 526,32 SAR. Calculer l'impôt comme base - amount plutôt que base * rate garantit que le fournisseur reçoit exactement le montant contractuel après arrondi.
Notez ce que le moteur refuse de faire. Il n'applique pas une convention parce que l'adresse du fournisseur se trouve dans un pays conventionné. Il l'applique quand le certificat, la légalisation et l'approbation de la ZATCA sont au dossier. Le paragraphe 4.2.2.2 de la circulaire fixe en outre un délai de cinq ans pour demander l'avantage conventionnel ou être contrôlé ; au-delà, il ne peut plus être réclamé, ce qui justifie de lancer le rapport des remboursements chaque mois plutôt qu'une fois par an.
Étape 7 : échéance et pénalité de retard
L'impôt retenu sur un paiement est dû avant le 10 du mois suivant ; la ZATCA le rappelle dans ses avis mensuels (les formulaires de décembre 2024 étaient dus au 10 janvier 2025). Les mêmes avis fixent la pénalité de retard à 1 % de l'impôt impayé pour chaque période de 30 jours de retard après l'échéance.
// 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) };
}Notre lecture, rendue explicite. L'avis ne dit pas ce qu'il advient d'une période partielle. Un jour de retard, est-ce 1 % ou encore rien ? La fonction prend la réponse en argument obligatoire au lieu de la cacher. Utilisez
startedpour provisionner prudemment, etcompletedsi c'est la lecture de votre conseiller. Sur les multiples entiers de 30 jours, les deux concordent : 90 jours de retard sur 10 000 SAR coûtent 300 SAR dans les deux cas.
Payer le 10 est dans les temps. Le décompte commence le lendemain.
Étape 8 : agréger le mois
Le formulaire mensuel de la ZATCA demande les montants par type de paiement et par taux. Le rapport regroupe les lignes sur exactement cette clé, totalise l'impôt, recense les remboursements possibles et transmet chaque alerte avec l'identifiant du paiement et le nom du fournisseur.
// 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 est le contrôle le moins cher de tout le système. Un certificat de résidence qui expire entre deux paiements fait passer silencieusement un fournisseur de zéro à 5 %, et le payeur ne s'en aperçoit que lorsque le fournisseur demande pourquoi sa facture a été amputée. Quarante-cinq jours de préavis suffisent pour demander le suivant.
Étape 9 : une CLI pour l'exécuter
La CLI lit deux fichiers JSON et affiche le mois. Elle prend le mois à déclarer et la date du jour en arguments, de sorte qu'une relance la semaine suivante donne le même résultat pour les mêmes données.
// 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`);
}Données d'exemple : un fournisseur tunisien au dossier complet, un autre au dossier incomplet, et un fournisseur dans un pays sans entrée dans la table des conventions :
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 nowLisez-le ligne par ligne. Le jalon de développement du fournisseur tunisien est à zéro au titre de l'article 7. La licence du même fournisseur est à 5 % et non 15 %, car l'article 12 la plafonne. La migration de données du second fournisseur tunisien est majorée et taxée à 5 % faute de dossier, et les mêmes 2 105,26 SAR apparaissent en remboursement possible. Les frais de gestion du groupe sont à 20 %, sans convention à examiner. Et le certificat du premier fournisseur expirera avant le traitement de décembre.
Étape 10 : tester chaque chiffre
Les tests figent les chiffres de l'article associé (10 000, 10 526,32, zéro, 2 500 et 300 SAR) et les cas limites que les tableurs faits main ratent.
// 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.tsLes 13 tests passent. Si vous modifiez un taux ou une entrée de convention, au moins l'un d'eux doit échouer ; c'est tout l'intérêt de figer des chiffres plutôt que de vérifier qu'« un nombre sort ».
Brancher le moteur sur un système de paiement
Le moteur est volontairement pur : pas de base de données, pas de HTTP, pas d'horloge. Il s'appelle donc facilement là où les paiements sont approuvés.
- Au bon de commande, exigez un type de paiement sur chaque ligne. Une ligne non classée est à l'origine de la plupart des erreurs, et le moteur ne peut pas la deviner.
- À l'approbation du paiement, appelez
computeLineet conservez le résultat complet, y comprisbasisetflags, à côté du paiement. Quand la ZATCA demande pourquoi une ligne est à zéro, la réponse est un enregistrement, pas la mémoire de quelqu'un. - En fin de mois, lancez
buildMonthet déposez le formulaire à partir de ses lignes avant le 10. Déposez les demandes de remboursement à partir derefundCandidates. - Chaque semaine, lancez
expiringCertificateset envoyez la liste au responsable du référencement fournisseurs.
Si le système est un ERP difficile à modifier, le même moteur peut tourner derrière une petite API appelée par l'ERP lors de l'approbation. C'est le schéma que nous avons utilisé pour la facturation électronique dans notre tutoriel d'intégration TypeScript de la phase 2 ZATCA.
Dépannage
Le total diffère d'une halala du virement. Une conversion en riyals a eu lieu avant l'arrondi. Restez en halalas de bout en bout et ne formatez qu'à l'affichage, comme le fait la CLI.
Un fournisseur conventionné est taxé à 5 %. Lisez l'alerte. C'est presque toujours un certificat expiré, une apostille manquante ou pas encore de référence d'approbation. L'impôt n'est pas perdu : la ligne est un remboursement possible.
Un fournisseur conventionné est taxé à 5 % avec une alerte « service PE ». Les séjours dépassent le seuil dans une fenêtre de douze mois. Vérifiez la fenêtre affichée ; elle peut chevaucher une fin d'année. Si les séjours sont mal saisis, corrigez les données, pas le seuil.
Une licence est taxée à 15 %. Le pays du fournisseur n'est pas dans la table, ou son entrée n'a pas de plafond pour les redevances. Ajoutez-le à partir du texte de la convention.
Des frais de gestion d'un fournisseur tunisien restent à 20 %. C'est voulu dans ce moteur : nous n'avons pas modélisé le traitement conventionnel des frais de gestion, donc le code garde le taux interne et le signale. Tranchez ce cas avec un conseiller avant de l'ajouter à businessProfits.
La pénalité semble fausse pour quelques jours de retard. Vérifiez le PeriodCount transmis. Les deux lectures ne divergent que sur les périodes partielles.
Ce que ce moteur ne fait pas
- La TVA. L'autoliquidation à 15 % sur un service importé est une écriture distincte, qu'aucune convention sur l'impôt sur le revenu ne touche. Utilisez le calculateur de TVA pour vérifier les montants.
- Les autres conventions. Seule la Tunisie est remplie. Chaque pays supplémentaire est un travail de lecture, pas de code.
- Le dépôt. Il produit les chiffres ; le formulaire se dépose toujours sur le portail de la ZATCA.
- Les anciennes pénalités. Pour des formulaires de retenue déjà en retard, vérifiez ce que couvre l'exonération des pénalités jusqu'au 31 décembre 2026 avant de calculer ce que vous devez.
Conclusion
La retenue à la source sur les fournisseurs étrangers est un problème de classification déguisé en pourcentage. La table des taux tient en dix lignes. Ce qui dérape, c'est tout le reste : le type de ligne, la convention, les jours de présence, les justificatifs et leur expiration, le calendrier. Les mettre dans un moteur typé avec des tests figés transforme le formulaire mensuel en rapport que l'on exécute, et chaque ligne à zéro en enregistrement que l'on peut défendre.
Vous voulez cela dans votre propre chaîne de paiement ? Envoyez-nous la liste de vos fournisseurs non résidents, leurs pays et ce qu'ils fournissent, et nous vous montrerons comment le moteur classe chacun d'eux et ce qui manque pour appliquer une convention. C'est le type de travail d'intégration de systèmes et d'API que nous faisons. Un diagnostic gratuit, sans engagement, via notre page de contact.
Sources : Circulaire fiscale de la ZATCA : application de la retenue à la source au titre des conventions de non-double imposition, version 1, janvier 2025 · Décisions et circulaires de la ZATCA sur la retenue à la source, dont la décision ministérielle n° 25 du 8/1/1445H · Convention de non-double imposition Arabie saoudite–Tunisie, ZATCA · Avis de la ZATCA sur l'échéance de la retenue et la pénalité de retard, 5 janvier 2025
Ce tutoriel code les textes publiés au 16 octobre 2026. Il s'agit d'un guide d'ingénierie, pas d'un conseil fiscal sur un cas précis. La qualification d'un paiement dépend de son contrat et de ses faits.