writing/tutorial/2026/08
TutorialAug 31, 2026·32 min read

Saudi VAT Return in TypeScript: Ledger to ZATCA's 16 Boxes

Every accounting system in Saudi Arabia has to turn a ledger into sixteen numbers on ZATCA's VAT return. This tutorial builds that engine in TypeScript: box mapping, the Adjustment column, reverse charge in box 9, blocked input tax under Article 50, proportional deduction under Article 51, and the SAR 15,000 correction threshold that most vendor tooling still hardcodes as 5,000.

There is no ZATCA API for filing a VAT return. There is a Fatoora API for invoices — five endpoints, one invoice at a time, certificates and clearance and reporting — and then there is a web form with sixteen rows that a human types numbers into once a month or once a quarter.

That gap is the whole job. Everything before the form can be automated; the form itself cannot. So the engine you build does not "file" anything. It produces sixteen numbers that are defensible in an audit, and it produces the working papers that prove where each of them came from.

This tutorial builds that engine. It is deliberately not a tour of the ZATCA portal — there are dozens of those, and ZATCA's own service page outranks all of them. It is the part nobody writes down: how the ledger becomes the return, and the eleven or twelve places where the mapping is quietly wrong in software that has been filing for years.

What You'll Build

A computeVatReturn() function that takes a period, a chart of accounts and a list of ledger transactions, and returns:

  • the sixteen return boxes, each with amount, adjustment and vat
  • a per-box audit trail listing the transactions that fed it
  • a list of blocking issues (input tax that must be reversed, exports missing evidence, corrections above the threshold that need a voluntary disclosure instead)

Plus the two things people forget: the annual apportionment true-up that belongs in the final return of the calendar year, and the unpaid-supplier reversal that no off-the-shelf report performs.

Prerequisites

  • Node.js 20+ and TypeScript 5.5+
  • A working understanding of double-entry bookkeeping — this tutorial assumes you know what an AP ageing report is
  • Familiarity with Saudi VAT at the invoice level. If VAT arithmetic itself is the open question, start with how Saudi VAT is calculated at 15% and come back
  • If you also need to clear invoices with ZATCA, that is a separate pipeline: ZATCA Phase 2 e-invoicing integration in TypeScript

A note on sources. ZATCA's English PDF of the VAT Implementing Regulations is the 8th edition dated 09/11/2021. The Arabic PDF is the current consolidated text and includes Board Resolutions 01-04-23 and 01-06-24. Two of the rules below — the correction threshold and the blocked input tax list — differ between the two. Where they disagree, the Arabic text governs. Article numbers in this tutorial are from the Arabic consolidated text.

Step 1: Know the Shape of the Form Before You Model It

The return has sixteen rows. Rows 1 through 5 are sales, 7 through 11 are purchases, and 6, 12, 13, 15 and 16 are computed by the portal. Each input row has three columns: Amount (SAR), Adjustment (SAR), VAT Amount (SAR).

BoxLine
1Standard rated sales
2Private healthcare, private education and first-house supplies to citizens
3Zero rated domestic sales
4Exports
5Exempt sales
6Total sales (computed)
7Standard rated domestic purchases
8Imports subject to VAT paid at customs
9Taxable imports subject to VAT accounted for through the reverse charge mechanism
10Zero rated purchases
11Exempt purchases
12Total purchases (computed)
13Total VAT due for the current period (computed)
14Corrections from previous period
15VAT credit carried forward (computed)
16Net VAT due or reclaimed (computed)

Three properties of this form drive the entire design, and each one is a place where implementations go wrong.

Amounts are entered exclusive of VAT. The portal computes the VAT column itself. ZATCA's filing guide says this twice, and it is the single most common data error on its own published list of common mistakes: entering VAT-inclusive figures instantly overstates the deduction.

The Adjustment column is subtractive, and you enter it as a positive number. The portal derives VAT from amount - adjustment. ZATCA's own worked example: purchases of 20,000 carrying 3,000 of VAT, of which 7,500 of base is not deductible, is entered as amount 20,000, adjustment 7,500 — leaving a deductible base of 12,500 and VAT of 1,875. A great many systems model this as a negative number or as a separate reversing line. Both produce a return that does not tie.

Box 14 is not like the others. You enter a VAT amount only, with no base. ZATCA is explicit: تتم تعبئة خانة التصحيحات عبر إدخال قيمة ضريبة القيمة المضافة فقط.

Model it honestly:

// src/types.ts
 
/** SAR stored as integer halalas. Never use floats for tax. */
export type Halalas = number;
 
export interface BoxValue {
  /** VAT-exclusive base, in halalas. */
  amount: Halalas;
  /** Subtractive, entered positive. The portal computes VAT on (amount - adjustment). */
  adjustment: Halalas;
  /** What we expect the portal to compute. Used for reconciliation, not for filing. */
  vat: Halalas;
}
 
export type BoxId =
  | 1 | 2 | 3 | 4 | 5 | 6
  | 7 | 8 | 9 | 10 | 11 | 12
  | 13 | 14 | 15 | 16;
 
