writing/tutorial/2026/10
● TutorialOct 30, 2026·26 min read

Saudi VAT Registration Threshold Monitor in TypeScript

Build the month-end check the VAT Implementing Regulations require of every unregistered business: the trailing twelve-month test of Article 3, the forward estimate of Article 4, every CR added together, capital assets left out, the 30-day deadline and effective dates, Article 9(1) for zero-rated sellers, and the Article 13 deregistration rules as amended in 2024. Tested, in TypeScript.

A business in Saudi Arabia does not become liable to register for VAT on a date someone writes in a calendar. It becomes liable at a month-end, the first one at which a calculation crosses SAR 375,000, and the calculation is one almost nobody runs. Article 3 of the VAT Implementing Regulations requires every resident person who is not registered to compute, at the end of each month, the value of their supplies over the twelve months then ended. Article 4 adds a second computation at the same month-end: an estimate of the supplies expected over the next twelve months. Either one crossing the threshold starts a 30-day clock, and missing it costs SAR 10,000 under Article 41 of the VAT Law, before the larger cost of VAT that was due on invoices issued without it.

We explained the rules for owners in Saudi VAT registration: the SAR 375,000 test, month by month. This tutorial is for the people who build the systems those owners use: accounting tools, ERPs, POS back offices and bookkeeping firms with dozens of small clients. It turns that article's "one monthly routine" into a small, tested TypeScript engine you can run at every month-end close.

What You'll Build

A dependency-free module that, given a person's sales and expense register and a twelve-month forecast, answers four questions at each month-end:

  1. Is registration mandatory now? Article 3 on the trailing twelve months, Article 4 on the next twelve, with every commercial registration (CR) the person owns added together and capital asset sales left out.
  2. By when, and from when? The last day to apply (30 days after the month-end) and the effective date each article sets, which are not the same.
  3. Is it only voluntary? Article 7, at SAR 187,500, on supplies or expenses, and Article 9(1) for a seller whose supplies are all zero-rated.
  4. Once registered, must or may the person deregister? Article 13(1), (3), (6) and (8), with the condition resolution 24-06-01 added in 2024.

It also refuses to answer when it should: a month with no entries, or a missing forecast, gives unknown, never "below the threshold".

Prerequisites

  • Node.js 20 or later
  • Working knowledge of TypeScript
  • Familiarity with how your system stores sales lines and expense lines by month
  • No ZATCA API access is needed. Nothing here calls ZATCA; this is the calculation that decides whether you must

A note on sources. Article numbers and quotations are from ZATCA's current consolidated Arabic text of the Implementing Regulations, which includes Board resolution 24-06-01 of 19 November 2024. ZATCA's English PDF is an older edition and does not contain that resolution. Where the two differ, the Arabic text governs. Each place where the engine takes a reading the text does not settle is listed in the section "Readings this engine takes" below.

Step 1: Project Setup

mkdir vat-threshold && cd vat-threshold
npm init -y
npm install --save-dev typescript tsx @types/node
mkdir src tests

tsconfig.json

{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "module": "commonjs",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "noEmit": true,
    "types": ["node"]
  },
  "include": ["src", "tests", "cli.ts"]
}

Every amount is an integer number of halalas. A threshold test that compares floating-point riyals will eventually decide that 374,999.99 plus 0.01 is less than 375,000. Integers make "exactly at the threshold" a value you can test.

Step 2: Months and the Two Date Rules

The Regulations count in calendar months and use two date rules, both in Article 3:

  • The deadline. "Within thirty days of the end of that month." The end of November is 30 November, so the last day is 30 December. The end of February 2027 is 28 February, so the last day is 30 March.
  • The Article 3 effective date. "From the start of the month following the month in which the registration application was submitted." It depends on when the person actually files, not on when they crossed.

src/months.ts

// Calendar months as "YYYY-MM" strings, and the two date rules the
// Regulations use: "within thirty days of the end of that month" and
// "from the start of the month following".
 
export type MonthKey = string; // "2026-11"
 
const MONTH_RE = /^(\d{4})-(0[1-9]|1[0-2])$/;
 
export function parseMonth(m: MonthKey): { year: number; month: number } {
  const match = MONTH_RE.exec(m);
  if (!match) throw new Error(`Bad month key: ${m}`);
  return { year: Number(match[1]), month: Number(match[2]) };
}
 
export function addMonths(m: MonthKey, n: number): MonthKey {
  const { year, month } = parseMonth(m);
  const index = year * 12 + (month - 1) + n;
  const y = Math.floor(index / 12);
  const mo = (index % 12) + 1;
  return `${y}-${String(mo).padStart(2, "0")}`;
}
 
/** The n months ending with `last`, oldest first. */
export function windowEnding(last: MonthKey, n: number): MonthKey[] {
  return Array.from({ length: n }, (_, i) => addMonths(last, i - n + 1));
}
 
/** The n months starting the month after `m`. */
export function windowAfter(m: MonthKey, n: number): MonthKey[] {
  return Array.from({ length: n }, (_, i) => addMonths(m, i + 1));
}
 
/** Last calendar day of the month, as "YYYY-MM-DD". */
export function monthEnd(m: MonthKey): string {
  const { year, month } = parseMonth(m);
  const d = new Date(Date.UTC(year, month, 0));
  return d.toISOString().slice(0, 10);
}
 
/** First day of the month, as "YYYY-MM-DD". */
export function monthStart(m: MonthKey): string {
  return `${m}-01`;
}
 
export function addDays(isoDate: string, days: number): string {
  const d = new Date(`${isoDate}T00:00:00Z`);
  d.setUTCDate(d.getUTCDate() + days);
  return d.toISOString().slice(0, 10);
}
 
