écrits/tutorial/2026/08
Tutorial31 août 2026·32 min

Déclaration de TVA saoudienne en TypeScript : du grand livre aux 16 cases de ZATCA

Tout système comptable en Arabie saoudite doit transformer un grand livre en seize chiffres sur la déclaration de TVA de ZATCA. Ce tutoriel construit ce moteur en TypeScript : ventilation des cases, colonne Ajustement, autoliquidation en case 9, TVA non déductible selon l'article 50, déduction proportionnelle selon l'article 51, et le seuil de correction de 15 000 SAR que la plupart des outils codent encore à 5 000.

Il n'existe aucune API ZATCA pour déposer une déclaration de TVA. Il existe une API Fatoora pour les factures — cinq points de terminaison, une facture à la fois, certificats, clearance et reporting — puis il existe un formulaire web de seize lignes dans lequel un humain saisit des chiffres une fois par mois ou par trimestre.

Cet écart, c'est tout le travail. Tout ce qui précède le formulaire est automatisable ; le formulaire lui-même ne l'est pas. Le moteur que vous construisez ne « dépose » donc rien. Il produit seize chiffres défendables lors d'un contrôle, et il produit les papiers de travail qui prouvent d'où vient chacun d'eux.

Ce tutoriel construit ce moteur. Ce n'est délibérément pas une visite guidée du portail ZATCA — il en existe des dizaines, et la page de service de ZATCA elle-même les surclasse toutes. C'est la partie que personne ne rédige : comment le grand livre devient la déclaration, et les onze ou douze endroits où la ventilation est silencieusement fausse dans des logiciels qui déposent des déclarations depuis des années.

Ce que vous allez construire

Une fonction computeVatReturn() qui prend une période, un plan comptable et une liste d'écritures, et renvoie :

  • les seize cases de la déclaration, chacune avec amount, adjustment et vat
  • une piste d'audit par case, listant les écritures qui l'ont alimentée
  • une liste de points bloquants (TVA déductible à reprendre, exportations sans justificatifs, corrections au-dessus du seuil qui exigent une déclaration rectificative plutôt que la case 14)

Plus les deux choses que l'on oublie toujours : la régularisation annuelle du prorata, qui appartient à la dernière déclaration de l'année civile, et la reprise de TVA sur fournisseur impayé, qu'aucun état standard ne réalise.

Prérequis

Note sur les sources. Le PDF anglais du règlement d'application de la loi TVA publié par ZATCA est la 8e édition, datée du 09/11/2021. Le PDF arabe est le texte consolidé en vigueur et intègre les résolutions du conseil 01-04-23 et 01-06-24. Deux des règles ci-dessous — le seuil de correction et la liste de TVA non déductible — diffèrent entre les deux. En cas de divergence, le texte arabe fait foi. Les numéros d'articles de ce tutoriel proviennent du texte arabe consolidé.

Étape 1 : connaître la forme du formulaire avant de le modéliser

La déclaration compte seize lignes. Les lignes 1 à 5 sont des ventes, 7 à 11 des achats, et les lignes 6, 12, 13, 15 et 16 sont calculées par le portail. Chaque ligne de saisie comporte trois colonnes : Montant (SAR), Ajustement (SAR) et Montant de TVA (SAR).

CaseLigne
1Ventes au taux normal
2Santé privée, enseignement privé et premier logement au profit des citoyens
3Ventes intérieures au taux zéro
4Exportations
5Ventes exonérées
6Total des ventes (calculé)
7Achats intérieurs au taux normal
8Importations soumises à la TVA acquittée en douane
9Importations taxables dont la TVA est autoliquidée
10Achats au taux zéro
11Achats exonérés
12Total des achats (calculé)
13Total de la TVA due pour la période (calculé)
14Corrections de la période précédente
15Crédit de TVA reporté (calculé)
16TVA nette due ou à rembourser (calculé)

Trois propriétés de ce formulaire commandent toute la conception, et chacune est un endroit où les implémentations se trompent.

Les montants sont saisis hors TVA. Le portail calcule lui-même la colonne de TVA. Le guide de dépôt de ZATCA le dit deux fois, et c'est l'erreur de données la plus fréquente sur sa propre liste publiée des erreurs courantes : saisir des montants TTC surévalue immédiatement la déduction.

