Une chaîne de distribution saoudienne de quarante succursales détient au minimum quarante licences d'activité commerciale (رخصة نشاط تجاري) délivrées par la plateforme Balady, quarante permis de sécurité civile, et par-dessus cela une série de licences d'enseigne, d'entrepôt et de chariot mobile. Toutes expirent. La plupart expirent à une date calculée dans le calendrier hégirien Umm al-Qura, ce qui signifie que la date de renouvellement avance d'environ onze jours à chaque année grégorienne.
Les exploitants suivent cela dans un tableur. Le tableur stocke des dates grégoriennes, quelqu'un ajoute 365 à la date de l'an dernier, et onze jours plus tard que prévu une succursale exerce sous licence expirée. La municipalité sanctionne chaque infraction et peut faire fermer le local.
Ce tutoriel construit le système qui aurait dû remplacer ce tableur : un suivi de conformité qui modélise correctement les licences Balady, calcule l'expiration dans le calendrier où la licence a réellement été émise, fait remonter les renouvellements avant qu'ils ne deviennent des amendes, et réconcilie ce que vous croyez avec ce que dit la plateforme.
Prérequis
Avant de commencer, assurez-vous de disposer de :
- Node.js 20+ (le tutoriel repose sur l'ICU complet, livré par défaut depuis Node 14)
- TypeScript 5.x et une pratique courante du langage
- Une familiarité avec Zod ou une bibliothèque de validation équivalente
- Une base PostgreSQL, ou tout autre stockage — le schéma se transpose sans difficulté
- Des notions de cron ou d'un ordonnanceur de tâches
Vous n'avez pas besoin d'identifiants de la plateforme Balady pour suivre ce tutoriel. C'est précisément l'objet de l'étape 1.
Ce que vous allez construire
Un service en quatre parties :
- Un modèle de domaine couvrant les types de licences réellement délivrés par Balady, avec des dates bi-calendaires.
- Un moteur de calendrier Umm al-Qura qui convertit dans les deux sens et ajoute correctement les années hégiriennes.
- Une machine à états d'escalade qui transforme les « jours restants » en travail attribué et actionnable.
- Une boucle de réconciliation qui détecte l'écart entre votre registre et la réalité.
À la fin, vous disposerez d'une bibliothèque testée, intégrable dans un back-office existant.
Étape 1 : comprendre avec quoi vous intégrez (et avec quoi vous n'intégrez pas)
C'est l'étape qui vous fera gagner un mois.
La plateforme Balady (balady.gov.sa) est le portail de services numériques du ministère des Municipalités et du Logement. Elle délivre, renouvelle et annule les licences municipales dans chaque أمانة et chaque بلدية du Royaume. Les services qui vous concernent :
| Service | Arabe | Périmètre |
|---|---|---|
| Licence d'activité commerciale | رخصة نشاط تجاري | Le permis d'exercer à une adresse donnée |
| Permis de construire | رخصة بناء | Construction, démolition, restauration |
| Permis de sécurité | تصريح السلامة | Délivré par la Défense civile, couplé à la licence commerciale |
| Licence d'enseigne | رخصة لوحة | Enseigne extérieure du commerce |
| Licence de chariot mobile | رخصة عربة متنقلة | Camions-restaurants et vendeurs ambulants |
| Suivi « Rukhsati » | رخصي | Vue en lecture seule de l'état des licences |
Voici ce que personne n'écrit noir sur blanc : Balady ne publie aucune API publique pour les développeurs. Pas d'enregistrement d'application OAuth, pas de bac à sable, pas de domaine api.balady.gov.sa avec point de terminaison de jeton et quotas d'appels. La plateforme authentifie les personnes via le portail national d'accès unifié (النفاذ الوطني الموحد, à travers Absher) et les établissements via business.balady.sa.
Restent trois voies d'intégration honnêtes :
- Saisie manuelle structurée et ingestion documentaire. Un utilisateur des opérations saisit ou téléverse chaque licence une fois ; vous analysez le PDF et normalisez les données. C'est ce que fait la quasi-totalité des exploitants, et c'est la voie retenue ici.
- Accès délégué via un bureau de services agréé. Beaucoup d'exploitants rémunèrent déjà un مكتب خدمات عامة pour déposer les renouvellements ; certains exportent un registre des licences sur demande.
- Intégration gouvernementale par les canaux propres de l'établissement. Les grandes entreprises sous convention formelle peuvent parfois obtenir des flux de données via le ministère, selon le même mécanisme d'intermédiation que pour la GOSI et Muqeem. Les identifiants appartiennent à l'établissement, pas à vous en tant que prestataire.
Ne développez pas contre un point de terminaison non documenté repéré dans les outils de développement du navigateur. Il n'offre aucune garantie de stabilité, il est lié à une session humaine, et extraire une session du portail national d'accès unifié constitue en soi un problème de conformité. Construisez plutôt le registre, et rendez l'étape humaine peu coûteuse et auditable.
C'est la même contrainte que pour Muqeem et, dans une moindre mesure, la GOSI. Si vous avez déjà intégré ces plateformes, la forme vous sera familière — voyez notre tutoriel sur le moteur de cotisations GOSI pour le motif de réconciliation appliqué à un autre registre.
Étape 2 : modéliser le domaine
Commencez par les types. La décision la plus importante se joue ici : stockez les deux calendriers, et stockez lequel fait foi.
mkdir balady-tracker && cd balady-tracker
npm init -y
npm install zod
npm install -D typescript tsx vitest @types/node
npx tsc --initCréez src/domain.ts :
import { z } from 'zod';
/** Le calendrier dans lequel l'expiration est juridiquement exprimée. */
export type CalendarSystem = 'hijri' | 'gregorian';
export const LicenceKind = z.enum([
'commercial_activity', // رخصة نشاط تجاري
'building_permit', // رخصة بناء
'safety_permit', // تصريح السلامة
'signage', // رخصة لوحة
'mobile_cart', // رخصة عربة متنقلة
]);
export type LicenceKind = z.infer<typeof LicenceKind>;
/** Date hégirienne (Umm al-Qura) : une date civile, pas un horodatage. */
export const HijriDate = z.object({
year: z.number().int().min(1300).max(1600),
month: z.number().int().min(1).max(12),
day: z.number().int().min(1).max(30),
});
export type HijriDate = z.infer<typeof HijriDate>;
/**
* Registre de commerce saoudien : 10 chiffres.
* Le premier chiffre encode le groupe de villes émetteur.
*/
export const CommercialRegistration = z
.string()
.regex(/^[12347]\d{9}$/, 'Le RC compte 10 chiffres commençant par 1, 2, 3, 4 ou 7');
/** Les numéros de licence Balady sont des chaînes de 10 chiffres. */
export const LicenceNumber = z
.string()
.regex(/^\d{10}$/, 'Le numéro de licence Balady compte exactement 10 chiffres');
export const Licence = z.object({
id: z.string().uuid(),
kind: LicenceKind,
licenceNumber: LicenceNumber,
commercialRegistration: CommercialRegistration,
/** Libellé de la succursale et municipalité émettrice. */
branchName: z.string().min(1),
municipality: z.string().min(1), // ex. "أمانة منطقة الرياض"
/** Le calendrier dans lequel la date d'expiration est imprimée. */
authoritativeCalendar: z.enum(['hijri', 'gregorian']),
/** Toujours renseignés tous les deux. L'un dérive de l'autre. */
expiresOnHijri: HijriDate,
expiresOnGregorian: z.string().date(), // ISO YYYY-MM-DD
/** Dernière confirmation de la licence auprès de la plateforme. */
lastVerifiedAt: z.string().datetime().nullable(),
/** Qui relance le renouvellement. Une expiration sans responsable devient une amende. */
ownerEmail: z.string().email(),
});
export type Licence = z.infer<typeof Licence>;Deux détails méritent d'être défendus.
authoritativeCalendar n'est pas décoratif. Les licences d'activité commerciale sont généralement émises et renouvelées sur des dates hégiriennes ; certains permis, et la plupart des contrats que vous recouperez, sont grégoriens. Si vous normalisez tout en grégorien à l'ingestion en oubliant lequel faisait foi, vous ne pourrez pas recalculer correctement la fenêtre de renouvellement l'année suivante. Conservez-le.
Les deux champs de date sont toujours renseignés. Vous indexez et interrogez sur expiresOnGregorian, parce que c'est ce que comprennent votre base, votre cron et vos équipes. Vous calculez la prochaine expiration à partir de expiresOnHijri. N'en stocker qu'un seul et dériver l'autre à la lecture, c'est ainsi que le bug des onze jours revient par une refactorisation bien intentionnée.
Étape 3 : construire le moteur de calendrier Umm al-Qura
C'est le cœur technique du tutoriel, et la partie que la plupart des implémentations ratent.
L'Arabie Saoudite utilise le calendrier Umm al-Qura (التقويم الأم القرى), un calendrier hégirien tabulaire spécifique — ni le calendrier astronomique, ni les variantes arithmétiques employées ailleurs. La précision compte : l'approche « ajouter 354 jours » est fausse pour environ la moitié des années, car les années hégiriennes alternent entre 354 et 355 jours.
Node intègre cela via l'ICU. Créez src/hijri.ts :
const UMALQURA = 'en-u-ca-islamic-umalqura-nu-latn';
const RIYADH = 'Asia/Riyadh';
const DAY_MS = 86_400_000;
const partsFormatter = new Intl.DateTimeFormat(UMALQURA, {
year: 'numeric',
month: 'numeric',
day: 'numeric',
timeZone: RIYADH,
});
export interface HijriParts {
year: number;
month: number;
day: number;
}
/** Convertit un instant grégorien en date civile Umm al-Qura à Riyad. */
export function toHijri(date: Date): HijriParts {
const parts = Object.fromEntries(
partsFormatter
.formatToParts(date)
.filter((p) => p.type !== 'literal')
.map((p) => [p.type, p.value]),
);
return {
year: Number(parts.year),
month: Number(parts.month),
day: Number(parts.day),
};
}
function compareHijri(a: HijriParts, b: HijriParts): number {
return a.year - b.year || a.month - b.month || a.day - b.day;
}Le sens inverse est plus délicat. L'ICU donne le grégorien vers l'hégirien, pas la réciproque. Plutôt que d'embarquer une table de correspondance Umm al-Qura qui se périmera, effectuez une recherche dichotomique dans la conversion en laquelle nous avons déjà confiance :
/**
* Trouve la date grégorienne dont la date Umm al-Qura vaut exactement `target`.
* Renvoie null si la date n'existe pas (ex. jour 30 d'un mois de 29 jours).
*/
export function fromHijri(target: HijriParts): Date | null {
// Amorce : époque hégirienne (19/07/622) et longueur moyenne de l'année.
const seed = Date.UTC(622, 6, 19) + (target.year - 1) * 354.367 * DAY_MS;
let lo = seed - 60 * DAY_MS;
let hi = seed + 420 * DAY_MS;
while (hi - lo > DAY_MS) {
const mid = lo + Math.floor((hi - lo) / 2 / DAY_MS) * DAY_MS;
if (compareHijri(toHijri(new Date(mid)), target) < 0) {
lo = mid;
} else {
hi = mid;
}
}
const candidate = new Date(hi);
return compareHijri(toHijri(candidate), target) === 0 ? candidate : null;
}
/**
* Ajoute des années hégiriennes entières en préservant le quantième hégirien.
* Ramène le jour 30 à 29 lorsque le mois cible est court.
*/
export function addHijriYears(date: Date, years: number): Date {
const current = toHijri(date);
const wanted: HijriParts = {
year: current.year + years,
month: current.month,
day: current.day,
};
const exact = fromHijri(wanted);
if (exact) return exact;
// Le jour 30 n'existe pas dans tous les mois hégiriens — repli sur le 29.
const clamped = fromHijri({ ...wanted, day: 29 });
if (!clamped) {
throw new Error(
`Impossible de résoudre la date hégirienne ${wanted.year}-${wanted.month}-${wanted.day}`,
);
}
return clamped;
}
/** ISO YYYY-MM-DD, ce que vous stockez et indexez. */
export function toIsoDate(date: Date): string {
return date.toISOString().slice(0, 10);
}La recherche dichotomique tourne en neuf itérations environ d'un appel Intl peu coûteux. C'est assez rapide pour s'exécuter par licence et par nuit, et correct par construction : elle ne peut renvoyer qu'une date dont l'ICU elle-même convient qu'elle correspond à la cible.
Pourquoi cela compte concrètement
Exécutez le moteur sur une licence émise aujourd'hui :
const issued = new Date('2026-08-12T00:00:00Z');
console.log(toHijri(issued));
// { year: 1448, month: 2, day: 29 } → 29 Safar 1448 AH
console.log(toIsoDate(addHijriYears(issued, 1)));
// 2027-08-02 — et non 2027-08-12
console.log(toIsoDate(addHijriYears(issued, 5)));
// 2031-06-19 — et non 2031-08-12Une année hégirienne tombe dix jours avant l'anniversaire grégorien naïf. Cinq années hégiriennes tombent cinquante-quatre jours avant. Un tableur qui ajoute un an à la date grégorienne place une licence de cinq ans presque deux mois en situation d'expiration avant que quiconque ne s'en aperçoive.
Étape 4 : normaliser les licences à l'ingestion
Quelle que soit la source — un formulaire, un CSV d'un bureau de services, un PDF analysé — tout passe par un normalisateur unique qui complète le calendrier que vous n'avez pas fourni.
Créez src/ingest.ts :
import { z } from 'zod';
import { addHijriYears, fromHijri, toHijri, toIsoDate } from './hijri';
import { HijriDate, Licence, LicenceKind, LicenceNumber } from './domain';
/** Ce que fournit réellement un utilisateur des opérations ou un import. */
export const LicenceInput = z
.object({
kind: LicenceKind,
licenceNumber: LicenceNumber,
commercialRegistration: z.string(),
branchName: z.string().min(1),
municipality: z.string().min(1),
ownerEmail: z.string().email(),
expiresOnHijri: HijriDate.optional(),
expiresOnGregorian: z.string().date().optional(),
})
.refine(
(input) => input.expiresOnHijri || input.expiresOnGregorian,
"Fournissez au moins une date d'expiration",
);
export type LicenceInput = z.infer<typeof LicenceInput>;
export function normaliseLicence(
raw: unknown,
id: string,
): Omit<Licence, 'lastVerifiedAt'> & { lastVerifiedAt: null } {
const input = LicenceInput.parse(raw);
let hijri = input.expiresOnHijri;
let gregorian = input.expiresOnGregorian;
// Le calendrier fourni par l'utilisateur est celui qui fait foi.
const authoritativeCalendar = input.expiresOnHijri ? 'hijri' : 'gregorian';
if (hijri && !gregorian) {
const resolved = fromHijri(hijri);
if (!resolved) {
throw new Error(
`La date hégirienne ${hijri.year}-${hijri.month}-${hijri.day} n'existe pas dans Umm al-Qura`,
);
}
gregorian = toIsoDate(resolved);
}
if (gregorian && !hijri) {
hijri = toHijri(new Date(`${gregorian}T00:00:00Z`));
}
return {
id,
kind: input.kind,
licenceNumber: input.licenceNumber,
commercialRegistration: input.commercialRegistration,
branchName: input.branchName,
municipality: input.municipality,
ownerEmail: input.ownerEmail,
authoritativeCalendar,
expiresOnHijri: hijri!,
expiresOnGregorian: gregorian!,
lastVerifiedAt: null,
};
}
/** Projette le prochain renouvellement en respectant le calendrier faisant foi. */
export function nextExpiry(licence: Licence, terms = 1): string {
const current = new Date(`${licence.expiresOnGregorian}T00:00:00Z`);
if (licence.authoritativeCalendar === 'hijri') {
return toIsoDate(addHijriYears(current, terms));
}
const projected = new Date(current);
projected.setUTCFullYear(projected.getUTCFullYear() + terms);
return toIsoDate(projected);
}Notez que normaliseLicence rejette une date hégirienne inexistante au lieu de l'arrondir silencieusement. Une licence enregistrée comme expirant le 30 Dhou al-Qi'da dans une année où ce mois compte 29 jours est une erreur de transcription : vous voulez l'apprendre à l'import, pas onze mois plus tard.
Étape 5 : la machine à états d'escalade
Les « jours restants » sont une donnée. Un responsable, une gravité et une action suivante forment un système. Créez src/escalation.ts :
import type { Licence } from './domain';
export type Severity = 'none' | 'low' | 'medium' | 'high' | 'critical';
export interface Stage {
id: string;
severity: Severity;
/** Borne supérieure incluse, en jours restants. */
withinDays: number;
action: string;
}
/**
* Ordonné du plus étroit au plus large. Un renouvellement Balady demande
* réellement trois à quatre semaines lorsqu'une contre-visite de sécurité
* est requise : le stade 'urgent' commence donc bien avant l'échéance.
*/
export const STAGES: Stage[] = [
{ id: 'grace', severity: 'critical', withinDays: 7, action: "Escalader au responsable des opérations aujourd'hui" },
{ id: 'urgent', severity: 'high', withinDays: 30, action: 'Déposer le renouvellement et réserver la visite de sécurité' },
{ id: 'due', severity: 'medium', withinDays: 60, action: 'Vérifier la validité du bail et du registre de commerce' },
{ id: 'upcoming', severity: 'low', withinDays: 90, action: 'Ajouter au prochain lot de renouvellement' },
];
export interface Assessment {
licenceId: string;
branchName: string;
daysRemaining: number;
stage: string;
severity: Severity;
action: string;
ownerEmail: string;
}
const DAY_MS = 86_400_000;
export function daysRemaining(licence: Licence, now: Date): number {
const expiry = Date.parse(`${licence.expiresOnGregorian}T00:00:00Z`);
const today = Date.parse(`${now.toISOString().slice(0, 10)}T00:00:00Z`);
return Math.round((expiry - today) / DAY_MS);
}
export function assess(licence: Licence, now: Date): Assessment {
const remaining = daysRemaining(licence, now);
const base = {
licenceId: licence.id,
branchName: licence.branchName,
daysRemaining: remaining,
ownerEmail: licence.ownerEmail,
};
if (remaining < 0) {
return {
...base,
stage: 'expired',
severity: 'critical',
action: 'Exploitation sans licence valide — revue immédiate requise',
};
}
const stage = STAGES.find((s) => remaining <= s.withinDays);
return stage
? { ...base, stage: stage.id, severity: stage.severity, action: stage.action }
: { ...base, stage: 'ok', severity: 'none', action: 'Aucune action' };
}Le cas limite à traiter délibérément : remaining === 0 signifie que la licence expire aujourd'hui, ce qui relève de grace et de la criticité — pas de expired. L'erreur d'un rang ici fait la différence entre une alerte qui part et une alerte qui part un jour trop tard.
Étape 6 : réconcilier avec la réalité
Votre registre est une croyance, et les croyances se périment : un directeur de succursale renouvelle via un bureau de services sans prévenir personne, ou une licence est annulée à la fermeture d'un point de vente. Sans réconciliation, votre outil affiche sereinement au vert une licence qui n'existe plus.
Puisqu'aucune API n'est interrogeable, réconcilier consiste ici à faire de la péremption un signal de premier ordre et à rendre la vérification humaine peu coûteuse.
Créez src/reconcile.ts :
import type { Licence } from './domain';
import { assess, type Assessment } from './escalation';
const DAY_MS = 86_400_000;
export interface ReconciliationFlag {
licenceId: string;
branchName: string;
reason: 'never_verified' | 'stale_verification' | 'expired_unverified';
detail: string;
}
/**
* Demi-vie de la vérification. Une licence contrôlée il y a 120 jours n'est
* plus une preuve mais un souvenir. À resserrer pour les sites à risque.
*/
const STALE_AFTER_DAYS = 120;
export function reconcile(licences: Licence[], now: Date): {
assessments: Assessment[];
flags: ReconciliationFlag[];
} {
const assessments = licences.map((l) => assess(l, now));
const flags: ReconciliationFlag[] = [];
for (const licence of licences) {
const verdict = assessments.find((a) => a.licenceId === licence.id)!;
if (!licence.lastVerifiedAt) {
flags.push({
licenceId: licence.id,
branchName: licence.branchName,
reason: 'never_verified',
detail: 'Importée mais jamais confirmée auprès de la plateforme Balady',
});
continue;
}
const ageDays = Math.floor(
(now.getTime() - Date.parse(licence.lastVerifiedAt)) / DAY_MS,
);
if (verdict.stage === 'expired') {
flags.push({
licenceId: licence.id,
branchName: licence.branchName,
reason: 'expired_unverified',
detail: `Enregistrée comme expirée depuis ${Math.abs(verdict.daysRemaining)} jours — vérifier qu'elle n'a pas été renouvelée hors système`,
});
} else if (ageDays > STALE_AFTER_DAYS) {
flags.push({
licenceId: licence.id,
branchName: licence.branchName,
reason: 'stale_verification',
detail: `Dernière vérification il y a ${ageDays} jours`,
});
}
}
return { assessments, flags };
}Le drapeau expired_unverified est celui qui justifie son existence. Une licence expirée dans votre registre correspond bien plus souvent à un renouvellement fait sans vous qu'à une infraction réelle — et traiter chacune comme une alarme incendie apprend aux équipes à ignorer l'alarme.
Associez-y un bouton « vérifier » en back-office qui renvoie l'utilisateur directement vers le service de consultation Balady pour ce numéro de licence, puis horodate lastVerifiedAt à la confirmation. C'est là toute l'intégration : faute de pouvoir automatiser la lecture, vous ramenez la lecture manuelle à quinze secondes et vous enregistrez qu'elle a eu lieu.
Étape 7 : brancher la tâche nocturne
// src/job.ts
import { reconcile } from './reconcile';
import type { Licence } from './domain';
interface Notifier {
send(to: string, subject: string, body: string): Promise<void>;
}
export async function runDailyComplianceJob(
licences: Licence[],
notify: Notifier,
now = new Date(),
): Promise<void> {
const { assessments, flags } = reconcile(licences, now);
const actionable = assessments.filter((a) => a.severity !== 'none');
// Regrouper par responsable pour éviter quarante e-mails distincts.
const byOwner = new Map<string, typeof actionable>();
for (const item of actionable) {
const bucket = byOwner.get(item.ownerEmail) ?? [];
bucket.push(item);
byOwner.set(item.ownerEmail, bucket);
}
for (const [owner, items] of byOwner) {
const critical = items.filter((i) => i.severity === 'critical').length;
const subject = critical
? `[URGENT] ${critical} licence(s) Balady à traiter aujourd'hui`
: `${items.length} licence(s) Balady approchent du renouvellement`;
const body = items
.sort((a, b) => a.daysRemaining - b.daysRemaining)
.map((i) => `${i.branchName} : ${i.daysRemaining} j — ${i.action}`)
.join('\n');
await notify.send(owner, subject, body);
}
if (flags.length > 0) {
console.warn(`[balady] ${flags.length} signalement(s) de réconciliation`);
}
}Planifiez-la une fois par jour, tôt, en Asia/Riyadh. Ne la lancez pas toutes les heures : le travail de renouvellement se mesure en jours, et un courriel horaire est le meilleur moyen de faire filtrer vos alertes vers un dossier que personne n'ouvre.
Tester votre implémentation
Le moteur de calendrier mérite de vrais tests, car c'est la partie où une erreur subtile reste invisible pendant un an. Créez src/hijri.test.ts :
import { describe, expect, it } from 'vitest';
import { addHijriYears, fromHijri, toHijri, toIsoDate } from './hijri';
describe('Moteur Umm al-Qura', () => {
it('convertit une date connue', () => {
expect(toHijri(new Date('2026-08-12T00:00:00Z'))).toEqual({
year: 1448,
month: 2,
day: 29,
});
});
it('fait un aller-retour chaque semaine sur onze ans', () => {
const start = Date.UTC(2018, 0, 1);
for (let i = 0; i < 4000; i += 7) {
const gregorian = new Date(start + i * 86_400_000);
const hijri = toHijri(gregorian);
const back = fromHijri(hijri);
expect(back, `pas d'inverse pour ${JSON.stringify(hijri)}`).not.toBeNull();
expect(toHijri(back!)).toEqual(hijri);
}
});
it("décale de dix jours par rapport à l'anniversaire grégorien", () => {
const issued = new Date('2026-08-12T00:00:00Z');
expect(toIsoDate(addHijriYears(issued, 1))).toBe('2027-08-02');
});
it('accumule le décalage sur cinq ans', () => {
const issued = new Date('2026-08-12T00:00:00Z');
expect(toIsoDate(addHijriYears(issued, 5))).toBe('2031-06-19');
});
it("renvoie null pour un jour qui n'existe pas", () => {
// Balayer une année pour trouver les mois courts sans inventer de date.
for (let month = 1; month <= 12; month++) {
const resolved = fromHijri({ year: 1448, month, day: 30 });
if (resolved) expect(toHijri(resolved).day).toBe(30);
}
});
});Lancez npx vitest run. Le test d'aller-retour est le plus important : il confronte la recherche dichotomique à l'ICU sur 572 dates, ce qui constitue une preuve bien plus solide qu'une poignée de cas choisis à la main.
Pour la machine d'escalade, testez explicitement les bornes — jour 0, 7, 8, 30, 31, et une valeur négative — car chacune est un endroit où une inégalité peut être écrite à l'envers.
Dépannage
toHijri renvoie une mauvaise année sur une petite installation Node. Vous êtes sur une compilation small-icu sans données de locales complètes. Vérifiez avec node -p "process.config.variables.icu_small", puis installez full-icu ou passez à une distribution Node officielle.
Les dates se décalent d'un jour selon l'heure d'exécution. Vous comparez un horodatage à une date civile. Normalisez toujours les deux côtés à minuit UTC sur la chaîne ISO, comme le fait daysRemaining ci-dessus, et fixez le timeZone du formateur à Asia/Riyadh pour qu'une tâche lancée à 23:00 UTC ne lise pas la date hégirienne de la veille.
fromHijri renvoie null pour une date figurant sur une licence réelle. Deux causes probables : la licence utilise une variante hégirienne autre qu'Umm al-Qura (rare sur les documents officiels, courant sur les documents manuscrits), ou la date a été mal transcrite. Remontez le cas à l'utilisateur plutôt que de l'arrondir en silence.
Les numéros de licence échouent à la validation. Les formats varient selon les أمانات et les anciens permis précèdent la numérotation actuelle. Élargissez l'expression régulière à une plage de longueurs et journalisez les rejets pour revue plutôt que de bloquer l'import : un validateur strict qui empêche d'enregistrer une licence réelle est pire qu'un validateur permissif.
Pour aller plus loin
- Recoupez le registre de commerce de chaque licence via l'API Wathq, afin qu'un RC annulé invalide automatiquement toutes les licences de la succursale — voir la vérification d'entreprise via Maroof et Wathq.
- Étendez la même machine d'escalade aux obligations Qiwa et Nitaqat, qui suivent un motif presque identique : l'intégration Qiwa aux systèmes RH.
- Injectez les sorties du suivi dans la couche de reporting au-dessus de votre ERP, pour que le risque de licence apparaisse à côté du chiffre d'affaires par succursale plutôt que dans un outil séparé.
- Si vous exploitez déjà la facturation électronique ZATCA phase 2, réutilisez son ordonnanceur d'expiration de certificats : la forme est la même.
Conclusion
Le point difficile de la conformité Balady n'a jamais été l'API, puisqu'il n'y en a pas. C'est que l'échéance vit dans un calendrier que votre stack n'utilise pas, répartie sur des succursales que personne ne pilote, dans un registre que personne ne vérifie.
Vous avez construit les trois pièces qui corrigent cela : un moteur Umm al-Qura correct par construction et prouvé par des tests d'aller-retour, une machine d'escalade qui attribue chaque expiration imminente à une personne avec une action, et une boucle de réconciliation qui traite votre propre registre comme une affirmation plutôt que comme un fait.
Cette combinaison vaut mieux qu'une API. Une API vous donnerait la date d'expiration ; elle ne vous dirait pas qui va renouveler.
Construire la couche de reporting et de conformité au-dessus des plateformes gouvernementales saoudiennes est notre travail le plus fréquent. Si vous assemblez les échéances Balady, ZATCA, Qiwa et GOSI dans une vue unique et souhaitez un second avis sur l'architecture avant de vous engager, dites-nous ce que vous intégrez et nous en discuterons avec vous.