الكتابات/tutorial/2026/08
Tutorial16 أغسطس 2026·30 دقيقة

بناء فواتير PINT AE الإماراتية والتحقق منها باستخدام TypeScript

كل المقالات تشرح ما هو معيار PINT AE، ولا واحدة منها تعرض الشيفرة. هذا الدرس يبني الجزء الذي لن يبنيه مزوّد الخدمة المعتمد نيابة عنك: تحويل مُحكَم الأنواع من بيانات طلباتك إلى UBL 2.1، ومجاميع ضريبية تتطابق حتى الفلس الواحد، وتحقق Schematron محلي يفشل في CI بدل أن يفشل في الإنتاج، وآلة حالات لساق الاستجابة تصمد أمام رفضٍ يصل بعد ثلاثة أيام من الإرسال.

هناك لحظة محددة تقرّر ما إذا كان مشروع الفوترة الإلكترونية في الإمارات سيسير على ما يرام أم لا، وتحدث قبل موعد التشغيل بوقت طويل. إنها اللحظة التي يسأل فيها أحدهم من جانب المالية جانبَ الهندسة: "مزوّد الخدمة يتولى الفوترة الإلكترونية، صحيح؟"

مزوّد الخدمة المعتمد يتولى النقل. هو يشغّل الركنين الثاني والثالث من نموذج Peppol الخماسي، وهو من يحمل الاعتماد، وهو من يتحدث إلى الهيئة الاتحادية للضرائب. أما ما لا يفعله فهو أن يعرف أن جدول orders لديك يخزّن الضريبة كنسبة على الرأس للعملاء القدامى وعلى مستوى السطر لكل من انضم بعد 2023، أو أن ثلاثة من أكبر عشرة عملاء لديك سُجّلوا قبل أن تبدأ بجمع الرقم الضريبي، أو أن التقريب لديك منحرف بفلس واحد لكل سطر منذ أربع سنوات ولم يلاحظه أحد لأن أحداً لم يتحقق يوماً من مطابقة الإجمالي لمجموع السطور.

طبقة التحويل هذه ملك لك أنت. وهذا الدرس يبنيها.

هذا هو المرافق الهندسي لمقال استراتيجي. إن كنت ما زلت في مرحلة تحديد نطاق المشروع أو اختيار مزوّد أو وضع الميزانية، فاقرأ أولاً الفوترة الإلكترونية في الإمارات 2026: ما لن يفعله مزوّد الخدمة نيابة عنك. ذلك المقال يغطي الجدول الزمني والمراحل وأين يقف الحدّ التجاري. أما هنا فنفترض أن تلك القرارات اتُّخذت وأنك تكتب الشيفرة.

ما الذي ستبنيه

مكتبة TypeScript تأخذ كائن الفاتورة الداخلي لديك وتنتج مستند UBL 2.1 متوافقاً مع PINT AE، مع أربعة أمور تتخطاها معظم التطبيقات الداخلية:

  1. نموذج نطاق مُحكَم الأنواع يرفض أن يمثّل فاتورة لا يمكن أن تكون صحيحة — رقم ضريبي مفقود، أو كمية سالبة على مستند ليس إشعار دائن، أو فئة ضريبية تتطلب سبب إعفاء دون أن تحمله.
  2. حساب نقدي بالفلس الصحيح، بحيث يتطابق cac:TaxTotal مع مجموع السطور تماماً في كل مرة.
  3. منظومة تحقق محلية تشغّل ملفات XSD وSchematron الرسمية كمجموعة اختبارات vitest، بحيث تفشل الفاتورة المعطوبة على جهازك وفي CI بدلاً من أن تعود كرسالة رفض من الهيئة.
  4. آلة حالات لساق الاستجابة، لأن فاتورة Peppol ليست "مُرسَلة" لمجرد أن طلب HTTP أعاد 200.

في النهاية سيكون لديك buildInvoiceXml() وvalidateInvoiceXml() ودورة حياة مستند محفوظة تستطيع أن تجيب على سؤال "ما الحالة القانونية الحالية للفاتورة INV-2026-00412" دون أن يفتح أحد لوحة تحكم المزوّد.

المتطلبات المسبقة

  • Node.js 20 أو أحدث، وTypeScript 5.5 أو أحدث
  • إلمام بمساحات أسماء XML — فمعيار UBL يستخدم أربعاً منها، والخلط بينها هو أكثر أسباب الفشل المبكر شيوعاً
  • توفّر Java 11 أو أحدث على الجهاز وفي CI (أدوات Schematron المرجعية تعمل على JVM، وسنغلّفها لا نعيد كتابتها)
  • الوصول إلى نموذج بيانات الفواتير لديك، أو استعداد لتكييف النموذج المثال
  • حساب اختباري لدى مزوّد الخدمة، ويفضّل قبل البدء. وإن لم يتوفّر بعد، فكل شيء حتى خطوة الإرسال يعمل دون اتصال.

ولست بحاجة إلى بيانات اعتماد إنتاجية لمتابعة الدرس. الخطوات من 1 إلى 7 محلية بالكامل.

الخطوة 0: ثبّت المواصفة، لا تحفظها

معيار PINT AE مواصفة ذات إصدارات، تنشرها لجنة التنسيق لما بعد الترسية في OpenPeppol مع قواعد إماراتية خاصة فوق نموذج الفوترة الدولي. وقد مرّ بأكثر من إصدار بالفعل، وسلاسل المعرّفات وقوائم الرموز وقواعد العمل مرتبطة بالإصدار.

لذلك أول ما يجب أن تكتبه ليس المُنشئ، بل وحدة واحدة تحمل كل الثوابت المشتقة من المواصفة في مكان واحد، مع ختم الإصدار عليها:

// src/spec/pint-ae.ts
 
/**
 * ثوابت مشتقة من مواصفة PINT AE.
 * ثبّتها على الإصدار الذي اعتُمد عليه مزوّد الخدمة لديك، وأعد التحقق
 * من https://docs.peppol.eu/poac/ae/ عند كل تحديث للمواصفة.
 * لا تضع هذه القيم مباشرة داخل المُنشئ أبداً.
 */