La colonne Ajustement est soustractive, et vous la saisissez en positif. Le portail dérive la TVA de amount - adjustment. L'exemple chiffré de ZATCA : des achats de 20 000 portant 3 000 de TVA, dont 7 500 de base non déductible, se saisissent en montant 20 000 et ajustement 7 500 — laissant une base déductible de 12 500 et une TVA de 1 875. Beaucoup de systèmes modélisent cela par un nombre négatif ou par une ligne d'extourne distincte. Les deux produisent une déclaration qui ne tombe pas juste.

La case 14 n'est pas comme les autres. On y saisit un montant de TVA seul, sans base. ZATCA est explicite : la case des corrections se remplit en saisissant uniquement la valeur de la TVA.

Modélisez cela honnêtement :

// src/types.ts
 
/** SAR stocké en halalas entiers. Jamais de flottants pour la fiscalité. */
export type Halalas = number;
 
export interface BoxValue {
  /** Base hors TVA, en halalas. */
  amount: Halalas;
  /** Soustractif, saisi en positif. Le portail calcule la TVA sur (amount - adjustment). */
  adjustment: Halalas;
  /** Ce que le portail devrait calculer. Sert au rapprochement, pas au dépôt. */
  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>;
  /** Quelles écritures ont produit quelle case. C'est le papier de travail. */
  trail: Record<BoxId, string[]>;
  issues: Issue[];
}
 
export interface Issue {
  severity: "blocking" | "review";
  code: string;
  message: string;
  reference?: string; // article du règlement d'application
  transactionIds?: string[];
}

Des entiers, toujours. Le halala est un centième de riyal. Stockez chaque valeur monétaire en nombre entier de halalas et ne divisez qu'à la couche de présentation. Le calcul flottant sur 15 % d'un grand livre volumineux produit une déclaration décalée de quelques halalas, et rapprocher ces halalas de l'arithmétique du portail consomme une après-midi par mois, indéfiniment.

Étape 2 : déterminer la période avant de sommer quoi que ce soit

L'article 58 fixe la période fiscale. Mensuelle si les livraisons taxables des douze mois précédents ont dépassé 40 000 000 SAR, trimestrielle sinon. Un assujetti sous le seuil peut demander à passer au mensuel, avec effet à la période suivant l'accord, et après deux ans en mensuel peut demander à revenir au trimestriel.

L'article 62(1) fixe l'échéance de dépôt : le dernier jour du mois suivant la fin de la période fiscale. L'article 59(1) place le paiement à la même date.

Vient ensuite la règle qui génère silencieusement des pénalités. Article 74(1) : les déclarations et les paiements doivent intervenir à cette date au plus tard, que ce soit un jour ouvré ou non. Seules les autres obligations sont reportées au jour ouvré suivant, et un jour ouvré est tout jour hors vendredi, samedi et jours fériés officiels.

La plupart des bibliothèques de planification font l'inverse par défaut. Une échéance au 31 janvier tombant un vendredi reste le 31 janvier.

// src/period.ts
import { Halalas } from "./types";
 
const MONTHLY_THRESHOLD: Halalas = 40_000_000_00; // 40 M SAR en halalas
 
export function requiredFrequency(
  taxableSuppliesLast12Months: Halalas,
  optedIntoMonthly = false,
): "monthly" | "quarterly" {
  // Article 58(1) : « dépasse » au sens strict, pas « atteint ».
  if (taxableSuppliesLast12Months > MONTHLY_THRESHOLD) return "monthly";
  // Article 58(3) : l'option volontaire pour le mensuel est admise sur demande.
  return optedIntoMonthly ? "monthly" : "quarterly";
}
 
/**
 * Article 62(1) + article 59(1) : dernier jour du mois suivant la fin de période.
 * Article 74(1) : AUCUN report week-end ni jour férié. Ne pas décaler.
 */
export function filingDeadline(periodEnd: Date): Date {
  return new Date(Date.UTC(
    periodEnd.getUTCFullYear(),
    periodEnd.getUTCMonth() + 2, // premier du mois d'après le mois suivant
    0,                            // jour 0 = dernier jour du mois précédent
  ));
}

Se tromper ici mène à l'article 42(3) — une pénalité de retard de dépôt d'au moins 5 % et d'au plus 25 % de la taxe qui aurait dû être déclarée — et à l'article 43, 5 % de la taxe impayée par mois ou fraction de mois d'impayé.

Étape 3 : classifier chaque écriture une seule fois

La ventilation d'une ligne de grand livre vers une case est un problème de classification, et il mérite d'être isolé. Tout ce qui suit n'est que sommation.