export function monthOf(isoDate: string): MonthKey {
  return isoDate.slice(0, 7);
}

Months are strings like "2026-11" because that is how a closed accounting period is usually keyed, and because string comparison of YYYY-MM sorts correctly. Dates are computed in UTC so the server's time zone cannot move a deadline by a day.

Step 3: The Register, One Person, Every CR

The input is the register you already have: sales lines and expense lines by month. Three rules decide what goes into the figure compared with the threshold.

Every CR together. Registration attaches to the person, not to the commercial registration. In decision VD-224-2020, the First Circuit for VAT violations and disputes in Dammam held that VAT registration is linked to all of a person's CRs. An owner with two sole proprietorships, each under the line, may be over it. The register therefore carries a cr field for audit, and the totals ignore it.

Capital assets are left out. Article 6(5) excludes the supply of a capital asset from total annual supplies, provided the asset was used to run the activity under a CR or licence and was not held to earn rent or for resale. Selling the delivery van does not push you over. Selling a flat you held to rent out is not covered by this exclusion, so book it under its real kind.

Exempt supplies are a policy, not a constant. Articles 3 and 4 say the value is computed "in accordance with the requirements set out in the Agreement". Most professional guides leave exempt supplies out of the count, but the Regulations do not say so in these articles. The engine takes the policy as a parameter and, in Step 5, runs both.

src/register.ts

// The monthly register: every sale and expense line, tagged with the
// commercial registration (CR) it was booked under and what kind of supply
// it is. Amounts are integer halalas.
import { MonthKey } from "./months";
 
export type Halalas = number;
 
export type SupplyKind =
  | "standard"       // 15%
  | "zero"           // zero-rated (exports, qualifying medicines...)
  | "exempt"         // residential rent, some financial services
  | "capital_asset"  // sale of an asset used in the business (Art. 6(5))
  | "out_of_scope";  // not a supply in the Kingdom
 
export interface SupplyLine {
  month: MonthKey;
  cr: string;
  kind: SupplyKind;
  amount: Halalas;
}
 
export interface ExpenseLine {
  month: MonthKey;
  cr: string;
  amount: Halalas;
}
 
export type ExemptPolicy = "exclude" | "include";
 
export interface MonthTotals {
  /** Supplies that count toward the threshold under the chosen policy. */
  counted: Halalas;
  /** Of which zero-rated. */
  zeroRated: Halalas;
  expenses: Halalas;
}
 
/**
 * Adds every CR together: registration attaches to the person, not to
 * the commercial registration (GSTC VD-224-2020). Capital asset sales and
 * out-of-scope lines never count. Exempt lines count only under "include".
 */
export function totalsByMonth(
  supplies: SupplyLine[],
  expenses: ExpenseLine[],
  exemptPolicy: ExemptPolicy,
): Map<MonthKey, MonthTotals> {
  const out = new Map<MonthKey, MonthTotals>();
  const row = (m: MonthKey) => {
    let r = out.get(m);
    if (!r) {
      r = { counted: 0, zeroRated: 0, expenses: 0 };
      out.set(m, r);
    }
    return r;
  };
 
  for (const s of supplies) {
    if (!Number.isInteger(s.amount)) throw new Error(`Non-integer halalas on ${s.month}`);
    const r = row(s.month);
    if (s.kind === "capital_asset" || s.kind === "out_of_scope") continue;
    if (s.kind === "exempt" && exemptPolicy === "exclude") continue;
    r.counted += s.amount;
    if (s.kind === "zero") r.zeroRated += s.amount;
  }
  for (const e of expenses) {
    if (!Number.isInteger(e.amount)) throw new Error(`Non-integer halalas on ${e.month}`);
    row(e.month).expenses += e.amount;
  }
  return out;
}

The kind you assign to each line is the classification your system already makes for invoicing. If your item master cannot tell a capital asset sale from a stock sale, fix that first: the monitor is only as good as the tag.

Step 4: The Month-End Test

This is the core. At a month-end asOf, the engine sums the twelve months ending with asOf (Article 3) and the twelve forecast months starting the month after (Article 4), then classifies.

Two comparisons look alike and are not:

  • Mandatory: "exceeds". Article 3 uses «تجاوزت», exceeds. Exactly SAR 375,000.00 does not exceed SAR 375,000; SAR 375,000.01 does. The code uses a strict comparison.
  • Voluntary: "not less than". Article 7 uses «لا يقل عن», not less than. Exactly SAR 187,500 qualifies. The code uses an inclusive comparison, and Article 7 counts supplies or expenses, which matters for a business spending on a fit-out before it sells anything.

The effective dates also differ by article:

  • Article 3(2): the start of the month after the month the application is filed. The engine reports the date that applies if the person files on the last day, the latest they can.
  • Article 4(2): "from the start of the first month in which its annual supplies in the Kingdom were expected to exceed" the threshold. The engine reports the start of the first forecast month, the earliest reading. VAT is due from that date whether or not it was charged.

Article 9(1) is the exception for a person over the line whose supplies are all zero-rated, such as an exporter selling its entire output abroad. It is not required to register, and may register voluntarily. One standard-rated riyal in the window is enough to lose it, so the test is an exact equality of the zero-rated total and the counted total.

src/registration.ts

// The month-end registration test: Implementing Regulations Art. 3
// (trailing twelve months), Art. 4 (next twelve months), Art. 7
// (voluntary), Art. 9(1) (zero-rated only).
import {
  MonthKey, addDays, addMonths, monthEnd, monthOf, monthStart,
  windowAfter, windowEnding,
} from "./months";
import {
  ExemptPolicy, ExpenseLine, Halalas, MonthTotals, SupplyLine, totalsByMonth,
} from "./register";
 