export interface VatReturn {
  taxpayerVatNumber: string;
  periodStart: string; // ISO date
  periodEnd: string;   // ISO date
  frequency: "monthly" | "quarterly";
  boxes: Record<BoxId, BoxValue>;
  /** Which ledger lines produced which box. This is the working paper. */
  trail: Record<BoxId, string[]>;
  issues: Issue[];
}
 
export interface Issue {
  severity: "blocking" | "review";
  code: string;
  message: string;
  reference?: string; // article of the Implementing Regulations
  transactionIds?: string[];
}

Integers, always. Halalas are hundredths of a riyal. Store every monetary value as an integer number of halalas and divide only at the presentation layer. Floating point on 15% of a large ledger produces a return that is off by a few halalas, and reconciling those against the portal's arithmetic burns an afternoon a month forever.

Step 2: Decide the Period Before You Sum Anything

Article 58 sets the tax period. Monthly if taxable supplies in the previous twelve months exceeded SAR 40,000,000; three months otherwise. A taxpayer below the threshold may apply to move to monthly, effective from the period after approval, and after two years on monthly may apply to go back.

Article 62(1) sets the filing deadline: the last day of the month following the end of the tax period. Article 59(1) sets payment on the same date.

Then there is the rule that quietly generates penalties. Article 74(1): returns and payments must be made on or before that date whether or not it is a working day. Only other obligations roll forward to the next working day, and a working day is any day except Friday, Saturday and state holidays.

Almost every scheduling library does the opposite by default. A 31 January deadline that lands on a Friday is still 31 January.

// src/period.ts
import { Halalas } from "./types";
 
const MONTHLY_THRESHOLD: Halalas = 40_000_000_00; // SAR 40m in halalas
 
export function requiredFrequency(
  taxableSuppliesLast12Months: Halalas,
  optedIntoMonthly = false,
): "monthly" | "quarterly" {
  // Article 58(1): strictly "exceeds", not "reaches".
  if (taxableSuppliesLast12Months > MONTHLY_THRESHOLD) return "monthly";
  // Article 58(3): voluntary opt-in is permitted on application.
  return optedIntoMonthly ? "monthly" : "quarterly";
}
 
/**
 * Article 62(1) + Article 59(1): last day of the month following period end.
 * Article 74(1): NO weekend or holiday relief. Do not roll forward.
 */
export function filingDeadline(periodEnd: Date): Date {
  return new Date(Date.UTC(
    periodEnd.getUTCFullYear(),
    periodEnd.getUTCMonth() + 2, // first of the month after next
    0,                            // day 0 = last day of the previous month
  ));
}

Get this wrong and you are looking at Article 42(3) — a late-filing penalty of not less than 5% and not more than 25% of the tax that should have been declared — plus Article 43, 5% of the unpaid tax for each month or part of a month it stays unpaid.

Step 3: Classify Every Transaction Once

The mapping from a ledger line to a box is a classification problem, and it is worth isolating it. Everything downstream is summation.

// src/classify.ts
 
export type SupplyKind =
  | "standard_rated_sale"
  | "citizen_health_edu_housing"
  | "zero_rated_domestic_sale"
  | "export"
  | "exempt_sale"
  | "standard_rated_purchase"
  | "import_vat_at_customs"
  | "import_reverse_charge"
  | "zero_rated_purchase"
  | "exempt_purchase"
  | "out_of_scope";
 
export interface ExportEvidence {
  customsDocument?: string;
  commercialDocument?: string;
  transportDocument?: string;
}
 
export interface LedgerTxn {
  id: string;
  date: string;          // ISO, the date the tax became due
  direction: "sale" | "purchase";
  /** VAT-exclusive base in halalas, in SAR after any currency conversion. */
  netAmount: number;
  vatAmount: number;
  vatRate: number;       // 0.15 or 0
  /** Exempt and zero-rated both show 0 VAT. Only the CoA knows which is which. */
  exemptSupply: boolean;
  counterpartyVatNumber?: string;
  counterpartyCountry: string; // ISO-3166 alpha-2
  counterpartyResident: boolean;
  /** Set when the goods physically cleared Saudi customs and VAT was paid there. */
  customsDeclarationNumber?: string;
  exportEvidence?: ExportEvidence;
  // --- purchase side only ---
  expenseCategory?: ExpenseCategory;
  /** Article 51(1)/(2)/(3): how this input tax attaches to the activity. */
  attribution?: "taxable" | "exempt" | "residual";
  /** Article 50(1): a blocked item resupplied onward as a taxable supply. */
  resuppliedAsTaxableSupply?: boolean;
  /** Article 50(1)(b)/(c): a Saudi statutory obligation unblocks the item. */
  statutoryObligation?: boolean;
  /** Date the supplier was actually paid. Undefined means still unpaid. */
  supplierPaidOn?: string;
  isCapitalAsset?: boolean;
  isNominalSupply?: boolean;
}
 
export const BOX_BY_KIND: Record<Exclude<SupplyKind, "out_of_scope">, number> = {
  standard_rated_sale: 1,
  citizen_health_edu_housing: 2,
  zero_rated_domestic_sale: 3,
  export: 4,
  exempt_sale: 5,
  standard_rated_purchase: 7,
  import_vat_at_customs: 8,
  import_reverse_charge: 9,
  zero_rated_purchase: 10,
  exempt_purchase: 11,
};