// 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, date d'exigibilité de la taxe
  direction: "sale" | "purchase";
  /** Base hors TVA en halalas, en SAR après toute conversion de devise. */
  netAmount: number;
  vatAmount: number;
  vatRate: number;       // 0.15 ou 0
  /** Exonéré et taux zéro affichent tous deux 0 de TVA. Seul le plan comptable distingue. */
  exemptSupply: boolean;
  counterpartyVatNumber?: string;
  counterpartyCountry: string; // ISO-3166 alpha-2
  counterpartyResident: boolean;
  /** Renseigné quand les biens ont physiquement passé la douane saoudienne. */
  customsDeclarationNumber?: string;
  exportEvidence?: ExportEvidence;
  // --- côté achats uniquement ---
  expenseCategory?: ExpenseCategory;
  /** Article 51(1)/(2)/(3) : rattachement de cette TVA déductible à l'activité. */
  attribution?: "taxable" | "exempt" | "residual";
  /** Article 50(1) : un bien exclu revendu en aval comme livraison taxable. */
  resuppliedAsTaxableSupply?: boolean;
  /** Article 50(1)(b)/(c) : une obligation légale saoudienne lève l'exclusion. */
  statutoryObligation?: boolean;
  /** Date de paiement effectif du fournisseur. Absente = toujours impayé. */
  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,
};

La règle de classification qui fait le plus trébucher les systèmes est la case 8 contre la case 9. Les deux concernent des importations. Elles ne sont pas interchangeables :

  • La case 8 est la TVA à l'importation effectivement payée à la douane saoudienne à la frontière. Vous avez une déclaration en douane.
  • La case 9 est la TVA que vous liquidez vous-même : l'autoliquidation de l'article 47, et aussi la TVA d'importation différée de l'article 44 pour les assujettis autorisés à l'acquitter via la déclaration plutôt qu'en douane.

Un système qui se fonde sur « la contrepartie est-elle étrangère ? » enverra les deux au même endroit. Le discriminant est de savoir si la douane saoudienne a encaissé la taxe.

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";
    // Exonéré et taux zéro sont une propriété de la livraison, pas du taux facturé.
    // Les deux affichent 0. Seul le plan comptable sait lequel est lequel.
    return txn.exemptSupply ? "exempt_sale" : "zero_rated_domestic_sale";
  }
 
  if (!txn.counterpartyResident) {
    // Report de l'article 44 et autoliquidation de l'article 47 : tous deux en case 9.
    // Le discriminant est l'encaissement en douane, pas le pays de la contrepartie.
    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";
}

Étape 4 : la case 9 est une ligne unique nette, pas deux lignes

C'est celle qui surprend ceux qui ont implémenté l'autoliquidation dans l'Union européenne ou au Royaume-Uni, où l'on passe une ligne de TVA collectée et une ligne de TVA déductible qui s'annulent.

Le guide Importations et exportations de ZATCA est explicite : la TVA autoliquidée est déclarée au champ 9, et le formulaire traite automatiquement la TVA déductible comme entièrement récupérable. Les assujettis qui n'ont pas droit à la déduction intégrale doivent effectuer les ajustements nécessaires au champ 9.

La case 9 ne porte donc aucune ligne de TVA collectée distincte. Si vous êtes intégralement assujetti, la TVA nette de cette ligne est nulle et vous ne saisissez aucun ajustement. Si vous êtes partiellement exonéré, la colonne Ajustement est l'endroit où vous reprenez la part non récupérable — et si vous la laissez vide, vous avez sous-déclaré.

L'exemple chiffré de ZATCA, une banque à 70 % de récupération recevant 100 000 SAR de services juridiques étrangers : montant 100 000, ajustement 30 000. (L'exemple publié utilise l'ancien taux de 5 %, d'où une TVA de 1 500 ; à 15 %, la même structure donne 10 500.)

// src/box9.ts
import { Halalas } from "./types";
 
/**
 * Autoliquidation de l'article 47, telle que la déclaration la modélise réellement.
 * Le formulaire déduit intégralement d'office : la SEULE chose qui rend correcte
 * la déclaration d'un assujetti partiel est l'ajustement.
 */
export function box9(
  reverseChargeBase: Halalas,
  recoveryRate: number, // 0..1, issu du prorata de l'article 51
): { 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),
  };
}