export const MANDATORY_THRESHOLD: Halalas = 375_000_00;
export const VOLUNTARY_THRESHOLD: Halalas = 187_500_00;
export const DAYS_TO_APPLY = 30;
 
export interface ForecastMonth {
  month: MonthKey;
  supplies: Halalas;   // standard + zero-rated; no capital assets, no exempt
  zeroRated: Halalas;  // of which zero-rated
  exempt: Halalas;     // counted only under exemptPolicy "include"
  expenses: Halalas;
}
 
export interface Ledger {
  firstTradingMonth: MonthKey; // months before it count as zero
  supplies: SupplyLine[];
  expenses: ExpenseLine[];
  exemptPolicy: ExemptPolicy;
}
 
export type Status =
  | "mandatory"          // apply within 30 days
  | "zero_rated_only"    // Art. 9(1): over the line, but excepted
  | "voluntary_eligible" // Art. 7: may apply
  | "below"
  | "unknown";           // data missing; no conclusion
 
export type Trigger = "ART3_TRAILING" | "ART4_FORWARD";
 
export interface Assessment {
  asOf: MonthKey;
  trailing: Halalas;
  trailingExpenses: Halalas;
  forward: Halalas | null;
  forwardExpenses: Halalas | null;
  status: Status;
  triggers: Trigger[];
  applyBy: string | null;
  /** Art. 3(2): start of the month after the filing month, if filed on applyBy. */
  art3EffectiveIfFiledOnDeadline: string | null;
  /** Art. 4(2), earliest reading: the first month of the forecast window. */
  art4EffectiveFrom: string | null;
  issues: string[];
}
 
/** Art. 3(2): registration starts the month after the application month. */
export function art3EffectiveFrom(filedOn: string): string {
  return monthStart(addMonths(monthOf(filedOn), 1));
}
 
export function assessMonth(
  asOf: MonthKey,
  ledger: Ledger,
  forecast: ForecastMonth[] | null,
): Assessment {
  const issues: string[] = [];
  const totals = totalsByMonth(ledger.supplies, ledger.expenses, ledger.exemptPolicy);
  const zero: MonthTotals = { counted: 0, zeroRated: 0, expenses: 0 };
 
  // Trailing twelve months, all CRs together.
  let trailing = 0, trailingZero = 0, trailingExpenses = 0;
  for (const m of windowEnding(asOf, 12)) {
    const t = totals.get(m);
    if (!t && m >= ledger.firstTradingMonth) issues.push(`MISSING_MONTH ${m}`);
    const r = t ?? zero;
    trailing += r.counted;
    trailingZero += r.zeroRated;
    trailingExpenses += r.expenses;
  }
 
  // Next twelve months, as estimated at this month-end.
  let forward: Halalas | null = null, forwardZero = 0;
  let forwardExpenses: Halalas | null = null;
  const expected = windowAfter(asOf, 12);
  if (!forecast) {
    issues.push("NO_FORECAST");
  } else if (
    forecast.length !== 12 ||
    forecast.some((f, i) => f.month !== expected[i])
  ) {
    issues.push("FORECAST_NOT_NEXT_12_MONTHS");
  } else {
    forward = 0; forwardExpenses = 0;
    for (const f of forecast) {
      forward += f.supplies + (ledger.exemptPolicy === "include" ? f.exempt : 0);
      forwardZero += f.zeroRated;
      forwardExpenses += f.expenses;
    }
  }
 
  // "Exceeds": strictly more than 375,000.
  const triggers: Trigger[] = [];
  if (trailing > MANDATORY_THRESHOLD) triggers.push("ART3_TRAILING");
  if (forward !== null && forward > MANDATORY_THRESHOLD) triggers.push("ART4_FORWARD");
 
  const applyBy = addDays(monthEnd(asOf), DAYS_TO_APPLY);
  const base = { asOf, trailing, trailingExpenses, forward, forwardExpenses, issues };
 
  if (triggers.length > 0) {
    // Art. 9(1): every supply in the window that crossed is zero-rated.
    const allZero =
      (!triggers.includes("ART3_TRAILING") || trailingZero === trailing) &&
      (!triggers.includes("ART4_FORWARD") || forwardZero === forward);
    if (allZero) {
      return {
        ...base, status: "zero_rated_only", triggers, applyBy: null,
        art3EffectiveIfFiledOnDeadline: null, art4EffectiveFrom: null,
      };
    }
    return {
      ...base, status: "mandatory", triggers, applyBy,
      art3EffectiveIfFiledOnDeadline: triggers.includes("ART3_TRAILING")
        ? art3EffectiveFrom(applyBy) : null,
      art4EffectiveFrom: triggers.includes("ART4_FORWARD")
        ? monthStart(expected[0]) : null,
    };
  }
 
  const none = {
    triggers, applyBy: null,
    art3EffectiveIfFiledOnDeadline: null, art4EffectiveFrom: null,
  };
 
  // Art. 7: "not less than" 187,500, supplies OR expenses, past OR next.
  const voluntary =
    trailing >= VOLUNTARY_THRESHOLD ||
    trailingExpenses >= VOLUNTARY_THRESHOLD ||
    (forward ?? 0) >= VOLUNTARY_THRESHOLD ||
    (forwardExpenses ?? 0) >= VOLUNTARY_THRESHOLD;
 
  // A gap in the data can hide a crossing. Never call it "below".
  if (issues.length > 0) return { ...base, ...none, status: "unknown" };
  return { ...base, ...none, status: voluntary ? "voluntary_eligible" : "below" };
}

