fatura-zatca
v0.2.1
Published
Arabic tax-invoice PDF: correct bidi text, halala-exact totals, and a ZATCA Phase-1 QR built from the authority's own specification text | فاتورة ضريبية عربية بصيغة PDF — نصّ مرتَّب صحيحاً، ومجاميع بالهللات، ورمز QR مبنيّ على نصّ المواصفة
Maintainers
Readme
فاتورة · fatura-zatca — فاتورة ضريبية عربية بصيغة PDF
A Saudi (ZATCA) tax invoice as a PDF, in one call — correctly ordered Arabic text, integer-minor-unit totals, and a Phase-1 TLV QR built from the specification text.
npm i fatura-zatcaimport { renderInvoice } from "fatura-zatca";
const { pdf, totals, qrPayload } = await renderInvoice({
number: "INV-2026-0042",
issuedAt: "2026-09-01T13:45:00+03:00",
currency: "SAR",
seller: { name: "مؤسسة الأمل التجارية", vatNumber: "310122393500003" },
lines: [{ description: "استشارة برمجية", quantity: 2, unitPrice: 50_000, vatRate: 15 }],
}, { fontBytes, amountInWords: tafgeet });Amounts are integers in the smallest unit: 50_000 means 500.00 SAR. Money is never a
float — 0.1 + 0.2 !== 0.3, and (1.005).toFixed(2) gives "1.00" when the answer is
1.01. VAT is computed per line then summed, not on the total.
The QR trap that breaks naive implementations: the TLV length is a BER length, in
bytes, not characters. شركة is 4 characters and 8 bytes; past 128 bytes you need
the long form. We measured 11 packages carrying 44,915 npm downloads — 91.7% of
those downloads go to code that gets this wrong. The benchmark is
open and reruns with one command.
Tests read the file, not the return value. 41 tests; the important ones open the
produced PDF, map glyph IDs back through the font, and rebuild the QR from the squares
actually drawn on the page — then decode it with jsqr, a reader we did not write.
Not here: Phase-2 tags 6–9 (XML hash, ECDSA signature, certificates) — those need a real cryptographic stamping certificate, and we do not ship code we could not run against one. Nor any claim about whether ZATCA accepted your invoice: that lives in the platform's response, not in the file.
Full write-up below is in Arabic. MIT.
نصٌّ مرتَّب صحيحاً، ومجاميع بالهللات، ورمز QR مبنيّ على نصّ مواصفة الهيئة — في ملف واحد جاهز للطباعة أو الإرسال.
import { renderInvoice } from "fatura-zatca";
const { pdf, totals, qrPayload } = await renderInvoice({
number: "INV-2026-0042",
issuedAt: "2026-09-01T13:45:00+03:00",
currency: "SAR",
seller: { name: "مؤسسة الأمل التجارية", vatNumber: "310122393500003" },
lines: [
{ description: "استشارة برمجية", quantity: 2, unitPrice: 50_000, vatRate: 15 },
],
}, { fontBytes, amountInWords: tafgeet });المبالغ أعدادٌ صحيحة بأصغر وحدة: 50_000 تعني ٥٠٠٫٠٠ ريالاً.
ثلاثة قرارات تُميّز هذه الحزمة
① الحساب بالهللات — لا بالكسور العشرية
0.1 + 0.2 !== 0.3 في كل لغةٍ تستعمل الفاصلة العائمة، و(1.005).toFixed(2)
تُعطي "1.00" والصواب "1.01". فاتورةٌ تُبنى على ذلك تُخطئ في هللة — وتلك
الهللة هي ما يرفض المُحقِّق الفاتورةَ من أجله.
فكل المبالغ أعدادٌ صحيحة، والقسمةُ على مئة آخرُ عملية لا أولها.
والضريبة تُحسب لكل بند ثم تُجمع، لا على المجموع. الفرق هللةٌ أو اثنتان في فاتورةٍ ذات بنود كثيرة، وهي التي تُفشل المطابقة.
② لا نخترع مالاً
lineTotal يُؤخذ من نظامك المحاسبي إن مرّرته — فالتقريب في ضرب كميةٍ كسرية
قرارٌ محاسبي يخصّك. وإن غاب حُسب quantity × unitPrice بتقريب نصفٍ إلى أعلى،
وقيل ذلك صراحةً.
والمبلغ بالحروف يُمرَّر من محرّك تفقيط تختاره؛ وإن لم تمرّره لم يُطبع السطر — ولا تُخترع صياغةٌ نحوية بلا محرّك.
③ رمز QR من نصّ المواصفة لا من عيّنة
ZATCA — Electronic Invoice Security Features Implementation Standards v1.1، §4.1 والجدول 3.
وسببُ التشديد قصّة: ذهبنا نتحقق من مولّدنا بعيّنة Base64 «رسمية» مستعادة من الذاكرة، فلم تُطابق. وقبل تعديل الكود فُكِّكت العيّنةُ نفسها فإذا هي فاسدة — تُعلن الوسم 4 بطول 6 ثم لا ينتظم ما بعدها TLV. المرجع كان خاطئاً والتطبيق سليماً، ولو صُدِّقت الذاكرة لصار الصوابُ خطأً.
فالاختبار اليوم يبني المتوقَّع بمشفّرٍ مستقل مكتوب من نصّ §4.1، ويقارنه بنسخة بايثون المرجعية أيضاً — فمقارنة الكود بنفسه لا تُثبت شيئاً.
والفخّ الذي يُسقط كل تطبيق ساذج: الطول بالبايتات لا بالأحرف. «شركة» أربعة أحرف وثمان بايتات، ومن عدّ الأحرف أنتج رمزاً يفشل قارئه بلا رسالة.
الاختبارات تقرأ الملف، لا مخرَج الدالة
٤١ اختباراً، وأهمّها ما يفتح ملف الـPDF المُنتَج ويقرأ منه:
- معرّفات الرسوم تُردّ إلى محارفها عبر خريطة الخط، فيُتحقَّق أن
1,546.75طُبعت كما تُقرأ لا57.645,1 - ورمز QR يُعاد بناؤه من المربّعات المرسومة فعلاً على الصفحة، ثم يُفكّ
بـ
jsqr— قارئٌ مستقلّ لم نكتبه — ويُطابَق بمجاميع الفاتورة
وهذا هو الفرق العملي كله: حزمٌ منشورة تُخرج نصّاً صحيحاً من دوالّها ثم تُنتج فاتورةً خاطئة، لأن مكتبة الرسم تعكس المقاطع بعدها. فاختبارٌ يفحص القيمة المُعادة يمرّ، والعميل يتسلّم مبلغاً مقلوباً.
npm test
node examples/sample.mjs # فاتورة كاملة للنظر إليهاوما كشفته العينُ ولم يكشفه الاختبار
كانت الاختبارات كلها خضراء، والفاتورة المطبوعة تحمل:
(15% )150.00 ← والصواب 150.00 (15%)
(15)% ← والصواب (15%)
SAR 1,571.75 ← والصواب 1,571.75 SARثلاثتها ترتيبٌ صحيح بقواعد يونيكود لنصٍّ لم يُعزل: الأقواس وعلامة النسبة محايدة، تأخذ اتجاه ما حولها فتتفرّق على جانبَي الرقم. والخطأ خطؤنا — تركنا الجارَ يحكم على ما ليس منه.
والعلاج أداةُ يونيكود لهذا الغرض بعينه: العزل الاتجاهي. وهو الآن في
arabic-bidi-shaper بدالّة isolate()، وتستعمله هذه الحزمة في كل خليةٍ
رقمية. وصارت الثلاثة اختباراتِ انحدارٍ حتى لا تعود.
والدرس مُدوَّن: لا تكفي البوّابةُ الخضراء. من لم ينظر إلى صفحته لا يستطيع أن يشهد لها.
ما ليس هنا
- الوسوم 6-9 (هاش XML، توقيع ECDSA، المفتاح العام، توقيع الهيئة للفواتير المبسطة) تخصّ المرحلة الثانية وتلزم من ربطته الهيئة بمنصة «فاتورة». ولا تُبنى بلا شهادة ختمٍ حقيقية، ولن نشحن كوداً لم يُشغَّل عليها.
- الخطّ — يمرّره المستدعي. للخطوط رخصها، وحزمةٌ تحمل خطاً بلا داعٍ تُثقل من لا يحتاجه.
- حكمُ قبولٍ من الهيئة. هذه الحزمة تبني ملفاً؛ وحالة القبول تعيش في استجابة المنصة، ولا تملكها أداةٌ محلية.
القطع
| | |
| --- | --- |
| arabic-bidi-shaper | ترتيب النصّ العربي — bidi مطابق لـ٩١٬٧٠٧ حالة من يونيكود |
| eta-lib | التسلسل الكنسي والتجزئة لمنظومة مصر |
| مُتوافِق | التفقيط — ١٠٠٪ على مقياس التفقيط العربي |
الرخصة: MIT · جزء من منظومة الفوترة العربية