Une autre règle de la case 9 coûte de l'argent dans l'autre sens : la réception d'un service exonéré auprès d'un non-résident — un prêt étranger, par exemple — ne se déclare pas du tout. Les systèmes qui autoliquident toute facture étrangère gonflent les deux côtés de la déclaration sans raison et attirent les questions.

Étape 5 : TVA non déductible — l'article 50 a changé en 2024

Si vous avez codé cette liste depuis le PDF anglais de la 8e édition, ou depuis un billet de blog, elle est périmée. La résolution 01-06-24 a modifié l'article 50, avec effet au 18 avril 2025. La liste actuelle de la TVA non déductible, sauf revente en aval comme livraison taxable :

  • (a) toute forme de services de divertissement, sportifs ou culturels, ou la participation à des événements de nature récréative
  • (b) l'hospitalité et la restauration — sauf si une loi en vigueur dans le Royaume oblige l'assujetti à les fournir aux salariés sur le lieu de travail
  • (c) les services d'assurance ou de santé fournis aux salariés et à leurs ayants droit, sauf obligation légale — cette branche est nouvelle
  • (d) l'achat ou la location de véhicules restreints
  • (e) l'assurance des véhicules restreints, ou leur réparation, modification ou entretien
  • (f) le carburant utilisé dans les véhicules restreints
  • (g) tout bien ou service acquis pour un usage personnel ou à des fins étrangères à l'activité

L'article 50(2) a également redéfini le « véhicule restreint ». Il s'agit désormais de tout véhicule conçu pour transporter au plus dix personnes, à l'exclusion : des camions, grues et engins lourds similaires utilisés exclusivement pour l'activité et non disponibles pour un usage privé ; des véhicules acquis ou loués pour être revendus ou reloués comme livraison taxable ; des véhicules immatriculés comme véhicules d'urgence ; et des véhicules utilisés exclusivement pour l'activité et non disponibles pour un usage privé.

L'ancien critère était « tout véhicule conçu pour circuler sur route ». Une règle fondée sur le nombre de places est un problème de classification matériellement différent : c'est une propriété du modèle de véhicule, pas du compte de charges, et sa place est donc dans votre registre d'actifs ou de flotte, pas dans une table de correspondance comptable.

// 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) : les biens exclus redeviennent déductibles s'ils sont revendus taxés. */
  resuppliedAsTaxableSupply: boolean;
  /** Article 50(1)(b) et (c) : une obligation légale saoudienne lève l'exclusion. */
  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) : fourniture obligatoire sur le lieu de travail
  "employee_insurance_healthcare",  // 50(1)(c) : ajouté par la résolution 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) modifié : nombre de places, plus quatre exclusions.
 * Ce sont des données du registre de véhicules, pas du plan comptable.
 */
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;
}

La TVA non déductible va dans la colonne Ajustement de la case 7 — la base, pas la TVA. C'est exactement la forme de l'exemple 20 000 / 7 500 de ZATCA.

Étape 6 : déduction proportionnelle — article 51, et la régularisation annuelle

Si l'activité réalise à la fois des livraisons taxables et exonérées, l'essentiel de la TVA d'amont n'est pas intégralement récupérable. L'article 51 fixe la méthode :

  • 51(1) La TVA affectée exclusivement et directement aux livraisons taxables est intégralement déductible.
  • 51(2) Celle affectée exclusivement aux livraisons exonérées ne l'est pas du tout.
  • 51(3)–(4) Tout le reste utilise une fraction : les livraisons taxables de la dernière année civile sur les livraisons taxables plus exonérées de la dernière année civile. Les livraisons réalisées hors du Royaume comptent selon le traitement qui aurait été le leur si elles y avaient été réalisées.
  • 51(5) La fraction exclut les livraisons d'actifs immobilisés et celles effectuées depuis un établissement de l'assujetti hors du Royaume.
  • 51(6) Un assujetti non enregistré l'année précédente utilise des valeurs estimées de l'année en cours.
  • 51(7) En fin d'année civile, comparez les valeurs utilisées aux valeurs réelles et régularisez la TVA déductible dans la dernière déclaration de cette année civile.

Il n'existe aucune règle d'arrondi du prorata. N'importez pas « arrondir au point de pourcentage supérieur » de la pratique britannique ou européenne — cela n'existe pas ici, et arrondir 82,3 % à 83 % surévalue votre déduction.

// src/apportionment.ts
 
export interface AnnualSupplyValues {
  taxableSupplies: number;   // hors actifs immobilisés et établissements étrangers (51(5))
  exemptSupplies: number;    // idem
}
 