The classification rule that trips systems most often is box 8 versus box 9. Both are imports. They are not interchangeable:

  • Box 8 is import VAT actually paid to Saudi Customs at the border. You have a customs declaration.
  • Box 9 is VAT you account for yourself: the reverse charge under Article 47, and also deferred import VAT under Article 44 for taxpayers approved to pay import VAT through the return rather than at customs.

A system that keys off "is the counterparty foreign?" will route both to the same place. The discriminator is whether Saudi Customs collected the tax.

export function classify(txn: LedgerTxn): SupplyKind {
  if (txn.direction === "sale") {
    if (!txn.counterpartyResident || txn.counterpartyCountry !== "SA") return "export";
    if (txn.vatRate === 0.15) return "standard_rated_sale";
    // Exempt vs zero-rated is a property of the supply, not of the rate charged.
    // Both show 0 VAT. Only the chart of accounts knows which is which.
    return txn.exemptSupply ? "exempt_sale" : "zero_rated_domestic_sale";
  }
 
  if (!txn.counterpartyResident) {
    // Article 44 deferral and Article 47 reverse charge both land in box 9.
    // Customs collection is the discriminator, not the counterparty's country.
    return txn.customsDeclarationNumber
      ? "import_vat_at_customs"
      : "import_reverse_charge";
  }
 
  if (txn.vatRate === 0.15) return "standard_rated_purchase";
  return txn.exemptSupply ? "exempt_purchase" : "zero_rated_purchase";
}

Step 4: Box 9 Is a Single Netted Line, Not Two

This is the one that surprises people who have implemented reverse charge in the EU or the UK, where you post an output line and an input line that cancel.

ZATCA's Imports and Exports Guideline is explicit: VAT accounted for under the reverse charge mechanism is reported in field 9, and the return form automatically treats the input tax as deductible. Taxpayers who are not entitled to fully deduct input tax must make the necessary adjustments in field 9.

So box 9 carries no separate output row. If you are fully taxable, the net VAT on that line is zero and you enter no adjustment. If you are partly exempt, the Adjustment column is where you claw back the non-recoverable share — and if you leave it blank, you have under-declared.

ZATCA's own worked example, a bank at 70% recovery receiving SAR 100,000 of foreign legal services: amount 100,000, adjustment 30,000. (The published example uses the old 5% rate, so its VAT figure is 1,500; at 15% the same shape gives 10,500.)

// src/box9.ts
import { Halalas } from "./types";
 
/**
 * Article 47 reverse charge, as the return actually models it.
 * The form auto-deducts in full, so the ONLY thing that makes a partly-exempt
 * taxpayer's return correct is the adjustment.
 */
export function box9(
  reverseChargeBase: Halalas,
  recoveryRate: number, // 0..1, from Article 51 apportionment
): { amount: Halalas; adjustment: Halalas; vat: Halalas } {
  const nonRecoverableBase = Math.round(reverseChargeBase * (1 - recoveryRate));
  const deductibleBase = reverseChargeBase - nonRecoverableBase;
  return {
    amount: reverseChargeBase,
    adjustment: nonRecoverableBase,
    vat: Math.round(deductibleBase * 0.15),
  };
}

One more box 9 rule that costs money in the other direction: receiving an exempt service from a non-resident — a foreign loan, for instance — is not reported at all. Systems that reverse-charge every foreign invoice inflate both sides of the return for no reason and invite questions.

Step 5: Blocked Input Tax — Article 50 Changed in 2024

If you coded this list from the English 8th-edition PDF, or from a blog post, it is out of date. Resolution 01-06-24 amended Article 50, effective 18 April 2025. The current list of input tax that is not deductible, unless the item is re-supplied onward as a taxable supply:

  • (a) any form of entertainment, sporting or cultural services, or attendance at events of an entertainment nature
  • (b) hospitality and catering of food and beverages — unless a law in force in the Kingdom obliges the taxpayer to provide them to employees at the workplace
  • (c) insurance or healthcare services provided to employees and their dependants, unless statutorily obligatory — this leg is new
  • (d) purchase or lease of Restricted Motor Vehicles
  • (e) insurance on Restricted Motor Vehicles, or their repair, modification or maintenance
  • (f) fuel used in Restricted Motor Vehicles
  • (g) any goods or services acquired for personal use or for purposes other than the activity

Article 50(2) also redefined "Restricted Motor Vehicle". It is now any vehicle designed to carry not more than ten persons, excluding: trucks, cranes and similar heavy equipment used exclusively for the activity with no private availability; vehicles bought or leased for onward taxable supply by sale or lease; vehicles registered as emergency vehicles; and vehicles used exclusively for the activity with no private availability.

The old test was "any vehicle designed to be used on the road". A seat-count rule is a materially different classification problem: it is a property of the vehicle model, not of the expense account, so it belongs on your asset or fleet record, not in a GL mapping table.

// src/blocked.ts
 
export type ExpenseCategory =
  | "entertainment"
  | "hospitality_catering"
  | "employee_insurance_healthcare"
  | "restricted_vehicle_acquisition"
  | "restricted_vehicle_insurance_maintenance"
  | "restricted_vehicle_fuel"
  | "personal_use"
  | "business";
 