Note what happens with incomplete data. A month between the first trading month and asOf with no lines at all is reported as MISSING_MONTH, because a month with no lines is usually a month nobody closed, not a month with no sales. A real quiet month should carry an explicit zero line. A missing or misaligned forecast is reported too. In both cases the engine still reports a trailing crossing, since that is already a fact, but it will not say below or voluntary_eligible, because the missing data could hide a crossing.

Step 5: Run the Count Both Ways

Because the exempt question is not settled in the articles that set the test, the honest output is both answers and a flag when they differ. A landlord who also runs a shop is the usual case: residential rent is exempt, and adding it moves the total.

Add this to src/registration.ts:

src/registration.ts

/**
 * The Regulations do not say, in Arts. 3 and 4, whether exempt supplies
 * count. Run both and flag the months where the answer depends on it.
 */
export function assessBothWays(
  asOf: MonthKey,
  ledger: Ledger,
  forecast: ForecastMonth[] | null,
) {
  const exclude = assessMonth(asOf, { ...ledger, exemptPolicy: "exclude" }, forecast);
  const include = assessMonth(asOf, { ...ledger, exemptPolicy: "include" }, forecast);
  return { exclude, include, dependsOnExempt: exclude.status !== include.status };
}

When dependsOnExempt is true, the system should not decide on its own. It should route the month to a person, with both figures, for written advice. The blog article says the same to owners; this is the code version of that sentence.

Step 6: Deregistration Under Article 13

Once a person is registered, the same monthly figures answer a different question. Article 13 separates what is required from what is allowed:

  • 13(1): the activity stopped. The person must deregister, and must apply within 30 days (13(4)). Resolution 24-06-01 added that all tax obligations must be settled before the request is approved, and that a transfer of the business which ends the activity completely is included.
  • 13(3): required, at any month-end, when all three hold. Supplies or expenses over the past twelve months do not exceed SAR 187,500; over the past twenty-four months they do not exceed SAR 375,000; and they are not expected to exceed SAR 187,500 over the coming months.
  • 13(6): allowed when supplies over the past twelve months do not exceed SAR 375,000 and are not expected to exceed it over the coming twelve.
  • 13(8): locked. A person who registered voluntarily under Article 7 and is still trading may not apply to deregister until twelve months have passed. Stopping the activity is the exception.

src/deregistration.ts

// Deregistration under Implementing Regulations Art. 13, as amended by
// ZATCA Board resolution 24-06-01 (19 November 2024).
import {
  MonthKey, addDays, monthEnd, windowAfter, windowEnding,
} from "./months";
import { Halalas, totalsByMonth } from "./register";
import {
  DAYS_TO_APPLY, ForecastMonth, Ledger, MANDATORY_THRESHOLD, VOLUNTARY_THRESHOLD,
} from "./registration";
 
export interface Registration {
  effectiveFrom: string;   // "YYYY-MM-DD"
  voluntary: boolean;      // registered under Art. 7
}
 
export type DeregStatus =
  | "must_deregister"  // Art. 13(1) or 13(3): apply within 30 days
  | "may_deregister"   // Art. 13(6)
  | "locked"           // Art. 13(8): voluntary, under twelve months
  | "stay"
  | "unknown";
 
export interface DeregAssessment {
  asOf: MonthKey;
  status: DeregStatus;
  rule: string | null;
  applyBy: string | null;
  lockedUntil: string | null;
  blockers: string[];
  issues: string[];
}
 
export interface DeregInput {
  asOf: MonthKey;
  ledger: Ledger;
  registration: Registration;
  forecast: ForecastMonth[] | null;
  ceasedOn?: string;               // date the economic activity stopped
  obligationsOutstanding: boolean; // unfiled returns, unpaid balances
}
 
function oneYearAfter(isoDate: string): string {
  const next = `${Number(isoDate.slice(0, 4)) + 1}${isoDate.slice(4)}`;
  return next.endsWith("-02-29") ? next.replace("-02-29", "-03-01") : next;
}
 