/**
 * Article 51(3)-(5). Renvoie un ratio non arrondi dans [0, 1].
 * Il n'existe AUCUN arrondi légal de ce pourcentage en Arabie saoudite.
 */
export function recoveryRate(prior: AnnualSupplyValues): number {
  const denominator = prior.taxableSupplies + prior.exemptSupplies;
  if (denominator === 0) return 1;
  return prior.taxableSupplies / denominator;
}
 
/**
 * Article 51(7) : régularisation annuelle obligatoire dans la DERNIÈRE déclaration
 * de l'année civile — décembre en mensuel, T4 en trimestriel.
 * Résultat positif = déduction complémentaire. Négatif = reprise.
 */
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;
}

Deux voisins méritent d'être câblés en même temps. L'article 52 impose des régularisations sur actifs immobilisés sur une période de six ans pour les biens meubles corporels et incorporels et dix ans pour les immeubles — ce qui exige un registre d'actifs portant le taux de récupération appliqué à l'acquisition, pas seulement un module d'immobilisations. Et l'article 51(8)–(9) autorisent une méthode alternative sur demande si elle reflète mieux l'usage réel, pour une durée fixée par ZATCA, au maximum cinq ans, après quoi il faut redéposer une demande.

Étape 7 : le seuil de correction est de 15 000 SAR

Voici le chiffre que la plupart des outils comptables saoudiens ont encore faux.

L'article 63(3) a été modifié par la décision du conseil 01-04-23 du 26/11/1444H, avec effet au 23 juin 2023. Le seuil en dessous duquel une minoration peut être portée sur la déclaration suivante, plutôt que de déclencher une rectification de la déclaration d'origine, est passé de 5 000 SAR à 15 000 SAR.

Article 63 en vigueur :

  • 63(1) Une minoration de la taxe nette doit être notifiée à ZATCA dans les 20 jours suivant la prise de connaissance, par rectification de la déclaration déposée — sauf application du 63(3).
  • 63(2) Une majoration peut être déduite de la taxe nette due sur toute déclaration ultérieure à la découverte, sous réserve du 63(4).
  • 63(3) L'exception : une minoration dont la valeur nette est inférieure à 15 000 SAR est ajoutée à la taxe nette due dans la déclaration de la période où l'erreur a été découverte. C'est la case 14.
  • 63(4) Aucune correction d'une majoration au-delà de cinq ans après la fin de l'année civile contenant la période fiscale.
  • 63(5) Toute correction doit indiquer la ou les périodes, la TVA collectée et déductible corrigée par période, et le motif.

Le seuil est strict : inférieur à 15 000, et non 15 000 ou moins.

Pourquoi cela compte plus qu'un seuil ordinaire : l'article 42(1) de la loi TVA prévoit une pénalité de 50 % de la différence pour le dépôt d'une déclaration inexacte, la production d'un document conduisant à liquider la taxe pour un montant inférieur au dû — et, dans ses termes mêmes, pour la rectification d'une déclaration après dépôt. Corriger dans la case 14 au titre du 63(3) évite entièrement la rectification. Coder le seuil à 5 000 pousse une correction parfaitement légitime de 12 000 sur la voie de la rectification, et dans le champ d'un article de pénalité qu'elle n'avait aucun besoin d'effleurer.

// src/corrections.ts
 
/** Article 63(3), modifié le 23 juin 2023. PAS 5 000. Strictement inférieur. */
export const CORRECTION_THRESHOLD = 15_000_00; // halalas
 