export const PINT_AE_RELEASE = "2025-Q2" as const;
 
export const CUSTOMIZATION_ID = "urn:peppol:pint:billing-1@ae-1";
export const PROFILE_ID = "urn:peppol:bis:billing";
export const UBL_VERSION_ID = "2.1";
 
export const NS = {
  inv: "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2",
  cn: "urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2",
  cac: "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2",
  cbc: "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2",
  ext: "urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2",
} as const;
 
/** رموز نوع المستند وفق UN/CEFACT 1001 المستخدمة في الملف الإماراتي. */
export const DOC_TYPE = {
  taxInvoice: "380",
  creditNote: "381",
  debitNote: "383",
  selfBilledInvoice: "389",
} as const;
 
/** رموز الفئات الضريبية UNCL5305 ضمن نطاق الإمارات. */
export const VAT_CATEGORY = {
  standard: "S",       // ٥٪
  zeroRated: "Z",
  exempt: "E",
  reverseCharge: "AE",
  outOfScope: "O",
} as const;
 
export type VatCategoryCode =
  (typeof VAT_CATEGORY)[keyof typeof VAT_CATEGORY];
 
/** الفئات التي تتطلب قانوناً ذكر سبب على المستند. */
export const REASON_REQUIRED: ReadonlySet<VatCategoryCode> = new Set([
  VAT_CATEGORY.zeroRated,
  VAT_CATEGORY.exempt,
  VAT_CATEGORY.reverseCharge,
  VAT_CATEGORY.outOfScope,
]);
 
export const AED = "AED";
/** الدرهم ينقسم إلى ١٠٠ فلس. كل المبالغ داخلياً أعداد صحيحة بالفلس. */
export const MINOR_UNITS = 2;

لماذا هذا أهم مما يبدو. التوقع الوحيد الذي أطرحه بثقة عن مشروعك هو أن إصدار المواصفة سيتغير مرة واحدة على الأقل بين اليوم وموعد تشغيلك، وغالباً مرة أخرى بعده. الفرق التي وضعت urn:peppol:pint:billing-1@ae-1 مباشرة داخل قوالب نصية في تسعة ملفات تقضي أسبوعاً في العثور عليها كلها. أما التي وضعتها هنا فتغيّر سطراً واحداً وتعيد تشغيل الاختبارات.

ملاحظة حول سلاسل المعرّفات أعلاه. هي مطابقة لارتباط PINT AE المنشور حالياً، لكن تعامل معها كنقطة انطلاق لا كنص مقدّس. قارنها بالإصدار الذي اعتُمد عليه مزوّد خدمتك في اليوم الأول من المشروع — فعدم تطابق CustomizationID يُرفض عند الركن الثاني برسالة خطأ غير مفيدة، وهو فحص من خمس دقائق يوفّر عليك بعد ظهيرة كاملة.

الخطوة 1: نموذج نطاق لا يستطيع التعبير عن فاتورة غير صحيحة

أقوى رافعة لديك هي جعل الحالات الخاطئة غير قابلة للتمثيل، بحيث يمسك المُصرِّف الأخطاء بدل Schematron. وهذا هو النموذج، وهو أضيق من UBL عن قصد:

// src/domain/invoice.ts
 
/** المال دائماً أعداد صحيحة بالوحدة الصغرى (الفلس). لا عدد عشري، ولا نص. */
export type Fils = number & { readonly __brand: "Fils" };
 
export const fils = (n: number): Fils => {
  if (!Number.isInteger(n)) {
    throw new TypeError(`Money must be integer fils, received ${n}`);
  }
  return n as Fils;
};
 
/** الرقم الضريبي الإماراتي: خمسة عشر رقماً بالضبط. */
export type Trn = string & { readonly __brand: "Trn" };
 
export const trn = (raw: string): Trn => {
  const cleaned = raw.replace(/[\s-]/g, "");
  if (!/^\d{15}$/.test(cleaned)) {
    throw new TypeError(`Invalid TRN: expected 15 digits, got "${raw}"`);
  }
  return cleaned as Trn;
};
 
export interface LegalIdentifier {
  /** الرخصة التجارية، الهوية الإماراتية، المستند التجاري، أو جواز السفر. */
  readonly scheme: "TL" | "EID" | "CD" | "PAS";
  readonly value: string;
}
 
export interface Party {
  readonly name: string;
  /** اختياري للمشترين تحت حد التسجيل. */
  readonly trn?: Trn;
  readonly legalId?: LegalIdentifier;
  readonly address: {
    readonly street: string;
    readonly city: string;
    readonly emirate: string;
    readonly countryCode: string; // ISO 3166-1 alpha-2
  };
}
 
interface VatBase {
  readonly rate: number; // نسبة مئوية، مثلاً ٥
}
 
export type VatTreatment =
  | ({ readonly category: "S" } & VatBase)
  | { readonly category: "Z" | "E" | "AE" | "O"; readonly rate: 0; readonly reason: string };
 
export interface InvoiceLine {
  readonly id: string;
  readonly description: string;
  readonly quantity: number;
  readonly unitCode: string; // UN/ECE Rec 20، مثل "EA" أو "HUR"
  readonly unitPrice: Fils;
  readonly lineExtensionAmount: Fils; // صافٍ من الضريبة
  readonly vat: VatTreatment;
}
 
export interface Invoice {
  readonly number: string;
  readonly issueDate: string; // YYYY-MM-DD
  readonly dueDate?: string;
  readonly documentType: "380" | "381" | "383" | "389";
  readonly currency: "AED";
  readonly seller: Party & { readonly trn: Trn }; // الرقم الضريبي للبائع ليس اختيارياً أبداً
  readonly buyer: Party;
  readonly lines: readonly InvoiceLine[];
  /** يُملأ لإشعارات الدائن والمدين فقط. */
  readonly precedingInvoice?: { readonly number: string; readonly issueDate: string };
}

