A Saudi company paying foreign vendors usually handles withholding tax in one of two ways. Either an accountant applies 5% by hand to every outbound transfer, or someone keeps a spreadsheet of "special cases" that nobody else understands. Both break the same way: a licence line taxed as a service, a treaty exemption claimed with an expired residency certificate, a vendor whose team quietly spent seven months on site, a form filed on the 14th instead of the 10th.
Our article on Saudi withholding tax on foreign services explains the rules for a finance reader. This tutorial is for the engineer who has to put those rules inside the payables system. We will build a small TypeScript engine that takes invoice lines and vendor records and returns, for each line, the rate, the tax, the legal basis and any warning, then rolls the month up into the figures for ZATCA's monthly withholding form.
Everything in it is traceable to a published text: Article 68 of the Income Tax Law, Article 63 of its Implementing Regulations as amended by Ministerial Decision 25 of 8/1/1445H, ZATCA's January 2025 circular on applying withholding tax under tax treaties, and the Saudi–Tunisian double taxation agreement signed on 8 July 2010. Where the texts leave a choice open, the code makes it explicit and the text says so.
What you will build
- A rate table that encodes Article 63(1) in basis points, with no floats anywhere.
- A treaty table, filled for Tunisia, that knows which payments are business profits and which are capped.
- A service permanent establishment test that finds the busiest twelve-month window in a vendor's stays.
- A line engine that chooses between three routes: domestic rate, treaty relief at source, or domestic rate with a refund claim to file.
- A calendar module for the due date and the 1% per 30 days late fine.
- A monthly report and a small CLI that prints it, plus 13 tests that pin every figure in the companion article.
Prerequisites
- Node.js 20 or later
- TypeScript 5 and
tsxto run files directly - Comfort with TypeScript types and
node:test - A rough idea of how your payables system stores vendors and payments
mkdir saudi-wht && cd saudi-wht
npm init -y
npm install -D typescript tsx @types/node
mkdir src test dataScope. This is an engine for the withholding person, the Saudi payer. It does not file anything with ZATCA, it does not replace the reverse-charge VAT entry on the same invoice, and it is not tax advice. It encodes the published texts as of 16 October 2026, and every place where we had to interpret them is marked "our reading".
Step 1: Model payments in halalas
The first decision is the one that causes most bugs later: money is stored as integer halalas, one riyal being 100 halalas. A gross-up at 5% on SAR 200,000 produces SAR 210,526.315..., and floating point will eventually disagree with the form by one halala. Integers do not.
The payment types are the categories listed in Article 63(1). Each invoice line carries its own type, because one contract often mixes a service and a licence, and the rate follows the line, not the contract.
// 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[];
}Three fields deserve a word. netOfTax captures the clause "prices are net of any taxes": the vendor must receive the amount written down, so the tax is computed on a grossed-up base. stays is the record of days the vendor's personnel spend in the Kingdom, which the treaty test needs. paperwork holds what ZATCA asks for before it lets you apply a treaty at source.
Step 2: Encode the domestic rate table
Ministerial Decision 25 of 8/1/1445H rewrote paragraph 1 of Article 63. The table in ZATCA's 2025 circular shows the result, and the important change for software buyers is that technical and consulting services are 5% whether the vendor is related or not. The old 15% for related-party services no longer exists.
// 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];
}Two things to notice. Rates are basis points, so 5% is 500, and no division happens until the final rounding. And relatedParty stays on the vendor record even though the rate table no longer uses it: you will want it for transfer pricing and for the day the rule changes again.
The circular also states that technical and consulting services are subject to withholding "even when they are entirely performed abroad". There is no "work done outside the Kingdom" branch in this table, on purpose.
Step 3: Encode the treaty, one country at a time
Domestic law is the default. A treaty in force with the vendor's country of residence can reduce it or remove it, and ZATCA's circular says treaties take precedence over domestic law. The question a treaty answers is not "what rate" but "may the Kingdom tax this at all".
For Tunisia, three provisions decide it, all readable in the Arabic text published on ZATCA's website:
- Article 7: business profits of a Tunisian enterprise are taxable only in Tunisia unless it has a permanent establishment in the Kingdom.
- The protocol, item 2(b): "business profits" expressly includes the provision of services. The treaty has no separate article on fees for technical services, so services stay under Article 7.
- Article 12(2): royalties may be taxed in the Kingdom, but not above 5% of the gross amount when the beneficial owner is resident in Tunisia.
// 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];
}The table is data, not code. Adding another country means reading its treaty and writing one entry. Do not copy Tunisia's entry for a country whose treaty has a technical-services article: in that case services are taxable at source up to the capped rate, which is exactly the situation ZATCA's circular covers in paragraph 3.2.11. An engine that guesses is worse than one that falls back to the domestic rate and says so, which is what ours does for any type the table does not list.
Step 4: Date helpers
Every rule in this engine is a date comparison: certificate expiry, the twelve-month window, the due date, days late. A handful of UTC helpers keep them consistent and free of time-zone drift.
// 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 clamps to the last day of the month, so adding twelve months to 29 February lands on 28 February. That matters for the permanent establishment window in the next step.
Step 5: The six-month service permanent establishment test
Treaty relief for services depends on there being no permanent establishment. Article 5(3)(b) of the Saudi–Tunisian treaty creates one when an enterprise furnishes services, including consultancy, through employees or other personnel in the Kingdom "for a period or periods aggregating more than six months within any twelve-month period", for the same or a connected project.
Two details make this harder than a sum of days per year. The window is any twelve months, not a calendar year, so four months at the end of one year and three at the start of the next can cross the line. And two engineers on site on the same day are one day of presence for the enterprise, not two.
// 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;
}The function turns stays into a set of days, then tests every window that starts on a first day of presence or ends on a last day of presence. The busiest twelve-month window always has one of those shapes, so this finds the true maximum without testing all 365 starting points.
Our reading. The treaty says "more than six months" and does not define a month in days. We set the threshold to 183 days in the treaty table. A team that plans to stay close to the line should not rely on the exact number: the code returns the day count and the window so a human can decide. Record planned stays as well as past ones, because a permanent establishment that exists by the end of a project affects the payments made at its start.
Step 6: The line engine
This is the core. For each invoice line it computes the domestic result, asks the treaty table whether a lower rate applies, and then checks whether the paperwork allows that rate to be used at 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,
};
}The decision has three outcomes, and each one maps to something the circular describes:
domestic. No treaty, a type the treaty table does not cover, a permanent establishment, or a treaty rate that is not lower. The line is taxed under Article 63(1).treaty-at-source. The treaty gives a lower rate and the payer holds everything the "benefit at source" procedure requires: a tax residency certificate valid on the payment date, authentication by the Saudi embassy or an apostille, and ZATCA's approval of the request filed through its portal. The circular lists one more document, the payer's undertaking on Form Q/7C to pay any tax or penalty arising from wrong information or a misreading of the treaty; it is part of the application, so we treat the approval reference as proof it was filed.refund-candidate. The treaty gives a lower rate but the paperwork is incomplete. The circular is explicit that a payer who cannot meet the at-source requirements withholds under domestic law and then applies for a refund. The engine does exactly that, and records how much a refund claim could recover.
The gross-up in amounts is where the "net of taxes" clause lands. If the vendor must receive SAR 200,000 and the rate is 5%, the base is 200,000 divided by 0.95, or SAR 210,526.32, and the tax is the difference: SAR 10,526.32. Computing the tax as base - amount rather than base * rate guarantees the vendor receives exactly the contractual amount after rounding.
Note what the engine refuses to do. It does not apply a treaty because the vendor's address is in a treaty country. It applies it when the certificate, the legalisation and ZATCA's approval are on file. Paragraph 4.2.2.2 of the circular also sets a five-year limit to apply for treaty benefits or be audited; after that, they can no longer be claimed, which is a good reason to run the refund report every month rather than once a year.
Step 7: Due date and late fine
Tax withheld from a payment is due by the 10th of the following month; ZATCA repeats it in its monthly notices (December 2024 forms were due by 10 January 2025). The same notices set the late penalty at 1% of the unpaid tax for every 30 days of delay beyond the due date.
// 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) };
}Our reading, made explicit. The notice does not say what happens with a partial period. Is one day late a 1% fine or no fine yet? The function takes the answer as a required argument instead of hiding it. Use
startedto provision conservatively andcompletedif your adviser reads the rule that way. On whole multiples of 30 days the two agree: 90 days late on SAR 10,000 is SAR 300 either way.
Paying on the 10th is on time. The count starts the day after.
Step 8: Roll the month up
ZATCA's monthly withholding form asks for amounts by payment type and rate. The report groups lines on exactly that key, totals the tax, collects refund candidates and passes every warning through with the payment ID and vendor name attached.
// 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 is the cheapest control in the whole system. A residency certificate that lapses between two payments silently moves a vendor from zero to 5%, and the payer only notices when the vendor asks why its invoice was short. Forty-five days of warning is enough to request the next one.
Step 9: A CLI to run it
The CLI reads two JSON files and prints the month. It takes the reporting month and today's date as arguments, so a rerun next week produces the same output for the same data.
// 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`);
}Sample data, with one Tunisian vendor whose paperwork is in order, one whose paperwork is missing, and one vendor in a country with no treaty entry:
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 nowRead it line by line. The development milestone from the Tunisian vendor is zero under Article 7. The licence from the same vendor is 5%, not 15%, because Article 12 caps it. The second Tunisian vendor's data migration is grossed up and taxed at 5% because nothing is on file, and the same SAR 2,105.26 appears as a refund candidate. The group management fee is 20% with no treaty to look at. And the first vendor's certificate will lapse before the December run.
Step 10: Test every figure
The tests pin the numbers from the companion article (SAR 10,000, 10,526.32, zero, 2,500, 300) and the edge cases that hand-built spreadsheets get wrong.
// 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.tsAll 13 pass. If you change a rate or a treaty entry, at least one of them should fail; that is the point of pinning figures rather than testing that "a number comes out".
Wiring it into a payables system
The engine is deliberately pure: no database, no HTTP, no clock. That makes it easy to call from wherever your payments are approved.
- At purchase order time, require a payment type on each line. An unclassified line is the root of most errors, and the engine cannot guess it.
- At payment approval, call
computeLineand store the full result, includingbasisandflags, next to the payment. When ZATCA asks why a line was taxed at zero, the answer is a stored record, not someone's memory. - At month end, run
buildMonthfor the month and file the form from its rows before the 10th. File the refund claims fromrefundCandidates. - Weekly, run
expiringCertificatesand send the list to whoever owns vendor onboarding.
If the system is an ERP you cannot easily change, the same engine can sit behind a small API that the ERP calls during approval. That is the pattern we used for e-invoicing in our ZATCA Phase 2 TypeScript integration tutorial.
Troubleshooting
The total is one halala off the bank transfer. Something converted to riyals before rounding. Keep halalas end to end and only format at the edge, as the CLI does.
A treaty vendor is being taxed at 5%. Read the flag. It is almost always an expired certificate, a missing apostille, or no approval reference yet. The tax is not lost: the line is a refund candidate.
A treaty vendor is being taxed at 5% with a "service PE" flag. The stays add up to more than the threshold in some twelve-month window. Check the window the flag prints; it may straddle a year end. If the stays were recorded wrongly, fix the data, not the threshold.
A licence is taxed at 15%. The vendor's country is not in the treaty table, or its entry has no royalty cap. Add it from the treaty text.
Management fees from a Tunisian vendor stay at 20%. Correct for this engine: we did not model how the treaty treats management fees, so the code keeps the domestic rate and says so. Decide that case with an adviser before adding it to businessProfits.
The late fine looks wrong for a few days of delay. Check which PeriodCount you passed. The two readings only diverge on partial periods.
What this engine does not do
- VAT. The reverse charge at 15% on an imported service is a separate entry, untouched by any income tax treaty. Use the VAT calculator to check amounts.
- Other treaties. Only Tunisia is filled. Each additional country is a reading job, not a coding job.
- Filing. It produces the figures; the form is still filed through ZATCA's portal.
- Old fines. For late withholding forms already outstanding, check what the fines waiver running to 31 December 2026 covers before computing what you owe.
Conclusion
Withholding tax on foreign vendors is a classification problem dressed up as a percentage. The rate table fits in ten lines. What goes wrong is everything around it: the line type, the treaty, the presence days, the paperwork and its expiry, the calendar. Putting those into a typed engine with pinned tests turns the monthly form into a report you run, and turns each zero-rated line into a record you can defend.
Want this inside your own payables flow? Send us a list of your non-resident vendors, their countries and what they supply, and we will show you how the engine classifies each one and what is missing to apply a treaty. That is the kind of systems and API integration work we do. A free diagnostic, no commitment, through our contact page.
Sources: ZATCA tax circular: Implementation of Withholding Tax under the Double Taxation Agreement, version 1, January 2025 · ZATCA withholding tax decisions and circulars, including Ministerial Decision 25 of 8/1/1445H · Saudi Arabia–Tunisia double taxation agreement, ZATCA · ZATCA notice on the withholding deadline and late-payment penalty, 5 January 2025
This tutorial encodes texts published as of 16 October 2026. It is engineering guidance, not tax advice on a specific case. How a payment is classified depends on its contract and its facts.