export interface BlockingContext {
  /** Article 50(1): blocked items become deductible if supplied onward, taxable. */
  resuppliedAsTaxableSupply: boolean;
  /** Article 50(1)(b) and (c): a Saudi statutory obligation unblocks these. */
  statutoryObligation: boolean;
}
 
const ALWAYS_BLOCKED: ExpenseCategory[] = [
  "entertainment",
  "restricted_vehicle_acquisition",
  "restricted_vehicle_insurance_maintenance",
  "restricted_vehicle_fuel",
  "personal_use",
];
 
const UNBLOCKED_BY_STATUTE: ExpenseCategory[] = [
  "hospitality_catering",           // 50(1)(b): workplace provision required by law
  "employee_insurance_healthcare",  // 50(1)(c): added by Resolution 01-06-24
];
 
export function isInputTaxBlocked(
  category: ExpenseCategory,
  ctx: BlockingContext,
): boolean {
  if (category === "business") return false;
  if (ctx.resuppliedAsTaxableSupply) return false;
  if (UNBLOCKED_BY_STATUTE.includes(category)) return !ctx.statutoryObligation;
  return ALWAYS_BLOCKED.includes(category);
}
 
/**
 * Article 50(2), as amended: seat count, plus four carve-outs.
 * This is vehicle-record data, not chart-of-accounts data.
 */
export function isRestrictedMotorVehicle(v: {
  seatingCapacity: number;
  usedExclusivelyForActivity: boolean;
  availableForPrivateUse: boolean;
  heldForOnwardSupplyOrLease: boolean;
  registeredEmergencyVehicle: boolean;
  isHeavyEquipment: boolean;
}): boolean {
  if (v.seatingCapacity > 10) return false;
  if (v.heldForOnwardSupplyOrLease) return false;
  if (v.registeredEmergencyVehicle) return false;
  if (v.isHeavyEquipment && v.usedExclusivelyForActivity && !v.availableForPrivateUse) return false;
  if (v.usedExclusivelyForActivity && !v.availableForPrivateUse) return false;
  return true;
}

Blocked input tax goes in the Adjustment column of box 7 — the base, not the VAT. That is exactly the shape of ZATCA's 20,000 / 7,500 example.

Step 6: Proportional Deduction — Article 51, and the Annual True-Up

If the business makes both taxable and exempt supplies, most input tax is not fully recoverable. Article 51 sets the method:

  • 51(1) Input tax exclusively and directly attributable to taxable supplies is fully deductible.
  • 51(2) Input tax exclusively attributable to exempt supplies is not deductible at all.
  • 51(3)–(4) Everything else uses a fraction: taxable supplies in the last calendar year over taxable plus exempt supplies in the last calendar year. Supplies made outside the Kingdom count on the basis of how they would have been treated had they been made inside it.
  • 51(5) The fraction excludes supplies of capital assets and supplies made from an establishment outside the Kingdom.
  • 51(6) A taxpayer not registered in the prior year uses estimated current-year values.
  • 51(7) At calendar year end, compare the values used against actual and adjust the input tax in the final return of that calendar year.

There is no rounding rule for the recovery percentage. Do not import "round up to the nearest whole percent" from UK or EU practice — it does not exist here, and rounding 82.3% up to 83% overstates your deduction.

// src/apportionment.ts
 
export interface AnnualSupplyValues {
  taxableSupplies: number;   // excl. capital assets and overseas establishments (51(5))
  exemptSupplies: number;    // idem
}
 
/**
 * Article 51(3)-(5). Returns an unrounded ratio in [0, 1].
 * There is NO statutory rounding of this percentage in KSA. Keep full precision.
 */
export function recoveryRate(prior: AnnualSupplyValues): number {
  const denominator = prior.taxableSupplies + prior.exemptSupplies;
  if (denominator === 0) return 1;
  return prior.taxableSupplies / denominator;
}
 
/**
 * Article 51(7): mandatory annual adjustment in the FINAL return of the calendar
 * year — December for monthly filers, Q4 for quarterly filers.
 * Positive result = additional deduction. Negative = claw back.
 */
export function annualApportionmentAdjustment(
  residualInputTaxForYear: number,
  rateUsedDuringYear: number,
  actual: AnnualSupplyValues,
): number {
  const actualRate = recoveryRate(actual);
  return Math.round(residualInputTaxForYear * (actualRate - rateUsedDuringYear));
}
 
export function isFinalReturnOfCalendarYear(
  periodEnd: Date,
  frequency: "monthly" | "quarterly",
): boolean {
  const isDecemberEnd = periodEnd.getUTCMonth() === 11;
  return frequency === "monthly"
    ? isDecemberEnd
    : isDecemberEnd && periodEnd.getUTCDate() === 31;
}

Two neighbours worth wiring in at the same time. Article 52 requires capital-asset adjustments over an adjustment period of six years for movable tangible and intangible assets and ten years for immovable ones — which needs an asset register carrying the recovery rate applied at acquisition, not just a fixed-asset module. And Article 51(8)–(9) allow an alternative method by application if it better reflects actual use, for a period ZATCA sets, up to five years, after which you reapply.

Step 7: The Correction Threshold Is SAR 15,000

Here is the number that most Saudi accounting tooling still has wrong.