اقرأ اتحاد VatTreatment مرة أخرى، فهو يقوم بعمل صامت. السطر الخاضع للنسبة القياسية يحمل نسبة ولا شيء غيرها. أما كل فئة غير قياسية فمُجبرة على حمل نص reason، ومُجبرة على نسبة صفر، على مستوى الأنواع نفسها. والقاعدة القائلة إن التوريد الخاضع لنسبة الصفر يجب أن يذكر سبب ذلك هي من أكثر إخفاقات Schematron شيوعاً في كل ولاية قضائية اعتمدت Peppol، وهنا يستحيل إنشاء سطر كهذا بدونها.

والقصة نفسها مع Fils وTrn. إنهما نوعان موسومان: لن يُقبل number عادي كـFils دون المرور عبر الدالة البانية fils()، وتلك الدالة ترفض أي قيمة غير صحيحة. وبذلك لا يستطيع خطأ التقريب العشري أن يصل إلى XML لأنه لا يستطيع الوصول إلى نموذج النطاق أصلاً.

الخطوة 2: حساب نقدي يتطابق

إليك إخفاقاً حقيقياً، وسيقع لك إن استخدمت الأعداد العشرية. ثلاثة سطور بقيمة 33.33 درهماً، بضريبة ٥٪:

  • الضريبة لكل سطر: 1.6665 لكل منها. بعد التقريب: 1.67 لكل منها، والمجموع 5.01.
  • الضريبة على المجموع: 99.99 مضروبة في 0.05 تساوي 4.9995، وبعد التقريب 5.00.

فرق فلس واحد. وSchematron يتحقق من أن إجمالي الضريبة يساوي مجموع المجاميع الفرعية، ولا يعنيه أي الإجابتين تعتبرها أنت أصحّ — بل يعنيه أن يكون المستند متسقاً داخلياً. ومعظم حالات الرفض في الشهر الأول من أي إلزام بالفوترة الإلكترونية هي هذه أو قريبة منها.

والحل هو تثبيت ترتيب العمليات وعدم الحيد عنه:

// src/money.ts
import { fils, type Fils } from "./domain/invoice";
 
export const addFils = (...xs: Fils[]): Fils =>
  fils(xs.reduce((a, b) => a + b, 0));
 
/** تقريب نصف-لأعلى على الأعداد الصحيحة. لا ينجو أي عدد عشري من هذه الدالة. */
export const applyRate = (base: Fils, ratePercent: number): Fils => {
  const numerator = base * Math.round(ratePercent * 100); // النسبة بنقاط الأساس
  const scaled = Math.round(numerator / 10_000);
  return fils(scaled);
};
 
/** من الفلس إلى النص العشري الذي يتوقعه UBL: 12345 تصبح "123.45". */
export const toAmountString = (v: Fils): string => {
  const sign = v < 0 ? "-" : "";
  const abs = Math.abs(v);
  return `${sign}${Math.trunc(abs / 100)}.${String(abs % 100).padStart(2, "0")}`;
};

ثم المجاميع، محسوبة في مكان واحد بالضبط، ومجمّعة حسب الفئة الضريبية لأن هذا ما يريده UBL:

// src/totals.ts
import { addFils, applyRate } from "./money";
import { fils, type Fils, type Invoice } from "./domain/invoice";
 
export interface TaxSubtotal {
  readonly category: string;
  readonly rate: number;
  readonly taxableAmount: Fils;
  readonly taxAmount: Fils;
  readonly reason?: string;
}
 
export interface Totals {
  readonly lineExtensionAmount: Fils;
  readonly taxExclusiveAmount: Fils;
  readonly taxInclusiveAmount: Fils;
  readonly payableAmount: Fils;
  readonly taxAmount: Fils;
  readonly subtotals: readonly TaxSubtotal[];
}
 
export function computeTotals(invoice: Invoice): Totals {
  const groups = new Map<string, { rate: number; base: Fils; reason?: string }>();
 
  for (const line of invoice.lines) {
    const key = `${line.vat.category}:${line.vat.rate}`;
    const existing = groups.get(key);
    const reason = "reason" in line.vat ? line.vat.reason : undefined;
    groups.set(key, {
      rate: line.vat.rate,
      base: addFils(existing?.base ?? fils(0), line.lineExtensionAmount),
      reason: existing?.reason ?? reason,
    });
  }
 
  // تُحسب الضريبة مرة واحدة لكل مجموعة فئة، على القاعدة المجمّعة.
  // لا تحسبها لكل سطر ثم تجمع — تلك هي علّة الفلس الواحد.
  const subtotals: TaxSubtotal[] = [...groups.entries()].map(([key, g]) => ({
    category: key.split(":")[0],
    rate: g.rate,
    taxableAmount: g.base,
    taxAmount: applyRate(g.base, g.rate),
    reason: g.reason,
  }));
 
  const lineExtensionAmount = addFils(...invoice.lines.map((l) => l.lineExtensionAmount));
  const taxAmount = addFils(...subtotals.map((s) => s.taxAmount));
  const taxInclusiveAmount = addFils(lineExtensionAmount, taxAmount);
 
  return {
    lineExtensionAmount,
    taxExclusiveAmount: lineExtensionAmount,
    taxInclusiveAmount,
    payableAmount: taxInclusiveAmount,
    taxAmount,
    subtotals,
  };
}

التعليق في المنتصف هو الدرس كله. اجمع القاعدة الخاضعة للضريبة لكل فئة أولاً، ثم طبّق النسبة مرة واحدة. أما كل رقم ضريبي على مستوى السطر تعرضه في واجهتك فهو تسهيل عرضي؛ حساب المستند يجري على المجموعة.

الخطوة 3: توليد UBL بمساحات أسمائه كاملة