export function assessDeregistration(input: DeregInput): DeregAssessment {
  const { asOf, ledger, registration, forecast } = input;
  const issues: string[] = [];
  // Resolution 24-06-01: obligations settled before approval.
  const blockers = input.obligationsOutstanding ? ["OBLIGATIONS_OUTSTANDING"] : [];
  const base = { asOf, blockers, issues, lockedUntil: null as string | null };
 
  // 13(1): activity stopped. 13(8) does not lock this case.
  if (input.ceasedOn) {
    return {
      ...base, status: "must_deregister", rule: "13(1)",
      applyBy: addDays(input.ceasedOn, DAYS_TO_APPLY),
    };
  }
 
  const totals = totalsByMonth(ledger.supplies, ledger.expenses, ledger.exemptPolicy);
  const sum = (months: MonthKey[]) => {
    let supplies = 0, expenses = 0;
    for (const m of months) {
      const t = totals.get(m);
      if (!t && m >= ledger.firstTradingMonth) issues.push(`MISSING_MONTH ${m}`);
      supplies += t?.counted ?? 0;
      expenses += t?.expenses ?? 0;
    }
    return { supplies, expenses };
  };
  const last12 = sum(windowEnding(asOf, 12));
  const last24 = sum(windowEnding(asOf, 24));
 
  let next: { supplies: Halalas; expenses: Halalas } | null = null;
  const expected = windowAfter(asOf, 12);
  if (!forecast) issues.push("NO_FORECAST");
  else if (forecast.length !== 12 || forecast.some((f, i) => f.month !== expected[i])) {
    issues.push("FORECAST_NOT_NEXT_12_MONTHS");
  } else {
    next = { supplies: 0, expenses: 0 };
    for (const f of forecast) {
      next.supplies += f.supplies + (ledger.exemptPolicy === "include" ? f.exempt : 0);
      next.expenses += f.expenses;
    }
  }
 
  if (issues.length > 0) {
    return { ...base, issues: [...new Set(issues)], status: "unknown", rule: null, applyBy: null };
  }
  const n = next!;
 
  // 13(8): a voluntary registrant still trading cannot apply in year one.
  const lockedUntil = oneYearAfter(registration.effectiveFrom);
  if (registration.voluntary && monthEnd(asOf) < lockedUntil) {
    return { ...base, lockedUntil, status: "locked", rule: "13(8)", applyBy: null };
  }
 
  // 13(3): all three, supplies and expenses alike ("does not exceed").
  const must =
    last12.supplies <= VOLUNTARY_THRESHOLD && last12.expenses <= VOLUNTARY_THRESHOLD &&
    last24.supplies <= MANDATORY_THRESHOLD && last24.expenses <= MANDATORY_THRESHOLD &&
    n.supplies <= VOLUNTARY_THRESHOLD && n.expenses <= VOLUNTARY_THRESHOLD;
  if (must) {
    return {
      ...base, status: "must_deregister", rule: "13(3)",
      applyBy: addDays(monthEnd(asOf), DAYS_TO_APPLY),
    };
  }
 
  // 13(6): supplies only, against the mandatory threshold.
  if (last12.supplies <= MANDATORY_THRESHOLD && n.supplies <= MANDATORY_THRESHOLD) {
    return { ...base, status: "may_deregister", rule: "13(6)", applyBy: null };
  }
  return { ...base, status: "stay", rule: null, applyBy: null };
}

Outstanding obligations are returned as a blocker, not as a status. The person may still be required to deregister; the application will not be approved until the blocker clears. Keeping the two apart lets the system show the right task: "file the missing return, then apply", not "nothing to do".

Step 7: Tests That Pin the Rules

Each rule above has a test, and each test uses figures you can check by hand. The registration tests use the shop from the blog article, opened in January.

tests/registration.test.ts

import { test } from "node:test";
import assert from "node:assert/strict";
import { addMonths, windowAfter } from "../src/months";
import { SupplyLine } from "../src/register";
import {
  ForecastMonth, Ledger, art3EffectiveFrom, assessBothWays, assessMonth,
} from "../src/registration";
 
const SAR = (n: number) => Math.round(n * 100);
 
// The shop from the article: opened January, sales in SAR per month.
const SHOP: [string, number][] = [
  ["2026-01", 20_000], ["2026-02", 22_000], ["2026-03", 25_000],
  ["2026-04", 28_000], ["2026-05", 30_000], ["2026-06", 25_000],
  ["2026-07", 40_000], ["2026-08", 45_000], ["2026-09", 50_000],
  ["2026-10", 60_000], ["2026-11", 55_000],
];
 
function shop(extra: SupplyLine[] = []): Ledger {
  return {
    firstTradingMonth: "2026-01",
    supplies: [
      ...SHOP.map(([month, sar]) => ({ month, cr: "1010000001", kind: "standard" as const, amount: SAR(sar) })),
      ...extra,
    ],
    expenses: [],
    exemptPolicy: "exclude",
  };
}
 
function flat(asOf: string, sarPerMonth: number, zero = 0): ForecastMonth[] {
  return windowAfter(asOf, 12).map((month) => ({
    month, supplies: SAR(sarPerMonth), zeroRated: SAR(zero), exempt: 0, expenses: 0,
  }));
}
 
test("Art. 3: trailing total crosses at the November month-end", () => {
  const oct = assessMonth("2026-10", shop(), flat("2026-10", 20_000));
  assert.equal(oct.trailing, SAR(345_000));
  assert.equal(oct.status, "voluntary_eligible");
 
  const nov = assessMonth("2026-11", shop(), flat("2026-11", 20_000));
  assert.equal(nov.trailing, SAR(400_000));
  assert.equal(nov.status, "mandatory");
  assert.deepEqual(nov.triggers, ["ART3_TRAILING"]);
  assert.equal(nov.applyBy, "2026-12-30");
  assert.equal(nov.art3EffectiveIfFiledOnDeadline, "2027-01-01");
});
 
test("Art. 3(2): effective date follows the filing month", () => {
  assert.equal(art3EffectiveFrom("2026-12-05"), "2027-01-01");
  assert.equal(art3EffectiveFrom("2027-01-02"), "2027-02-01");
});
 
test("exactly 375,000 does not exceed; 375,000.01 does", () => {
  const at = (sar: number) => assessMonth("2026-12", {
    firstTradingMonth: "2026-12",
    supplies: [{ month: "2026-12", cr: "A", kind: "standard", amount: SAR(sar) }],
    expenses: [], exemptPolicy: "exclude",
  }, flat("2026-12", 0));
  assert.equal(at(375_000).status, "voluntary_eligible");
  assert.equal(at(375_000.01).status, "mandatory");
});
 
test("Art. 7: exactly 187,500 in expenses alone is enough to volunteer", () => {
  const a = assessMonth("2026-12", {
    firstTradingMonth: "2026-12", supplies: [],
    expenses: [{ month: "2026-12", cr: "A", amount: SAR(187_500) }],
    exemptPolicy: "exclude",
  }, flat("2026-12", 0));
  assert.equal(a.status, "voluntary_eligible");
});
 