Article 63(3) was amended by Board Decision 01-04-23 dated 26/11/1444H, effective 23 June 2023. The threshold below which an understatement may be swept into the next return, rather than triggering an amendment of the original one, went from SAR 5,000 to SAR 15,000.

Current Article 63:

  • 63(1) An understatement of net tax must be notified to ZATCA within 20 days of becoming aware of it, by amending the previously filed return — unless 63(3) applies.
  • 63(2) An overstatement may be deducted from net tax due in any subsequent return after discovery, subject to 63(4).
  • 63(3) The exception: an understatement whose net value is less than SAR 15,000 is added to net tax due in the return for the period in which the error was discovered. That is box 14.
  • 63(4) No correction of an overstatement more than five years after the end of the calendar year containing the tax period.
  • 63(5) Every correction must state the period or periods, the output and input tax being corrected per period, and the reason.

The threshold is strict: less than 15,000, not 15,000 or less.

Why this matters more than a threshold usually would: Article 42(1) of the VAT Law imposes a penalty of 50% of the difference for filing an incorrect return, submitting a document that results in tax being calculated at less than due — and, in terms, for amending a return after submission. Correcting inside box 14 under 63(3) avoids the amendment entirely. Getting the threshold wrong by hardcoding 5,000 pushes a perfectly legitimate 12,000 correction down the amendment path and into scope of a penalty article it never needed to touch.

// src/corrections.ts
 
/** Article 63(3), as amended 23 June 2023. NOT 5,000. Strictly less-than. */
export const CORRECTION_THRESHOLD = 15_000_00; // halalas
 
export interface PriorPeriodError {
  id: string;
  periodEnd: string;
  /** Positive = tax was understated. Negative = overstated. Halalas. */
  netVatEffect: number;
  reason: string;
  discoveredOn: string;
}
 
export function routeCorrections(errors: PriorPeriodError[], now: Date) {
  const box14: PriorPeriodError[] = [];
  const requiresAmendment: PriorPeriodError[] = [];
  const timeBarred: PriorPeriodError[] = [];
 
  for (const e of errors) {
    if (e.netVatEffect < 0) {
      // Article 63(4): 5 years from the end of the calendar year of the period.
      const deadline = Date.UTC(new Date(e.periodEnd).getUTCFullYear() + 6, 0, 1);
      (now.getTime() >= deadline ? timeBarred : box14).push(e);
      continue;
    }
    // Article 63(1) vs 63(3).
    (e.netVatEffect < CORRECTION_THRESHOLD ? box14 : requiresAmendment).push(e);
  }
 
  return {
    box14Vat: box14.reduce((sum, e) => sum + e.netVatEffect, 0),
    box14,
    requiresAmendment, // Article 63(1): notify within 20 days
    timeBarred,
  };
}

Remember that box 14 takes a VAT amount only. There is no base and no adjustment on that row.

Step 8: The Checks Nobody Automates

Everything so far produces a return that ties. These are the rules that make it correct, and they are the ones an assessment finds because they are all visible from reports the auditor already has.

Article 40(10): the unpaid-supplier reversal. If you deducted input tax and have not paid the supplier within twelve months of the supply date, you must reduce the deduction. Article 40(11) reinstates it when you eventually pay. This requires joining AP ageing to the VAT ledger. Almost no off-the-shelf system does it — and an auditor finds it in about four minutes from the aged creditors listing.

Article 32: export evidence within 90 days. Zero-rating fails if you do not hold the evidence within ninety days of the supply, and the evidence is a three-part set: customs export documentation, commercial documentation identifying the customer and the place of delivery, and transport documentation. "The customer's address is foreign, therefore zero-rate" is the single most common export assessment there is.

Article 61: currency conversion at the SAMA daily rate on the date the tax became due. Not the invoice date, not a month-end rate, not the group's corporate policy rate. ERPs default to their own rate table and nobody notices until the reconciliation.

Article 62(2)(c) and Article 15: nominal supplies. Deemed supplies must be disclosed. Gifts, samples and goods given to employees are relieved only up to SAR 200 per recipient per calendar year, with an aggregate cap of SAR 50,000 per calendar year. Marketing giveaways and staff perks posted straight to an expense account never reach the VAT engine at all.

Article 49(8): input tax may be deducted late, but not more than five calendar years after the calendar year of the supply. Late-invoice catch-ups need an age gate.

Article 49(7)(a): a correctly issued simplified tax invoice is acceptable alternative evidence for deduction. This one is the opposite error — software that hard-blocks recovery without a full tax invoice under-claims. If that rule surprises you, the detail is in the simplified tax invoice rule most people get wrong.

Article 54, new in 2024: credit and debit notes within fifteen days following the end of the month in which the triggering event occurred. Systems that batch credit notes at quarter close now breach a hard deadline.

Article 66 and retention. Six years generally — but ZATCA's guideline sets eleven years for records on movable capital assets and fifteen for immovable, because Article 52's adjustment period runs first and the five years run after it.

// src/checks.ts
import { Issue, LedgerTxn } from "./types";
 
const DAY = 86_400_000;
 