لا تبنِ XML بقوالب نصية. علامة & غير مهرَّبة في اسم عميل مثل "الفطيم وأولاده" ستنتج مستنداً يفشل في تحليل XSD عند مزوّد الخدمة، والخطأ العائد إليك سيتحدث عن السطر ٨٤ من مستند لا تراه أصلاً. استخدم مُنشئاً يهرّب نيابة عنك:

npm install xmlbuilder2
npm install -D vitest tsx
// src/build/invoice-xml.ts
import { create } from "xmlbuilder2";
import {
  CUSTOMIZATION_ID, PROFILE_ID, UBL_VERSION_ID, NS, AED,
} from "../spec/pint-ae";
import type { Fils, Invoice, Party } from "../domain/invoice";
import { computeTotals } from "../totals";
import { toAmountString } from "../money";
 
const amt = (v: Fils) => ({ "@currencyID": AED, "#": toAmountString(v) });
 
function partyNode(p: Party, endpointScheme: string) {
  const node: Record<string, unknown> = {};
 
  if (p.trn) {
    node["cbc:EndpointID"] = { "@schemeID": endpointScheme, "#": p.trn };
  }
 
  node["cac:PostalAddress"] = {
    "cbc:StreetName": p.address.street,
    "cbc:CityName": p.address.city,
    "cbc:CountrySubentity": p.address.emirate,
    "cac:Country": { "cbc:IdentificationCode": p.address.countryCode },
  };
 
  if (p.trn) {
    node["cac:PartyTaxScheme"] = {
      "cbc:CompanyID": p.trn,
      "cac:TaxScheme": { "cbc:ID": "VAT" },
    };
  }
 
  node["cac:PartyLegalEntity"] = {
    "cbc:RegistrationName": p.name,
    ...(p.legalId
      ? { "cbc:CompanyID": { "@schemeAgencyID": p.legalId.scheme, "#": p.legalId.value } }
      : {}),
  };
 
  return node;
}
 
export function buildInvoiceXml(invoice: Invoice, endpointScheme: string): string {
  const t = computeTotals(invoice);
 
  const doc = create({ version: "1.0", encoding: "UTF-8" }).ele("Invoice", {
    xmlns: NS.inv,
    "xmlns:cac": NS.cac,
    "xmlns:cbc": NS.cbc,
    "xmlns:ext": NS.ext,
  });
 
  doc.ele("cbc:UBLVersionID").txt(UBL_VERSION_ID);
  doc.ele("cbc:CustomizationID").txt(CUSTOMIZATION_ID);
  doc.ele("cbc:ProfileID").txt(PROFILE_ID);
  doc.ele("cbc:ID").txt(invoice.number);
  doc.ele("cbc:IssueDate").txt(invoice.issueDate);
  if (invoice.dueDate) doc.ele("cbc:DueDate").txt(invoice.dueDate);
  doc.ele("cbc:InvoiceTypeCode").txt(invoice.documentType);
  doc.ele("cbc:DocumentCurrencyCode").txt(AED);
 
  if (invoice.precedingInvoice) {
    doc.ele("cac:BillingReference").ele("cac:InvoiceDocumentReference").ele({
      "cbc:ID": invoice.precedingInvoice.number,
      "cbc:IssueDate": invoice.precedingInvoice.issueDate,
    });
  }
 
  doc.ele("cac:AccountingSupplierParty").ele({
    "cac:Party": partyNode(invoice.seller, endpointScheme),
  });
  doc.ele("cac:AccountingCustomerParty").ele({
    "cac:Party": partyNode(invoice.buyer, endpointScheme),
  });
 
  const taxTotal = doc.ele("cac:TaxTotal");
  taxTotal.ele("cbc:TaxAmount", { currencyID: AED }).txt(toAmountString(t.taxAmount));
 
  for (const s of t.subtotals) {
    const sub = taxTotal.ele("cac:TaxSubtotal");
    sub.ele("cbc:TaxableAmount", { currencyID: AED }).txt(toAmountString(s.taxableAmount));
    sub.ele("cbc:TaxAmount", { currencyID: AED }).txt(toAmountString(s.taxAmount));
    const cat = sub.ele("cac:TaxCategory");
    cat.ele("cbc:ID").txt(s.category);
    cat.ele("cbc:Percent").txt(s.rate.toFixed(2));
    if (s.reason) cat.ele("cbc:TaxExemptionReason").txt(s.reason);
    cat.ele("cac:TaxScheme").ele("cbc:ID").txt("VAT");
  }
 
  doc.ele("cac:LegalMonetaryTotal").ele({
    "cbc:LineExtensionAmount": amt(t.lineExtensionAmount),
    "cbc:TaxExclusiveAmount": amt(t.taxExclusiveAmount),
    "cbc:TaxInclusiveAmount": amt(t.taxInclusiveAmount),
    "cbc:PayableAmount": amt(t.payableAmount),
  });
 
  for (const line of invoice.lines) {
    const l = doc.ele("cac:InvoiceLine");
    l.ele("cbc:ID").txt(line.id);
    l.ele("cbc:InvoicedQuantity", { unitCode: line.unitCode }).txt(String(line.quantity));
    l.ele("cbc:LineExtensionAmount", { currencyID: AED })
      .txt(toAmountString(line.lineExtensionAmount));
    l.ele("cac:Item").ele({
      "cbc:Name": line.description,
      "cac:ClassifiedTaxCategory": {
        "cbc:ID": line.vat.category,
        "cbc:Percent": line.vat.rate.toFixed(2),
        "cac:TaxScheme": { "cbc:ID": "VAT" },
      },
    });
    l.ele("cac:Price").ele("cbc:PriceAmount", { currencyID: AED })
      .txt(toAmountString(line.unitPrice));
  }
 
  return doc.end({ prettyPrint: true });
}