test("Art. 4: a signed contract crosses before the invoices do", () => {
  const ledger: Ledger = {
    firstTradingMonth: "2026-06",
    supplies: ["2026-06", "2026-07", "2026-08", "2026-09"].map((month) => ({
      month, cr: "A", kind: "standard" as const, amount: SAR(5_000),
    })),
    expenses: [], exemptPolicy: "exclude",
  };
  const a = assessMonth("2026-09", ledger, flat("2026-09", 50_000));
  assert.equal(a.trailing, SAR(20_000));
  assert.equal(a.forward, SAR(600_000));
  assert.deepEqual(a.triggers, ["ART4_FORWARD"]);
  assert.equal(a.applyBy, "2026-10-30");
  assert.equal(a.art4EffectiveFrom, "2026-10-01");
  assert.equal(a.art3EffectiveIfFiledOnDeadline, null);
});
 
test("capital asset sales never count (Art. 6(5))", () => {
  const a = assessMonth("2026-10", shop([
    { month: "2026-10", cr: "1010000001", kind: "capital_asset", amount: SAR(90_000) },
  ]), flat("2026-10", 20_000));
  assert.equal(a.trailing, SAR(345_000));
  assert.equal(a.status, "voluntary_eligible");
});
 
test("two CRs, one person: totals are added (VD-224-2020)", () => {
  const ledger: Ledger = {
    firstTradingMonth: "2026-01",
    supplies: Array.from({ length: 12 }, (_, i) => addMonths("2026-01", i)).flatMap((month) => [
      { month, cr: "CR-1", kind: "standard" as const, amount: SAR(20_000) },
      { month, cr: "CR-2", kind: "standard" as const, amount: SAR(15_000) },
    ]),
    expenses: [], exemptPolicy: "exclude",
  };
  const a = assessMonth("2026-12", ledger, flat("2026-12", 0));
  assert.equal(a.trailing, SAR(420_000));
  assert.equal(a.status, "mandatory");
});
 
test("Art. 9(1): over the line but all zero-rated is excepted", () => {
  const ledger: Ledger = {
    firstTradingMonth: "2026-12",
    supplies: [{ month: "2026-12", cr: "A", kind: "zero", amount: SAR(400_000) }],
    expenses: [], exemptPolicy: "exclude",
  };
  assert.equal(assessMonth("2026-12", ledger, flat("2026-12", 0)).status, "zero_rated_only");
 
  ledger.supplies.push({ month: "2026-12", cr: "A", kind: "standard", amount: SAR(1) });
  assert.equal(assessMonth("2026-12", ledger, flat("2026-12", 0)).status, "mandatory");
});
 
test("a month with no lines is a gap, not a zero", () => {
  const ledger = shop();
  ledger.supplies = ledger.supplies.filter((s) => s.month !== "2026-08");
  const a = assessMonth("2026-10", ledger, flat("2026-10", 20_000));
  assert.equal(a.status, "unknown");
  assert.deepEqual(a.issues, ["MISSING_MONTH 2026-08"]);
});
 
test("no forecast: never 'below', but a trailing crossing still stands", () => {
  assert.equal(assessMonth("2026-10", shop(), null).status, "unknown");
  assert.equal(assessMonth("2026-11", shop(), null).status, "mandatory");
});
 
test("a forecast that starts in the wrong month is rejected", () => {
  const a = assessMonth("2026-10", shop(), flat("2026-09", 0));
  assert.deepEqual(a.issues, ["FORECAST_NOT_NEXT_12_MONTHS"]);
});
 
test("exempt rent decides the answer: flagged", () => {
  const r = assessBothWays("2026-10", shop([
    { month: "2026-10", cr: "1010000001", kind: "exempt", amount: SAR(40_000) },
  ]), flat("2026-10", 20_000));
  assert.equal(r.exclude.status, "voluntary_eligible");
  assert.equal(r.include.status, "mandatory");
  assert.equal(r.dependsOnExempt, true);
});

tests/deregistration.test.ts

import { test } from "node:test";
import assert from "node:assert/strict";
import { addMonths, windowAfter } from "../src/months";
import { ForecastMonth, Ledger } from "../src/registration";
import { DeregInput, assessDeregistration } from "../src/deregistration";
 
const SAR = (n: number) => Math.round(n * 100);
 
// Twenty-four months of flat sales, January 2025 to December 2026.
function ledger(sarPerMonth: number): Ledger {
  return {
    firstTradingMonth: "2025-01",
    supplies: Array.from({ length: 24 }, (_, i) => ({
      month: addMonths("2025-01", i), cr: "A", kind: "standard" as const, amount: SAR(sarPerMonth),
    })),
    expenses: [], exemptPolicy: "exclude",
  };
}
 
function flat(asOf: string, sarPerMonth: number): ForecastMonth[] {
  return windowAfter(asOf, 12).map((month) => ({
    month, supplies: SAR(sarPerMonth), zeroRated: 0, exempt: 0, expenses: 0,
  }));
}
 
function input(sarPerMonth: number, forecastSar: number, over: Partial<DeregInput> = {}): DeregInput {
  return {
    asOf: "2026-12",
    ledger: ledger(sarPerMonth),
    registration: { effectiveFrom: "2025-01-01", voluntary: false },
    forecast: flat("2026-12", forecastSar),
    obligationsOutstanding: false,
    ...over,
  };
}
 
test("13(3): all three conditions met, must apply within 30 days", () => {
  // 12 x 15,000 = 180,000; 24 x 15,000 = 360,000; next 12 = 120,000.
  const a = assessDeregistration(input(15_000, 10_000));
  assert.equal(a.status, "must_deregister");
  assert.equal(a.rule, "13(3)");
  assert.equal(a.applyBy, "2027-01-30");
});
 