export function runComplianceChecks(txns: LedgerTxn[], periodEnd: Date): Issue[] {
  const issues: Issue[] = [];
 
  // Article 40(10): input tax deducted, supplier unpaid after 12 months.
  const unpaid = txns.filter((t) =>
    t.direction === "purchase" &&
    t.vatAmount > 0 &&
    !t.supplierPaidOn &&
    periodEnd.getTime() - Date.parse(t.date) > 365 * DAY,
  );
  if (unpaid.length) {
    issues.push({
      severity: "blocking",
      code: "UNPAID_SUPPLIER_REVERSAL",
      reference: "Implementing Regulations, Article 40(10)",
      message:
        `${unpaid.length} purchase(s) with deducted input tax are unpaid after 12 months. ` +
        `The deduction must be reversed, and reinstated on payment under Article 40(11).`,
      transactionIds: unpaid.map((t) => t.id),
    });
  }
 
  // Article 32: export evidence must be held within 90 days.
  const staleExports = txns.filter((t) =>
    t.direction === "sale" &&
    t.counterpartyCountry !== "SA" &&
    !hasCompleteExportEvidence(t) &&
    periodEnd.getTime() - Date.parse(t.date) > 90 * DAY,
  );
  if (staleExports.length) {
    issues.push({
      severity: "blocking",
      code: "EXPORT_EVIDENCE_MISSING",
      reference: "Implementing Regulations, Article 32",
      message:
        `${staleExports.length} export(s) lack the full evidence set after 90 days. ` +
        `Zero-rating fails; these move from box 4 to box 1 at 15%.`,
      transactionIds: staleExports.map((t) => t.id),
    });
  }
 
  // Article 49(8): input tax more than 5 calendar years old.
  const cutoff = Date.UTC(periodEnd.getUTCFullYear() - 5, 0, 1);
  const stale = txns.filter(
    (t) => t.direction === "purchase" && t.vatAmount > 0 && Date.parse(t.date) < cutoff,
  );
  if (stale.length) {
    issues.push({
      severity: "blocking",
      code: "INPUT_TAX_TIME_BARRED",
      reference: "Implementing Regulations, Article 49(8)",
      message: `${stale.length} purchase(s) fall outside the five-year deduction window.`,
      transactionIds: stale.map((t) => t.id),
    });
  }
 
  return issues;
}
 
function hasCompleteExportEvidence(t: LedgerTxn): boolean {
  const e = t.exportEvidence;
  return Boolean(e?.customsDocument && e?.commercialDocument && e?.transportDocument);
}

Step 9: Assemble the Return

// src/compute.ts
import { BoxId, BoxValue, Issue, LedgerTxn, VatReturn } from "./types";
import { classify, BOX_BY_KIND } from "./classify";
import { isInputTaxBlocked } from "./blocked";
import { recoveryRate, annualApportionmentAdjustment, isFinalReturnOfCalendarYear } from "./apportionment";
import { routeCorrections, PriorPeriodError } from "./corrections";
import { runComplianceChecks } from "./checks";
 
const emptyBox = (): BoxValue => ({ amount: 0, adjustment: 0, vat: 0 });
 
export interface ComputeInput {
  taxpayerVatNumber: string;
  periodStart: Date;
  periodEnd: Date;
  frequency: "monthly" | "quarterly";
  transactions: LedgerTxn[];
  priorErrors: PriorPeriodError[];
  priorYearSupplies: { taxableSupplies: number; exemptSupplies: number };
  actualYearSupplies?: { taxableSupplies: number; exemptSupplies: number };
  residualInputTaxForYear?: number;
  creditCarriedForward: number;
}
 