انتبه إلى ترتيب العناصر. فمخطط UBL يفرض التسلسل لا مجرد الوجود — cbc:IssueDate قبل cbc:InvoiceTypeCode، وcac:TaxTotal قبل cac:LegalMonetaryTotal، وcac:LegalMonetaryTotal قبل السطور. والمستند الذي تتوفر فيه كل الحقول المطلوبة بترتيب خاطئ مستند غير صحيح. وهذا أكثر أصناف الأخطاء إزعاجاً في التشخيص انطلاقاً من رسالة رفض بعيدة، وهو سبب وجود الخطوة الرابعة.

الخطوة 4: تحقق محلياً قبل أن يغادر أي شيء المبنى

ملفات التحقق المرجعية لـPINT AE هي مخططات XSD إضافة إلى مجموعات قواعد Schematron، والأداة المعيارية لتشغيل Schematron تعمل على JVM. وإعادة كتابة Schematron بلغة TypeScript مشروع قائم بذاته لا خطوة؛ أما تغليف المدقق الرسمي فعشرون سطراً تبقى صحيحة عند تغيّر القواعد.

نزّل ملفات تحقق Peppol الخاصة بالإصدار المستهدف واربطها:

// src/validate/schematron.ts
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { writeFile, mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
 
const run = promisify(execFile);
 
export interface ValidationFinding {
  readonly severity: "fatal" | "warning";
  readonly ruleId: string;
  readonly location: string;
  readonly message: string;
}
 
const JAR = process.env.PEPPOL_VALIDATOR_JAR ?? "./tools/phive-cli.jar";
const RULESET = process.env.PEPPOL_RULESET ?? "eu.peppol.pint.ae:invoice:latest";
 
export async function validateInvoiceXml(xml: string): Promise<ValidationFinding[]> {
  const dir = await mkdtemp(join(tmpdir(), "pint-ae-"));
  const file = join(dir, "invoice.xml");
  try {
    await writeFile(file, xml, "utf8");
    const { stdout } = await run("java", [
      "-jar", JAR,
      "--vesid", RULESET,
      "--mode", "json",
      file,
    ]);
    return parseFindings(stdout);
  } catch (err) {
    // الخروج بقيمة غير صفرية هو طريقة المدقق في الإبلاغ عن نتائج، لا انهيار.
    const stdout = (err as { stdout?: string }).stdout;
    if (stdout) return parseFindings(stdout);
    throw new Error(
      `Validator failed to run. Is Java on PATH and ${JAR} present? ${String(err)}`,
    );
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
}
 
function parseFindings(stdout: string): ValidationFinding[] {
  const report = JSON.parse(stdout) as {
    results?: Array<{ items?: Array<Record<string, string>> }>;
  };
  return (report.results ?? []).flatMap((r) =>
    (r.items ?? []).map((i) => ({
      severity: i.errorLevel === "ERROR" ? ("fatal" as const) : ("warning" as const),
      ruleId: i.errorID ?? "unknown",
      location: i.errorLocation ?? "",
      message: i.errorText ?? "",
    })),
  );
}

والآن الجزء الذي يجعل الأمر يثبت — التحقق كاختبار، لا كسكربت يتذكر أحدهم تشغيله:

// src/__tests__/invoice.spec.ts
import { describe, expect, it } from "vitest";
import { buildInvoiceXml } from "../build/invoice-xml";
import { validateInvoiceXml } from "../validate/schematron";
import { computeTotals } from "../totals";
import { standardInvoice, mixedRateInvoice, reverseChargeInvoice } from "./fixtures";
 
describe("PINT AE conformance", () => {
  for (const [name, fixture] of Object.entries({
    standardInvoice, mixedRateInvoice, reverseChargeInvoice,
  })) {
    it(`${name} produces zero fatal findings`, async () => {
      const findings = await validateInvoiceXml(buildInvoiceXml(fixture, "0235"));
      const fatal = findings.filter((f) => f.severity === "fatal");
      expect(fatal, JSON.stringify(fatal, null, 2)).toHaveLength(0);
    }, 30_000);
  }
});
 
describe("monetary reconciliation", () => {
  it("tax total equals the sum of subtotals for awkward thirds", () => {
    // ٣ سطور بقيمة 33.33 درهماً وضريبة ٥٪ — انحراف الفلس الكلاسيكي
    const t = computeTotals(mixedRateInvoice);
    const summed = t.subtotals.reduce((a, s) => a + s.taxAmount, 0);
    expect(t.taxAmount).toBe(summed);
  });
 
  it("inclusive total equals exclusive plus tax", () => {
    const t = computeTotals(standardInvoice);
    expect(t.taxInclusiveAmount).toBe(t.taxExclusiveAmount + t.taxAmount);
  });
});

ضع هذا في CI منذ اليوم الأول، وبأشكال بيانات عملاء حقيقية في العيّنات. القيمة ليست في إثبات أن فاتورة اليوم صحيحة — بل في أنه حين يضيف أحدهم حقل خصم في مارس، أو فرعاً في إمارة جديدة برقم ضريبي مختلف، تخبره المجموعة قبل أن تخبره الهيئة. الفرق التي تضيف التحقق في نهاية المشروع تكتشف مشاكل بياناتها في أسبوع التشغيل، وهو أغلى أسبوع ممكن لاكتشافها.

الخطوة 5: مشاكل البيانات الخمس التي هي مسؤوليتك فعلاً

XML هو النصف السهل. أما وقت المشروع الحقيقي فيذهب إلى هنا، مرتباً تقريباً حسب حجم ما يستهلكه:

تغطية الرقم الضريبي للمشتري. كل فاتورة بين منشأتين تحتاج الرقم الضريبي للمشتري. ونظام إدارة العملاء لديك يملكه لمن انضم بعد أن بدأت بطلبه. احسب العدد قبل أن تعد بتاريخ:

SELECT
  COUNT(*) FILTER (WHERE trn IS NULL OR trn = '')                    AS missing,
  COUNT(*) FILTER (WHERE trn ~ '^[0-9]{15}$')                        AS well_formed,
  COUNT(*) FILTER (WHERE trn IS NOT NULL AND trn !~ '^[0-9]{15}$')   AS malformed
FROM customers
WHERE status = 'active' AND customer_type = 'business';

خانة malformed هي التي تفاجئ الناس — أرقام ضريبية أُدخلت بمسافات، أو ببادئة TRN-، أو بملاحظة في آخرها، أو نُسخت بأربعة عشر رقماً. والتوحيد رخيص؛ أما ملاحقة المفقودة فعملية تجارية تمتد شهوراً، ولهذا يجب أن تبدأ في الشهر الأول لا الخامس.

رموز الوحدات. يريد UBL رموز التوصية رقم ٢٠ من UN/ECE. ونظامك يحتوي "قطعة" و"ساعة" و"صندوق" و"حبة" ومدخلاً واحداً مكتوب فيه "-" فقط. كل قيمة مميزة تحتاج تحويلاً، والتحويل يحتاج مالكاً:

const UNIT_CODE_MAP: Record<string, string> = {
  each: "EA", pcs: "EA", unit: "EA", item: "EA",
  hour: "HUR", hr: "HUR", hours: "HUR",
  day: "DAY", month: "MON",
  kg: "KGM", km: "KMT", litre: "LTR", l: "LTR",
};
 
export function toUnitCode(raw: string): string {
  const code = UNIT_CODE_MAP[raw.trim().toLowerCase()];
  if (!code) {
    // افشل بصوت عالٍ وقت البناء. الرجوع الصامت إلى "EA" علّة في سلامة
    // البيانات تظهر بعد شهور في تدقيق ضريبي.
    throw new Error(`Unmapped unit of measure: "${raw}". Add it to UNIT_CODE_MAP.`);
  }
  return code;
}

ورمي الاستثناء مقصود. البديل المغري هو الرجوع الافتراضي إلى EA، وهو خاطئ: لن تعرف بذلك حتى يسألك مدقق لماذا احتسبت أربعة آلاف فاتورة الساعات كوحدات.

إشعارات دائن لا تشير إلى شيء. يجب أن يشير إشعار الدائن إلى الفاتورة التي يعكسها عبر cac:BillingReference. وإن كان نظامك يصدر إشعارات مستقلة — بادرات حسن نية، أو تسويات أرصدة افتتاحية — فتلك تحتاج إما مستنداً سابقاً أو معالجة مختلفة. جدها الآن:

SELECT COUNT(*) FROM credit_notes WHERE original_invoice_id IS NULL;

أرقام ضريبية متعددة الفروع. المجموعة التي تملك عدة كيانات مرخّصة تملك عدة أرقام ضريبية، ويجب أن تحمل الفاتورة الرقم العائد للكيان المُصدِر. وإن كان ترقيم فواتيرك عالمياً بينما كياناتك القانونية ليست كذلك، فذلك التحويل يجب أن يوجد في مكان ما، وعبارة "الجميع يعرف أن الفرع الثالث يفوتر تحت الرخصة التجارية" ليست مكاناً.

انحراف تقريب موجود في الدفاتر أصلاً. قبل أن تبني أي شيء، تحقق مما إذا كانت مجاميعك المخزنة تتطابق:

SELECT id, total_vat, computed_vat, total_vat - computed_vat AS drift
FROM (
  SELECT i.id, i.total_vat,
         ROUND(SUM(l.net_amount) * 0.05, 2) AS computed_vat
  FROM invoices i JOIN invoice_lines l ON l.invoice_id = i.id
  WHERE i.issue_date >= DATE '2026-01-01'
  GROUP BY i.id, i.total_vat
) x
WHERE ABS(total_vat - computed_vat) > 0.001
ORDER BY ABS(total_vat - computed_vat) DESC
LIMIT 50;

إن أعاد هذا صفوفاً، فأمامك قرار بشأن البيانات التاريخية قبل أن يتحول إلى حوار امتثال بدل حوار هندسي.

الخطوة 6: الإرسال إلى الركن الثاني، بلا تكرار

مزوّد خدمتك يعرض واجهة برمجية — شكلها يتغير، أما الاهتمامات فلا. وأهمها: رقم الفاتورة يُصدر مرة واحدة. وإعادة المحاولة بعد انتهاء المهلة يجب ألا تنشئ مستنداً قانونياً ثانياً.

// src/transmit/send.ts
import { createHash } from "node:crypto";
 
export interface SubmissionResult {
  readonly providerRef: string;
  readonly acceptedAt: string;
}
 
export async function submit(
  invoiceNumber: string,
  xml: string,
  deps: { fetch: typeof fetch; baseUrl: string; token: string },
): Promise<SubmissionResult> {
  // مفتاح عدم التكرار مشتق من المستند نفسه: البايتات ذاتها المُعاد إرسالها
  // هي الإرسال ذاته؛ والبايتات المتغيرة مستند آخر يجب أن يرفضه المزوّد
  // تحت رقم فاتورة مستخدم بالفعل.
  const idempotencyKey = createHash("sha256")
    .update(`${invoiceNumber}:${xml}`)
    .digest("hex");
 
  const res = await deps.fetch(`${deps.baseUrl}/documents`, {
    method: "POST",
    headers: {
      "Content-Type": "application/xml",
      Authorization: `Bearer ${deps.token}`,
      "Idempotency-Key": idempotencyKey,
    },
    body: xml,
  });
 
  if (res.status === 409) {
    // أُرسل مسبقاً. استخرج المرجع القائم بدل الفشل.
    const existing = await res.json();
    return { providerRef: existing.documentId, acceptedAt: existing.receivedAt };
  }
 
  if (!res.ok) {
    throw new TransmissionError(res.status, await res.text());
  }
 
  const body = await res.json();
  return { providerRef: body.documentId, acceptedAt: body.receivedAt };
}
 
export class TransmissionError extends Error {
  constructor(readonly status: number, readonly body: string) {
    super(`Corner 2 rejected submission: HTTP ${status}`);
    this.name = "TransmissionError";
  }
}

أعد المحاولة فقط عند أخطاء 5xx وأعطال الشبكة، مع تراجع تدريجي. أما خطأ 4xx فيعني أن المستند خاطئ، وإرسال المستند الخاطئ نفسه تسع مرات إضافية ينتج تسع حالات رفض متطابقة وتذكرة دعم فنّي شديدة الحيرة.

الخطوة 7: ساق الاستجابة آلة حالات لا قيمة مُعادة

هذه هي الخطوة التي تكتشفها الفرق متأخرة، وهي التي تغيّر مخطط قاعدة بياناتك.

في النموذج الخماسي تسافر فاتورتك: منك، إلى مزوّدك، عبر شبكة Peppol إلى مزوّد المشتري، إلى المشتري — مع ساق إبلاغ موازية تحمل البيانات الضريبية إلى الجهة المختصة. والإقرارات تعود بشكل غير متزامن. فاستجابة على مستوى الرسالة قد تصل بعد دقائق. واستجابة الفاتورة — قبول المشتري أو رفضه على مستوى الأعمال — قد تصل بعد أيام، وكثيراً ما تفعل، لأنها إنسان يضغط زراً في نظام حسابات دائنة.

لذا فحالة sent ليست حالة نهائية، وسؤال "هل نجح الأمر" لا يُجاب عليه من رمز حالة HTTP:

// src/lifecycle/state.ts
export type DocumentState =
  | "draft"           // بُني ولم يُثبت صحته بعد
  | "validated"       // يجتاز Schematron محلياً
  | "submitted"       // قبله مزوّدنا عند الركن الثاني
  | "delivered"       // MLR: وصل إلى نقطة وصول المشتري
  | "accepted"        // IR: قبله المشتري
  | "rejected"        // IR: رفضه المشتري — يحتاج إشعار دائن
  | "failed";         // تعذّر إرساله
 
const TRANSITIONS: Record<DocumentState, readonly DocumentState[]> = {
  draft: ["validated", "failed"],
  validated: ["submitted", "failed"],
  submitted: ["delivered", "failed"],
  delivered: ["accepted", "rejected"],
  accepted: [],
  rejected: [],
  failed: ["validated"], // أصلح ثم أعد المحاولة
};
 
export function canTransition(from: DocumentState, to: DocumentState): boolean {
  return TRANSITIONS[from].includes(to);
}
 
export class IllegalTransition extends Error {
  constructor(from: DocumentState, to: DocumentState) {
    super(`Illegal document transition: ${from} to ${to}`);
    this.name = "IllegalTransition";
  }
}

ثم التخزين. لاحظ ما الذي يُحفَظ: البايتات التي أُرسلت بالضبط، لا الكائن الذي بُنيت منه.

CREATE TABLE einvoice_document (
  id                BIGSERIAL PRIMARY KEY,
  invoice_number    TEXT NOT NULL UNIQUE,
  state             TEXT NOT NULL,
  -- البايتات المُرسَلة حرفياً. إعادة بناء XML لاحقاً لن تعيد إنتاجها
  -- بمجرد أن يتغير إصدار المواصفة أو تحويلك.
  xml_payload       BYTEA NOT NULL,
  xml_sha256        TEXT NOT NULL,
  spec_release      TEXT NOT NULL,
  provider_ref      TEXT,
  submitted_at      TIMESTAMPTZ,
  delivered_at      TIMESTAMPTZ,
  responded_at      TIMESTAMPTZ,
  rejection_reason  TEXT,
  created_at        TIMESTAMPTZ NOT NULL DEFAULT now()
);
 
CREATE TABLE einvoice_event (
  id            BIGSERIAL PRIMARY KEY,
  document_id   BIGINT NOT NULL REFERENCES einvoice_document(id),
  from_state    TEXT NOT NULL,
  to_state      TEXT NOT NULL,
  payload       JSONB,
  occurred_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);
 
CREATE INDEX ON einvoice_document (state) WHERE state IN ('submitted', 'delivered');

قراران في التصميم يستحقان الدفاع:

احفظ البايتات لا النموذج. التزام الحفظ يقع على المستند الذي جرى تبادله. وبعد سنتين سينتج مُنشئك مخرجات مختلفة قليلاً للمدخل نفسه — تحديث مواصفة، أو إصلاح تحويل، أو ترقية مكتبة — والمستند المُعاد توليده ليس المستند الذي أرسلته. وxml_sha256 يتيح لك إثبات ذلك لاحقاً.

اختم spec_release على كل صف. حين تنتقل إلى إصدار جديد من PINT AE في منتصف السنة، ستحتاج معرفة أي المستندات صدرت تحت أي قواعد للإجابة على استفسار عنها. أما استرجاع ذلك العمود لاحقاً فتخمين.

والفهرس الجزئي شيء صغير يؤتي ثماره: وظيفة التسوية لديك تريد بالضبط المستندات التي ما زالت في الطريق، وتلك المجموعة تبقى صغيرة بينما يتجاوز الجدول المليون صف.

اختبار تطبيقك

إلى جانب مجموعة المطابقة في الخطوة الرابعة، ثلاثة فحوص تستحق مكانها:

اختبارات الملفات الذهبية. التقط لقطة من XML لكل عيّنة وقارن عند التغيير. فحين تغيّر ترقية مكتبة ترتيب السمات أو أسلوب الوسوم المغلقة ذاتياً بصمت، تريد أن ترى ذلك في فرق نصي لا في رسالة رفض.

اختبار خاصية على المال. ولّد مجموعات سطور عشوائية وتحقق من ثبات الثابت لها جميعاً:

import fc from "fast-check";
 
it("totals always reconcile regardless of line composition", () => {
  fc.assert(
    fc.property(
      fc.array(fc.integer({ min: 1, max: 5_000_00 }), { minLength: 1, maxLength: 60 }),
      (amounts) => {
        const inv = invoiceWithLineAmounts(amounts);
        const t = computeTotals(inv);
        const summed = t.subtotals.reduce((a, s) => a + s.taxAmount, 0);
        return t.taxAmount === summed
          && t.taxInclusiveAmount === t.taxExclusiveAmount + t.taxAmount;
      },
    ),
    { numRuns: 500 },
  );
});

خمسمئة فاتورة عشوائية ستجد التركيبة التي لن تجدها عيّناتك الثلاث المكتوبة يدوياً.

منظومة إعادة تشغيل على أشكال إنتاجية. خذ فواتير الشهر الماضي الحقيقية، ومرّرها عبر المُنشئ والمدقق في وظيفة للقراءة فقط، واحسب النتائج القاتلة حسب معرّف القاعدة. ذلك الرقم وحده — "4,812 فاتورة، 61 نتيجة قاتلة، جميعها AE-R-011 رقم ضريبي مفقود للمشتري" — هو أنفع تقرير حالة ستنتجه في المشروع، وهو يحوّل خطر امتثال مجرداً إلى قائمة عمل.

استكشاف الأخطاء

"المستند لا يطابق التخصيص" — قيمة CustomizationID لديك لا تطابق الإصدار الذي يتوقعه الطرف المستقبِل. راجع src/spec/pint-ae.ts مقابل الإصدار المعتمد لدى مزوّدك. هذا هو خطأ اليوم الأول الأول.

أخطاء تسلسل XSD على مستند يبدو مكتملاً — ترتيب العناصر لا وجودها. UBL يفرض التسلسل. قارن مخرجاتك بمستند عيّنة رسمي عنصراً بعنصر.

نتائج عدم تطابق إجمالي الضريبة — أنت تحسب الضريبة لكل سطر ثم تجمع. عد إلى الخطوة الثانية واجمع القاعدة لكل فئة أولاً.

نتائج لا تظهر إلا في الإنتاج — تكون دائماً تقريباً بيانات محارف. نص عربي، أو علامة &، أو مسافة غير فاصلة مُلصقة من Excel، أو شرطة طويلة في وصف. عيّناتك بمحارف ASCII؛ وعملاؤك ليسوا كذلك. أضف عيّنة باسم تجاري عربي كامل، وأخرى فيها & < > " ' في وصف الصنف.

صمت بعد الإرسال — لم يعد شيء، ولن يعود، إن لم تسجّل نقطة استدعاء راجع أو وظيفة استقصاء. أحداث التسليم والاستجابة تُدفع أو تُسحب؛ لا تصل من تلقاء نفسها. تحقق من أن وظيفة التسوية في الخطوة السابعة تعمل فعلاً، ومن أن استعلامها يشمل submitted لا delivered فقط.

رفض يصل بعد أن دفع العميل — هذا طبيعي، وعلى عمليتك المحاسبية أن تتعامل معه. فالفاتورة المرفوضة لا تُلغي إصدار نفسها؛ بل تحتاج إشعار دائن وإعادة إصدار، وهذا سير عمل تجاري لا علّة برمجية.

الخطوات التالية

  • اربط وظيفة التسوية بتنبيه على المستندات العالقة في submitted أكثر من أربع وعشرين ساعة — فالتوقف الصامت هو نمط الفشل الأغلى والأقل ظهوراً.
  • عالج الفوترة الذاتية (389) إن كان أي عميل يفوتر نفسه عنك؛ فأدوار الأطراف تنقلب وقواعد التحقق تختلف.
  • وسّع المُنشئ نفسه ليشمل إشعارات الدائن والمدين — فمستند CreditNote يستخدم عنصر جذر ومساحة أسماء مختلفين، ويصبح cac:BillingReference إلزامياً.
  • إن كان نظامك Odoo، فطبقة التحويل في هذا الدرس تُركّب خلف واجهته الخارجية لا داخل وحدة: راجع تكامل Odoo 17 عبر الواجهة الخارجية مع TypeScript.
  • تعمل في السعودية أيضاً؟ درس تكامل الفوترة الإلكترونية ZATCA المرحلة الثانية يغطي المشكلة نفسها تحت نظام مختلف — تخليص بدل تبادل خماسي، مع ختم تشفيري ورمز استجابة سريعة. وإن كنت تقدّم في البلدين، فابنِ نموذج نطاق واحداً ومُسلسلَين اثنين، لا نظامين.

الخاتمة

يُقدَّم إلزام الفوترة الإلكترونية في الإمارات عادةً كقرار شراء: اختر مزوّداً، وقّع، انتهى الأمر. والمزوّد ضروري فعلاً ويقوم بالهندسة الشبكية الصعبة فعلاً. لكن الجزء الذي يقرّر ما إذا كانت فواتيرك ستُقبل هو الجزء داخل أنظمتك أنت — الأرقام الضريبية التي تملكها أو لا تملكها، والتقريب الذي تمارسه منذ سنوات، ورموز الوحدات التي لم يوحّدها أحد، وإشعارات الدائن التي لا تشير إلى شيء.

كل ما في هذا الدرس موجود لتقديم تلك الاكتشافات في الزمن. نوع Fils الموسوم يكشف علّة تقريب وقت التصريف. واتحاد VatTreatment يجعل سبب الإعفاء المفقود غير قابل للتمثيل. ومجموعة Schematron في CI تحوّل رفضاً مستقبلياً إلى اختبار فاشل اليوم. ومنظومة إعادة التشغيل تحوّل سؤال "هل نحن جاهزون" من رأي إلى عدد.

لا شيء من هذا غريب. إنه انضباط هندسي عادي مطبَّق على موعد نهائي لا يتزحزح.


تحوّل بيانات فواتيرك إلى PINT AE وتريد عيناً ثانية على الثغرات؟ نحن نعمل على التكامل بين أنظمة تخطيط الموارد وطبقات الامتثال — التحويل، ومنظومة التحقق، ووظيفة التسوية. ومراجعة قصيرة لنموذج بياناتك الحالي تكشف عادةً المشاكل المكلفة في بعد ظهيرة واحدة. أخبرنا بما تعمل عليه.