test("13(3)(b) fails on the 24-month total: only 13(6) applies", () => {
  // 24 x 16,000 = 384,000 exceeds 375,000.
  const a = assessDeregistration(input(16_000, 10_000));
  assert.equal(a.status, "may_deregister");
  assert.equal(a.rule, "13(6)");
});
 
test("13(6): below 375,000 both ways, deregistration is optional", () => {
  const a = assessDeregistration(input(30_000, 30_000));
  assert.equal(a.status, "may_deregister");
});
 
test("over 375,000 in the next twelve months: stay registered", () => {
  const a = assessDeregistration(input(30_000, 32_000));
  assert.equal(a.status, "stay");
});
 
test("13(8): a voluntary registrant still trading is locked for twelve months", () => {
  const a = assessDeregistration(input(5_000, 5_000, {
    registration: { effectiveFrom: "2026-03-01", voluntary: true },
  }));
  assert.equal(a.status, "locked");
  assert.equal(a.lockedUntil, "2027-03-01");
});
 
test("13(1): stopping the activity overrides the lock", () => {
  const a = assessDeregistration(input(5_000, 0, {
    registration: { effectiveFrom: "2026-03-01", voluntary: true },
    ceasedOn: "2026-12-10",
  }));
  assert.equal(a.status, "must_deregister");
  assert.equal(a.applyBy, "2027-01-09");
});
 
test("24-06-01: outstanding obligations are a blocker, not a status", () => {
  const a = assessDeregistration(input(15_000, 10_000, { obligationsOutstanding: true }));
  assert.equal(a.status, "must_deregister");
  assert.deepEqual(a.blockers, ["OBLIGATIONS_OUTSTANDING"]);
});
 
test("a gap in the 24 months gives no answer", () => {
  const i = input(15_000, 10_000);
  i.ledger.supplies = i.ledger.supplies.filter((s) => s.month !== "2025-04");
  const a = assessDeregistration(i);
  assert.equal(a.status, "unknown");
  assert.deepEqual(a.issues, ["MISSING_MONTH 2025-04"]);
});

Run them:

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

All 20 should pass. Before trusting a suite like this, break the code on purpose and check that a test fails. We did that for nine changes: the mandatory comparison made inclusive, the voluntary comparison made strict, 31 days instead of 30, capital asset sales counted, the Article 4 date moved by one month, Article 9(1) ignoring standard-rated sales, the 24-month window shortened to 12, the Article 13(8) lock removed, and missing months ignored. Each one failed at least one test.

Step 8: The Month-End Report

A small CLI reads a register file and prints one line per month-end. The script below builds the register for the shop, with the owner's forecast at each month-end set to SAR 25,000 a month.

make-demo.mjs

const shop = [20000,22000,25000,28000,30000,25000,40000,45000,50000,60000,55000];
const months = shop.map((_, i) => `2026-${String(i+1).padStart(2,"0")}`);
const add = (m, n) => { const [y, mo] = m.split("-").map(Number); const i = y*12 + mo-1 + n; return `${Math.floor(i/12)}-${String(i%12+1).padStart(2,"0")}`; };
const forecasts = {};
for (const m of months.slice(5)) forecasts[m] = Array.from({length:12}, (_, i) => ({ month: add(m, i+1), supplies: 2500000, zeroRated: 0, exempt: 0, expenses: 0 }));
const ledger = { firstTradingMonth: "2026-01", exemptPolicy: "exclude", expenses: [],
  supplies: months.map((month, i) => ({ month, cr: "1010000001", kind: "standard", amount: shop[i]*100 })) };
console.log(JSON.stringify({ ledger, months: months.slice(5), forecasts }, null, 2));

cli.ts

// Month-end report for one person. Usage: npx tsx cli.ts register.json
import { readFileSync } from "node:fs";
import { assessBothWays, ForecastMonth, Ledger } from "./src/registration";
 
interface RegisterFile {
  ledger: Ledger;
  months: string[];
  forecasts: Record<string, ForecastMonth[]>;
}
 
const file: RegisterFile = JSON.parse(readFileSync(process.argv[2], "utf8"));
const sar = (h: number | null) =>
  h === null ? "-" : (h / 100).toLocaleString("en-US", { minimumFractionDigits: 2 });
 
for (const asOf of file.months) {
  const r = assessBothWays(asOf, file.ledger, file.forecasts[asOf] ?? null);
  const a = r.exclude;
  const parts = [
    asOf.padEnd(8),
    sar(a.trailing).padStart(12),
    sar(a.forward).padStart(12),
    a.status.padEnd(18),
  ];
  if (a.applyBy) parts.push(`apply by ${a.applyBy}`);
  if (a.art3EffectiveIfFiledOnDeadline) {
    parts.push(`registered from ${a.art3EffectiveIfFiledOnDeadline} if filed that day`);
  }
  if (a.art4EffectiveFrom) parts.push(`VAT due from ${a.art4EffectiveFrom}`);
  if (r.dependsOnExempt) parts.push(`(with exempt: ${r.include.status})`);
  if (a.issues.length) parts.push(a.issues.join(", "));
  console.log(parts.join("  "));
}
node make-demo.mjs > register.json
npx tsx cli.ts register.json
2026-06     150,000.00    300,000.00  voluntary_eligible
2026-07     190,000.00    300,000.00  voluntary_eligible
2026-08     235,000.00    300,000.00  voluntary_eligible
2026-09     285,000.00    300,000.00  voluntary_eligible
2026-10     345,000.00    300,000.00  voluntary_eligible
2026-11     400,000.00    300,000.00  mandatory           apply by 2026-12-30  registered from 2027-01-01 if filed that day