export function computeVatReturn(input: ComputeInput): VatReturn {
  const boxes = Object.fromEntries(
    Array.from({ length: 16 }, (_, i) => [i + 1, emptyBox()]),
  ) as Record<BoxId, BoxValue>;
 
  const trail = Object.fromEntries(
    Array.from({ length: 16 }, (_, i) => [i + 1, [] as string[]]),
  ) as Record<BoxId, string[]>;
 
  const issues: Issue[] = [];
  const rate = recoveryRate(input.priorYearSupplies);
 
  for (const txn of input.transactions) {
    const kind = classify(txn);
    if (kind === "out_of_scope") continue;
 
    const box = BOX_BY_KIND[kind] as BoxId;
    boxes[box].amount += txn.netAmount;
    trail[box].push(txn.id);
 
    if (txn.direction !== "purchase") {
      boxes[box].vat += txn.vatAmount;
      continue;
    }
 
    // Purchases: work out how much of the BASE is not deductible.
    // The adjustment column is subtractive and entered positive.
    let nonDeductibleBase = 0;
 
    const blocked = isInputTaxBlocked(txn.expenseCategory ?? "business", {
      resuppliedAsTaxableSupply: txn.resuppliedAsTaxableSupply ?? false,
      statutoryObligation: txn.statutoryObligation ?? false,
    });
 
    if (blocked) {
      // Article 50: none of it is deductible.
      nonDeductibleBase = txn.netAmount;
    } else if (txn.attribution === "exempt") {
      // Article 51(2).
      nonDeductibleBase = txn.netAmount;
    } else if (txn.attribution === "residual") {
      // Article 51(3)-(4). Capital assets are excluded from the FRACTION
      // (51(5)) but the input tax on them is still apportioned.
      nonDeductibleBase = Math.round(txn.netAmount * (1 - rate));
    }
    // attribution === "taxable" -> Article 51(1), fully deductible, no adjustment.
 
    boxes[box].adjustment += nonDeductibleBase;
    boxes[box].vat += Math.round((txn.netAmount - nonDeductibleBase) * txn.vatRate);
  }
 
  // Article 51(7): the annual true-up belongs in the final return of the year.
  if (
    isFinalReturnOfCalendarYear(input.periodEnd, input.frequency) &&
    input.actualYearSupplies &&
    input.residualInputTaxForYear !== undefined
  ) {
    const trueUp = annualApportionmentAdjustment(
      input.residualInputTaxForYear,
      rate,
      input.actualYearSupplies,
    );
    if (trueUp !== 0) {
      issues.push({
        severity: "review",
        code: "ANNUAL_APPORTIONMENT_TRUEUP",
        reference: "Implementing Regulations, Article 51(7)",
        message:
          `Annual apportionment adjustment of ${(trueUp / 100).toFixed(2)} SAR is due ` +
          `in this return. Rate used during the year: ` +
          `${(rate * 100).toFixed(4)}%; actual: ` +
          `${(recoveryRate(input.actualYearSupplies) * 100).toFixed(4)}%.`,
      });
    }
  }
 
  // Computed rows.
  boxes[6] = sumBoxes(boxes, [1, 2, 3, 4, 5]);
  boxes[12] = sumBoxes(boxes, [7, 8, 9, 10, 11]);
 
  const outputVat = [1, 2, 3, 4, 5].reduce((s, b) => s + boxes[b as BoxId].vat, 0);
  const inputVat = [7, 8, 9, 10, 11].reduce((s, b) => s + boxes[b as BoxId].vat, 0);
  boxes[13] = { amount: 0, adjustment: 0, vat: outputVat - inputVat };
 
  // Article 63: box 14 carries a VAT amount only.
  const corrections = routeCorrections(input.priorErrors, input.periodEnd);
  boxes[14] = { amount: 0, adjustment: 0, vat: corrections.box14Vat };
 
  for (const e of corrections.requiresAmendment) {
    issues.push({
      severity: "blocking",
      code: "CORRECTION_EXCEEDS_THRESHOLD",
      reference: "Implementing Regulations, Article 63(1) and 63(3)",
      message:
        `Error ${e.id} understates tax by ${(e.netVatEffect / 100).toFixed(2)} SAR, ` +
        `at or above the SAR 15,000 threshold. It cannot go in box 14. ` +
        `Amend the original return within 20 days of discovery.`,
      transactionIds: [e.id],
    });
  }
 
  // Article 69(6): the default is carry-forward. A refund only happens on request.
  boxes[15] = { amount: 0, adjustment: 0, vat: input.creditCarriedForward };
  boxes[16] = {
    amount: 0,
    adjustment: 0,
    vat: boxes[13].vat + boxes[14].vat - boxes[15].vat,
  };
 
  issues.push(...runComplianceChecks(input.transactions, input.periodEnd));
 
  return {
    taxpayerVatNumber: input.taxpayerVatNumber,
    periodStart: input.periodStart.toISOString().slice(0, 10),
    periodEnd: input.periodEnd.toISOString().slice(0, 10),
    frequency: input.frequency,
    boxes,
    trail,
    issues,
  };
}
 
function sumBoxes(boxes: Record<BoxId, BoxValue>, ids: number[]): BoxValue {
  return ids.reduce<BoxValue>(
    (acc, id) => ({
      amount: acc.amount + boxes[id as BoxId].amount,
      adjustment: acc.adjustment + boxes[id as BoxId].adjustment,
      vat: acc.vat + boxes[id as BoxId].vat,
    }),
    emptyBox(),
  );
}

Step 10: Credit, Refund and the Handover

Article 69 governs what happens when box 16 comes out negative. Three things to encode:

  • 69(6) The default is carry forward. A refund happens only if you ask for one. A system that automatically reports "refund due" is describing an outcome nobody has requested.
  • 69(2) The request may be made when the return is filed, or at any other time within five years following the end of the calendar year the circumstances relate to.
  • 69(3) ZATCA may reject a refund request if any returns are outstanding — so if your engine has flagged a missing period, the refund is going nowhere.
  • 69(4) Once approved, ZATCA must conclude and initiate payment within sixty days, by bank transfer.
  • 69(5) ZATCA may offset the credit against other amounts owed. The 2024 amendment widened this to any amounts owed under any regulation it administers — including customs fines.

Then comes the part that is not code. The output of computeVatReturn() is sixteen numbers that a human types into the portal, plus a trail that justifies each one.

Make the handover artefact do the work: a one-page summary of the sixteen boxes in portal order, and behind it the per-box transaction listing. When ZATCA asks in eighteen months where box 7's adjustment came from, the answer is a file, not an archaeology project.

Testing Your Implementation

Test the rules, not the totals. Totals passing tells you the arithmetic works; it tells you nothing about whether box 8 and box 9 are the right way round.

// src/__tests__/return.test.ts
import { describe, expect, it } from "vitest";
import { box9 } from "../box9";
import { routeCorrections, CORRECTION_THRESHOLD } from "../corrections";
import { classify } from "../classify";
import { filingDeadline } from "../period";
 