export interface PriorPeriodError {
  id: string;
  periodEnd: string;
  /** Positif = taxe minorée. Négatif = majorée. En 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 ans après la fin de l'année civile de la période.
      const deadline = Date.UTC(new Date(e.periodEnd).getUTCFullYear() + 6, 0, 1);
      (now.getTime() >= deadline ? timeBarred : box14).push(e);
      continue;
    }
    // Article 63(1) contre 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) : notifier sous 20 jours
    timeBarred,
  };
}

Rappelez-vous que la case 14 ne prend qu'un montant de TVA. Ni base ni ajustement sur cette ligne.

Étape 8 : les contrôles que personne n'automatise

Tout ce qui précède produit une déclaration qui tombe juste. Voici les règles qui la rendent correcte, et ce sont celles qu'un redressement trouve, parce qu'elles sont toutes visibles depuis des états dont le contrôleur dispose déjà.

Article 40(10) : la reprise sur fournisseur impayé. Si vous avez déduit la TVA et n'avez pas payé le fournisseur dans les douze mois suivant la livraison, vous devez réduire la déduction. L'article 40(11) la rétablit lorsque vous payez. Cela exige de croiser la balance âgée fournisseurs avec le registre de TVA. Presque aucun système standard ne le fait — et un contrôleur le trouve en quatre minutes depuis l'état des créanciers.

Article 32 : justificatifs d'exportation sous 90 jours. Le taux zéro tombe si vous ne détenez pas les justificatifs dans les quatre-vingt-dix jours de la livraison, et le justificatif est un jeu de trois pièces : documents douaniers d'exportation, documents commerciaux identifiant le client et le lieu de livraison, et documents de transport. « L'adresse du client est étrangère, donc taux zéro » est le redressement à l'export le plus courant qui soit.

Article 61 : conversion de devise au taux quotidien SAMA à la date d'exigibilité de la taxe. Pas la date de facture, pas un taux de fin de mois, pas le taux de la politique groupe. Les ERP utilisent leur propre table de taux par défaut et personne ne le remarque avant le rapprochement.

Articles 62(2)(c) et 15 : les livraisons à soi-même. Les livraisons réputées doivent être déclarées. Les cadeaux, échantillons et biens remis aux salariés ne sont dispensés qu'à hauteur de 200 SAR par bénéficiaire et par année civile, avec un plafond global de 50 000 SAR par année civile. Les cadeaux marketing et avantages du personnel comptabilisés directement en charges n'atteignent jamais le moteur de TVA.

Article 49(8) : la TVA peut être déduite tardivement, mais pas au-delà de cinq années civiles après l'année civile de la livraison. Le rattrapage de factures tardives exige un contrôle d'ancienneté.

Article 49(7)(a) : une facture simplifiée correctement émise constitue un justificatif alternatif recevable pour la déduction. Celle-ci est l'erreur inverse — un logiciel qui bloque la récupération en l'absence de facture complète sous-déduit. Si cette règle vous surprend, le détail est dans la règle de la facture simplifiée que tout le monde comprend de travers.

Article 54, nouveau en 2024 : les notes de crédit et de débit dans les quinze jours suivant la fin du mois au cours duquel l'événement générateur est survenu. Les systèmes qui groupent les avoirs à la clôture trimestrielle enfreignent désormais une échéance ferme.

Article 66 et conservation. Six ans en principe — mais le guide de ZATCA porte cela à onze ans pour les documents relatifs aux actifs immobilisés meubles et quinze pour les immeubles, la période de régularisation de l'article 52 courant d'abord et les cinq ans ensuite.

// 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) : TVA déduite, fournisseur impayé après 12 mois.
  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: "Règlement d'application, article 40(10)",
      message:
        `${unpaid.length} achat(s) avec TVA déduite restent impayés après 12 mois. ` +
        `La déduction doit être reprise, puis rétablie au paiement (article 40(11)).`,
      transactionIds: unpaid.map((t) => t.id),
    });
  }
 
  // Article 32 : les justificatifs d'exportation doivent être détenus sous 90 jours.
  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: "Règlement d'application, article 32",
      message:
        `${staleExports.length} exportation(s) sans jeu complet de justificatifs après 90 jours. ` +
        `Le taux zéro tombe : elles passent de la case 4 à la case 1 à 15 %.`,
      transactionIds: staleExports.map((t) => t.id),
    });
  }
 
  // Article 49(8) : TVA d'amont de plus de cinq années civiles.
  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: "Règlement d'application, article 49(8)",
      message: `${stale.length} achat(s) hors de la fenêtre de déduction de cinq ans.`,
      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);
}

Étape 9 : assembler la déclaration

// 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;
    }
 
    // Achats : déterminer la part de la BASE non déductible.
    // La colonne d'ajustement est soustractive et se saisit en positif.
    let nonDeductibleBase = 0;
 
    const blocked = isInputTaxBlocked(txn.expenseCategory ?? "business", {
      resuppliedAsTaxableSupply: txn.resuppliedAsTaxableSupply ?? false,
      statutoryObligation: txn.statutoryObligation ?? false,
    });
 
    if (blocked) {
      // Article 50 : rien n'est déductible.
      nonDeductibleBase = txn.netAmount;
    } else if (txn.attribution === "exempt") {
      // Article 51(2).
      nonDeductibleBase = txn.netAmount;
    } else if (txn.attribution === "residual") {
      // Article 51(3)-(4). Les actifs immobilisés sont exclus de la FRACTION
      // (51(5)) mais leur TVA d'amont reste soumise au prorata.
      nonDeductibleBase = Math.round(txn.netAmount * (1 - rate));
    }
    // attribution === "taxable" -> article 51(1), déduction intégrale, sans ajustement.
 
    boxes[box].adjustment += nonDeductibleBase;
    boxes[box].vat += Math.round((txn.netAmount - nonDeductibleBase) * txn.vatRate);
  }
 
  // Article 51(7) : la régularisation annuelle appartient à la dernière déclaration.
  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: "Règlement d'application, article 51(7)",
        message:
          `Régularisation annuelle de prorata de ${(trueUp / 100).toFixed(2)} SAR due ` +
          `dans cette déclaration. Taux utilisé pendant l'année : ` +
          `${(rate * 100).toFixed(4)} %; réel : ` +
          `${(recoveryRate(input.actualYearSupplies) * 100).toFixed(4)} %.`,
      });
    }
  }
 
  // Lignes calculées.
  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 : la case 14 ne porte qu'un montant de TVA.
  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: "Règlement d'application, articles 63(1) et 63(3)",
      message:
        `L'erreur ${e.id} minore la taxe de ${(e.netVatEffect / 100).toFixed(2)} SAR, ` +
        `soit au seuil de 15 000 SAR ou au-dessus. Elle ne peut aller en case 14. ` +
        `Rectifiez la déclaration d'origine dans les 20 jours de la découverte.`,
      transactionIds: [e.id],
    });
  }
 
  // Article 69(6) : le principe est le report. Le remboursement n'a lieu que sur demande.
  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(),
  );
}

Étape 10 : crédit, remboursement et transmission

L'article 69 régit ce qui se passe lorsque la case 16 ressort négative. Trois points à coder :

  • 69(6) Le principe est le report. Un remboursement n'a lieu que si vous le demandez. Un système qui annonce automatiquement « remboursement dû » décrit une issue que personne n'a sollicitée.
  • 69(2) La demande peut être faite au dépôt de la déclaration, ou à tout autre moment dans les cinq ans suivant la fin de l'année civile à laquelle se rapportent les circonstances.
  • 69(3) ZATCA peut rejeter la demande si des déclarations sont manquantes — si votre moteur a signalé une période absente, le remboursement n'ira nulle part.
  • 69(4) Après accord, ZATCA doit conclure et initier le paiement sous soixante jours, par virement bancaire.
  • 69(5) ZATCA peut compenser le crédit avec d'autres montants dus. L'amendement de 2024 a étendu cela à tout montant dû au titre de n'importe quelle réglementation qu'elle applique — y compris les amendes douanières.

Vient ensuite la partie qui n'est pas du code. La sortie de computeVatReturn(), ce sont seize chiffres qu'un humain saisit dans le portail, plus une piste qui justifie chacun d'eux.

Faites travailler le livrable : une page résumant les seize cases dans l'ordre du portail, et derrière, le détail des écritures par case. Quand ZATCA demandera dans dix-huit mois d'où venait l'ajustement de la case 7, la réponse sera un fichier, pas un chantier d'archéologie.

Tester votre implémentation

Testez les règles, pas les totaux. Des totaux qui passent vous disent que l'arithmétique fonctionne ; ils ne vous disent rien sur le fait que les cases 8 et 9 soient dans le bon sens.

// 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("autoliquidation de l'article 47, case 9", () => {
  it("se solde à zéro pour une activité intégralement taxable", () => {
    const r = box9(100_000_00, 1);
    expect(r.adjustment).toBe(0);
    expect(r.vat).toBe(15_000_00);
  });
 
  it("reprend la part non récupérable dans la colonne d'ajustement", () => {
    // Structure de l'exemple ZATCA : banque à 70 %, 100 000 SAR de 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("seuil de correction de l'article 63(3)", () => {
  it("vaut 15 000 et non 5 000", () => {
    expect(CORRECTION_THRESHOLD).toBe(15_000_00);
  });
 
  it("route 14 999,99 vers la case 14 et 15 000,00 vers une rectification", () => {
    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("case 8 contre case 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("route les importations dédouanées vers la case 8", () => {
    expect(classify({ ...base, customsDeclarationNumber: "SA-123" }))
      .toBe("import_vat_at_customs");
  });
 
  it("route les services sans déclaration en douane vers la case 9", () => {
    expect(classify(base)).toBe("import_reverse_charge");
  });
});
 
describe("article 74(1) : aucun report de week-end", () => {
  it("conserve le 31 janvier même s'il tombe un vendredi", () => {
    const deadline = filingDeadline(new Date("2026-12-31T00:00:00Z"));
    expect(deadline.toISOString().slice(0, 10)).toBe("2027-01-31");
  });
});

Puis confrontez au réel. Passez le moteur sur une période déjà déposée et comparez les seize cases à ce qui a effectivement été soumis. Tout écart est soit un bug, soit une erreur de dépôt historique — et les deux méritent d'être connus tant que l'initiative d'annulation des amendes reste ouverte.

Dépannage

Votre colonne de TVA ne colle pas à celle du portail. Attendu, et généralement sans gravité. Les montants sont saisis hors TVA et le portail calcule lui-même la TVA ; l'arrondi ligne à ligne divergera de quelques halalas. Rapprochez l'écart plutôt que de le forcer. Il n'existe aucune règle légale d'arrondi pour la déclaration — la seule indication est de niveau guide : les montants de TVA s'arrondissent au halala le plus proche, et le même montant arrondi sert sur la déclaration.

La case 6 ou 12 ne tombe pas juste avec la balance. En général des opérations hors champ qui se sont glissées, ou l'inverse : une livraison à soi-même de l'article 15 qui n'a jamais atteint le moteur de TVA parce qu'elle a été comptabilisée directement en charges marketing.

Le numéro de TVA d'un fournisseur se révèle être un numéro de registre de commerce. Les deux sont de longues chaînes numériques et les champs s'inversent constamment. Vérifiez avant la déclaration, pas après — vérifier un numéro de TVA saoudien couvre les quatre méthodes et ce que chacune prouve réellement.

Vous avez trouvé des erreurs sur une période déposée. Faites-les passer par l'article 63 avant toute chose : moins de 15 000 va en case 14 de la déclaration en cours, à partir de 15 000 il faut une rectification sous 20 jours. Et notez que l'initiative d'annulation des amendes court jusqu'au 31 décembre 2026 et couvre les pénalités de correction de déclaration — mais sa date de coupure est gelée, donc attendre n'aide pas. Le détail est dans ce qui tombe vraiment avant le 31 décembre 2026.

Vous cherchez une API de dépôt. Il n'y en a pas. La seule famille d'API publiques de ZATCA est Fatoora : CSID de conformité, contrôles de factures de conformité, émission et renouvellement du CSID de production, clearance et reporting — des certificats et une facture à la fois. Aucun point de terminaison de dépôt de déclaration, aucun schéma XML de la déclaration, aucun import en masse. L'Arabie saoudite n'a pas non plus de modèle de prestataire accrédité pour le dépôt ; le répertoire des fournisseurs de solutions est décrit par ZATCA elle-même comme une liste indicative non contraignante, et son périmètre se limite à la facturation électronique. Tout éditeur sérieux dit « génère », jamais « dépose » — et les éditeurs qui offrent un dépôt direct vers EmaraTax aux Émirats n'ont aucun équivalent chez ZATCA.

Pour aller plus loin

Conclusion

Les seize cases sont la partie facile. Ce qui sépare une déclaration qui survit à un contrôle d'une déclaration qui n'y survit pas, c'est la couche du dessous : l'ajustement de la case 9 reflète-t-il votre taux de récupération réel, la TVA sur une facture impayée depuis deux ans a-t-elle été reprise au titre de l'article 40(10), l'exportation que vous avez détaxée dispose-t-elle de ses trois justificatifs, la régularisation annuelle a-t-elle atterri dans la déclaration de décembre, et votre seuil de correction dit-il 15 000 ou encore 5 000.

Rien de tout cela n'est visible depuis le portail. Tout est visible depuis votre grand livre — c'est précisément pourquoi le moteur appartient à votre côté du mur, et pourquoi le caractère manuel du dépôt vous coûte bien moins cher que la plupart ne le supposent.

Si vous portez cette logique dans des tableurs, ou dans un ERP dont vous n'avez jamais réellement audité la localisation saoudienne, cela mérite un examen avant la clôture de la prochaine période plutôt qu'après. Nous faisons ce type de travail en revue à périmètre fixe : apportez une période déposée et le grand livre correspondant, nous rapprochons les deux et vous disons lesquelles de ces règles votre configuration actuelle traite mal. Commencez par nous contacter — une période déposée et un plan comptable suffisent.