The trailing total crosses at the November month-end, the last day to apply is 30 December, and filing that day gives registration from 1 January 2027. That is the table in the blog article, now produced by code.

Now change one input: suppose that at the end of July the owner's honest estimate was "about what July did, every month", SAR 40,000 a month. Edit the July forecast to 4000000 halalas per month and run again. The July line becomes:

2026-07     190,000.00    480,000.00  mandatory           apply by 2026-08-30  VAT due from 2026-08-01

The forward test crossed four months before the trailing one, and under the earliest reading of Article 4(2) VAT is due from 1 August. For a growing business this is the normal case, not the edge case: the forecast usually crosses first. ZATCA's support account has said publicly that for new businesses the threshold is computed on the expected sales of the coming twelve months. A monitor that only sums the past is checking the test that arrives second.

Step 9: Wiring It Into a Real System

The engine is deliberately small. The work that makes it useful is around it.

Run it at month-end close, not on a schedule of its own. The trailing figure is only meaningful once the month is closed. Trigger the assessment from the close event, for every unregistered client or entity, and store the result.

Store the forecast with the result. Each assessment should be written once, with the forecast that produced it, and never updated. If ZATCA later registers the person itself under Article 6(1), the effective date is the one Articles 3 and 4 set. Your record of what was expected, and when, is the evidence of what the person knew at each month-end.

Feed the forecast from things that exist. Signed contracts, recurring subscriptions and confirmed purchase orders are better inputs than a growth percentage. When a contract is signed mid-month, the forecast at that month-end should include it. That is exactly the case Article 4 was written for.

Alert before the deadline, not on it. Raise a task on the day the status turns mandatory, a reminder ten days before applyBy, and an escalation two days before. Route unknown to whoever closes the books, with the missing months listed.

Make the pricing decision part of the task. Registration changes prices. Contracts priced "inclusive" will have VAT taken out of them, so 15 of every 115 riyals received becomes tax. The task that says "apply by 30 December" should also ask "add 15% or absorb it?". The VAT calculator shows both directions for a given price.

After registration, hand over. A registered person needs a different engine: the periodic return. We built it in the Saudi VAT return engine tutorial. From the effective date, your customers will also start checking your number before they deduct the VAT you charge; the Saudi VAT number checker shows what they see, and the supplier VAT number gate tutorial builds that check from their side.

Readings This Engine Takes

Where the text does not settle a point, the engine takes a reading and says so. Change these if your adviser reads them differently.

  • Article 4(2) effective date. "The start of the first month in which annual supplies were expected to exceed" the threshold. We read it as the first month of the forecast window, the earliest date and the one that matches ZATCA's public replies on new businesses. A later reading, the first month at which a rolling total including the forecast would exceed, is possible. The earliest date is the one to plan around.
  • Exempt supplies. Not settled in Articles 3 and 4. The engine computes both and flags months where the answer depends on it.
  • Article 13(3), "supplies or expenses does not exceed". We require both to be within the limit. If either exceeds, the person can still qualify for voluntary registration, so the duty to leave is not triggered.
  • Article 13(3)(c) window. The Arabic says "any of the eleven following months", the English "that month and the eleven following". The engine uses the same twelve-month forecast as Article 4, which covers both.
  • The 24-06-01 settlement condition. The resolution wrote it into paragraph 1, the ceased-activity case, with the words "in all cases". The engine flags outstanding obligations in every deregistration case as a precaution, since every deregistration needs ZATCA's approval.
  • Not modelled. Article 6(2) and 6(3) let ZATCA, on request, move an effective date later (to no later than the start of the month after the application) or earlier. Article 9(2) lets ZATCA combine related persons by notice; when you receive one, put both persons' lines in one ledger. Nonresidents (Article 5) follow a different rule entirely.

Troubleshooting

The status is unknown but every month has sales. Check the month keys. A line keyed "2026-8" instead of "2026-08" never matches a month in the window, so its month shows as missing. Normalise keys at import.

The trailing total differs from the trial balance. The trial balance includes capital asset disposals, out-of-scope income and, depending on your policy, exempt income. Compare against counted, not against revenue.

A client with two CRs shows two separate results. You are running the engine per CR. Run it per person, with every CR's lines in one ledger. Registration attaches to the person.

The forecast is rejected as FORECAST_NOT_NEXT_12_MONTHS. The forecast must start the month after asOf and cover exactly twelve months. A forecast built on the calendar year will not line up after January.

The engine says mandatory for an exporter. At least one line in the window is not tagged zero. Article 9(1) needs every supply to be zero-rated. A single domestic sale, even a small one, removes the exception.

A voluntary registrant shows locked after a bad year. Article 13(8) blocks the application for twelve months from the effective date while the person is still trading. ZATCA may still deregister them under 13(5); the person may not apply.

Next Steps

Conclusion

The registration threshold is not a yearly event. It is a test at every month-end, run twice, once on what happened and once on what is expected, on figures that include every CR the person owns and exclude capital asset sales. The engine in this tutorial does that in a few hundred lines, refuses to guess when data is missing, gives the deadline and the effective date that apply, and covers the way out under Article 13 as amended in 2024. The hard part is not the code. It is the tagging of each line and an honest forecast, and both belong in the month-end close.

Building accounting or ERP software for Saudi businesses, or keeping the books for several of them? We integrate checks like this into existing systems: the register, the forecast feed, the month-end trigger and the alerts. Send us how your system stores sales and contracts today, and we will reply with where the monitor fits and what it needs. Contact us.