describe("Article 47 reverse charge, box 9", () => {
  it("nets to zero VAT for a fully taxable business", () => {
    const r = box9(100_000_00, 1);
    expect(r.adjustment).toBe(0);
    expect(r.vat).toBe(15_000_00);
  });
 
  it("claws back the non-recoverable share in the adjustment column", () => {
    // ZATCA's own example shape: bank at 70% recovery, SAR 100,000 of services.
    const r = box9(100_000_00, 0.7);
    expect(r.amount).toBe(100_000_00);
    expect(r.adjustment).toBe(30_000_00);
    expect(r.vat).toBe(10_500_00);
  });
});
 
describe("Article 63(3) correction threshold", () => {
  it("is 15,000 and not 5,000", () => {
    expect(CORRECTION_THRESHOLD).toBe(15_000_00);
  });
 
  it("routes 14,999.99 to box 14 and 15,000.00 to an amendment", () => {
    const now = new Date("2026-08-31T00:00:00Z");
    const mk = (id: string, v: number) => ({
      id, periodEnd: "2026-06-30", netVatEffect: v,
      reason: "test", discoveredOn: "2026-08-01",
    });
    const r = routeCorrections([mk("a", 14_999_99), mk("b", 15_000_00)], now);
    expect(r.box14.map((e) => e.id)).toEqual(["a"]);
    expect(r.requiresAmendment.map((e) => e.id)).toEqual(["b"]);
  });
});
 
describe("box 8 vs box 9", () => {
  const base = {
    id: "t1", date: "2026-07-01", direction: "purchase" as const,
    netAmount: 100_000_00, vatAmount: 15_000_00, vatRate: 0.15,
    counterpartyCountry: "AE", counterpartyResident: false,
  };
 
  it("routes customs-cleared imports to box 8", () => {
    expect(classify({ ...base, customsDeclarationNumber: "SA-123" }))
      .toBe("import_vat_at_customs");
  });
 
  it("routes services with no customs declaration to box 9", () => {
    expect(classify(base)).toBe("import_reverse_charge");
  });
});
 
describe("Article 74(1): no weekend relief", () => {
  it("keeps 31 January even when it falls on a Friday", () => {
    const deadline = filingDeadline(new Date("2026-12-31T00:00:00Z"));
    expect(deadline.toISOString().slice(0, 10)).toBe("2027-01-31");
  });
});

Then reconcile against reality. Run the engine over a period you have already filed and diff the sixteen boxes against what was actually submitted. Any difference is either a bug or a historical filing error — and both are worth knowing about while the fines waiver is still open.

Troubleshooting

Your VAT column does not match the portal's. Expected, and mostly harmless. Amounts are entered VAT-exclusive and the portal computes VAT itself; per-line rounding will diverge by halalas. Reconcile the difference rather than overriding it. There is no statutory rounding rule for the return — the only guidance is guideline-level, that tax amounts round to the nearest halala and that the same rounded figure is used on the return.

Box 6 or box 12 does not tie to the trial balance. Usually out-of-scope transactions leaking in, or the reverse of it: a nominal supply under Article 15 that never reached the VAT engine because it was posted straight to marketing expense.

A supplier's VAT number turns out to be a commercial registration number. Both are long numeric strings and the fields get swapped constantly. Check them before the return, not after — verify a Saudi VAT number covers the four methods and what each one actually proves.

You have found errors in a filed period. Route them through Article 63 before doing anything else: under 15,000 goes in box 14 of the current return, at or above it needs an amendment within 20 days. And note that the Cancellation of Fines initiative runs to 31 December 2026 and covers VAT-return-correction penalties — but its cut-off is frozen, so waiting does not help. The detail is in what really drops before 31 December 2026.

You are looking for a filing API. There isn't one. ZATCA's only public API family is Fatoora: Compliance CSID, compliance invoice checks, Production CSID issue and renew, clearance and reporting — certificates plus one invoice at a time. There is no return-submission endpoint, no XML schema for the return, and no bulk import. Saudi Arabia also has no accredited-service-provider filing model; the Solution Providers Directory is described by ZATCA itself as a non-binding guiding list, and it is e-invoicing scope only. Every serious vendor says "generate", never "submit" — the same vendors that offer true direct filing into the UAE's EmaraTax have no ZATCA equivalent.

Next Steps

Conclusion

The sixteen boxes are the easy part. What separates a return that survives an assessment from one that does not is the layer underneath: whether box 9's adjustment reflects your actual recovery rate, whether the input tax on a two-year-old unpaid invoice has been reversed under Article 40(10), whether the export you zero-rated has all three evidence documents, whether the annual true-up landed in December's return, and whether your correction threshold says 15,000 or still says 5,000.

None of that is visible from the portal. All of it is visible from your ledger — which is exactly why the engine belongs on your side of the wall, and why filing being manual costs you far less than most people assume.

If you are carrying this logic inside spreadsheets, or inside an ERP whose KSA localisation you have never actually audited, that is worth a look before the next period closes rather than after. We do this kind of work as a fixed-scope review: bring one filed period and your ledger for it, and we will reconcile the two and tell you which of these rules your current setup is getting wrong. Start at contact — a filed period and a chart of accounts is enough to begin.