التقنية المالية والمدفوعات الأدلة التقنية

الفوترة الإلكترونية فاتورة ZATCA: كيف تربط نظامك

بنينا فاتورة مبسطة بصيغة XML ورمز QR بلغة Python، وتحققنا منهما بأداة الهيئة، وحسبنا الهاش بأنفسنا وجربنا الأخطاء الشائعة.

الفوترة الإلكترونية فاتورة ZATCA: كيف تربط نظامك

لنفرض أن لديك شركة ناشئة تدير ستة مقاه صغيرة، وفريقها التقني كتب نظام نقاط البيع بنفسه قبل ثلاث سنوات: شاشة على جهاز لوحي، وطابعة إيصالات حرارية، وقاعدة بيانات في السحابة تجمع مبيعات الفروع. والنظام يطبع منذ 2021 فاتورة مبسطة عليها رمز QR، ويمسحه بعض الزبائن بتطبيق الهيئة فيظهر اسم المقهى والمبلغ والضريبة، وكان الجميع راضين. ثم وصل إشعار من هيئة الزكاة والضريبة والجمارك ZATCA بأن الشركة ضمن مجموعة مستهدفة في مرحلة الربط، وأمامها ستة أشهر. وفتح المهندس المسؤول الوثائق، فوجد كلمات لم يرها في كود الإيصال من قبل: UBL 2.1، وختم تشفيري Cryptographic Stamp، وهاش الفاتورة السابقة PIH، وعداد الفواتير ICV، وتوقيع XAdES، ورمز QR بتسعة حقول بدل خمسة.

وهذا المقال هو ما يحتاجه ذلك المهندس قبل أن يكتب سطراً: ما الذي تغير بين المرحلتين ولماذا، وكيف تبنى فاتورة مبسطة بصيغة XML كما يصفها معيار الهيئة، وكيف يبنى رمز QR بايتاً بايتاً، ولماذا يتعلق كل ذلك بسلسلة من الهاشات. ولن نشرح من الوثائق فقط، وإنما سوف نبني في المختبر فاتورة بلغة Python، ثم نتحقق منها بأداة التحقق الرسمية من الهيئة SDK، ونوقعها بشهادة الاختبار التي تأتي معها، ثم نفك رمز QR الذي كتبته الأداة ونتحقق من كل حقل فيه بأنفسنا، ونجرب أخطاء حقيقية واحداً واحداً.

سوف نناقش في هذا المقال:

  • ما هي الفوترة الإلكترونية فاتورة، والفرق بين الفاتورة الضريبية والمبسطة، وبين الاعتماد Clearance والإبلاغ Reporting.
  • الوثائق الرسمية التي تحتاجها، وأداة التحقق SDK وما تقوله الهيئة عن نتيجتها.
  • الفاتورة المبسطة بصيغة UBL من الداخل: الرأس والعداد والهاش السابق والبائع والإجماليات والبنود.
  • المختبر: بناء الفاتورة، وأول تحقق بخمسة أخطاء، وخطأ لا تتوقعه سببه الكسور العشرية في قواعد التحقق نفسها.
  • التوقيع والهاش: ما الذي يدخل في الهاش وما الذي يستثنى، وكيف حسبناه بأنفسنا وطابق الأداة.
  • رمز QR بصيغة TLV، وخطأ الطول بالحروف لا بالبايتات، وما وجدناه في الحقول 7 و8 و9.
  • سلسلة الهاش، والتعديل بعد التوقيع، وما يطلبه الدليل من النظام نفسه، وأخطاء شائعة وقائمة تحقق.

ما هي الفوترة الإلكترونية فاتورة؟

تعرف الهيئة في صفحة ما هي الفوترة الإلكترونية الفاتورة الإلكترونية بأنها فاتورة ضريبية تنشأ بصيغة إلكترونية منظمة Structured عبر وسيلة إلكترونية، وتقول صراحة إن الفاتورة الورقية التي تحول إلى صيغة إلكترونية بالنسخ أو المسح الضوئي ليست فاتورة إلكترونية. أي أن ملف PDF مطبوعاً من Word لا يكفي، والمقصود بيانات يقرؤها نظام آخر دون إنسان. وتفرق الصفحة نفسها بين نوعين:

  • الفاتورة الضريبية Tax Invoice: تصدر عادة من منشأة لمنشأة B2B، وتحتوي كل عناصر الفاتورة الضريبية، ومنها بيانات المشتري.
  • الفاتورة الضريبية المبسطة Simplified Tax Invoice: تصدر عادة من منشأة لمستهلك B2C، وهي إيصال المقهى في قصتنا، والمشتري فيها قد يبقى بلا اسم.

والتطبيق على مرحلتين بحسب الصفحة نفسها: مرحلة الإصدار Generation المطبقة منذ 4 ديسمبر 2021 على كل المكلفين المقيمين، وفيها تصدر الفاتورة من حل إلكتروني متوافق مع حقول إضافية، ومرحلة الربط والتكامل Integration التي بدأت من 1 يناير 2023 على موجات، وفيها يجب أن يربط الحل بأنظمة الهيئة وأن تصدر الفواتير بالصيغة المطلوبة. وقرار الهيئة بشأن ضوابط ومتطلبات تطبيق الفوترة ينص على أن الهيئة تحدد المجموعات المستهدفة ومراحل الربط، وتبلغ كل مجموعة بإجراءات الربط قبل موعدها بستة أشهر على الأقل، وهذا هو الإشعار الذي وصل المقهى.

لماذا تريد الهيئة بيانات منظمة لا ورقة؟

قد يتساءل صاحب المقهى: الإيصال الحراري كان يحمل كل ما يحتاجه الزبون، فما الذي يضيفه ملف XML؟ والإجابة في قائمة الفوائد التي تذكرها الإرشادات التفصيلية في فقرتها الأولى، ومنها تقليل تكلفة الفوترة والأخطاء البشرية، وتحسين المحاسبة ومسك الدفاتر، والحد من الاقتصاد الخفي والتستر التجاري بزيادة متطلبات تتبع الفواتير وحفظ بياناتها، وزيادة الالتزام الضريبي بالتحقق من المعاملات. وكلها تعود إلى فكرة واحدة: الورقة يقرؤها إنسان، فلا يمكن مطابقتها آلياً مع مليون ورقة أخرى، أما البيانات المنظمة فتطابق الهيئة فيها فاتورة البائع مع إقرار المشتري ومع إقرار البائع نفسه دون أن يفتح أحد ملفاً.

ومن جهة المهندس، يعني ذلك أن الفاتورة لم تعد «مستنداً يطبع»، وإنما رسالة بين نظامين، مثل رسائل البنوك التي شرحناها في رسائل البنوك: ISO 8583 وISO 20022: لها معيار بأسماء عناصر، ومخطط يتحقق من البنية، وقواعد عمل تتحقق من المعنى، والطرف الآخر يرفض ما لا يطابق. والفرق أن رسالة البنك تذهب لبنك آخر، ورسالة الفاتورة تذهب لجهة تملك سلطة فرض الغرامات، فالخطأ فيها أغلى.

الاعتماد أم الإبلاغ؟ نوع الفاتورة يحدد المسار

أهم فرق هندسي بين النوعين في المرحلة الثانية هو متى تصل الفاتورة إلى الهيئة، والصورة التالية تبينه:

نظام الفوترة EGS يولد XML ويوقعه، ومنه مساران: الفاتورة الضريبية B2B تذهب للهيئة أولاً للاعتماد Clearance ثم تسلم للمشتري بعد الاعتماد عبر منصة فاتورة FATOORA، والفاتورة المبسطة B2C تسلم للعميل فوراً بختم ورمز QR ثم ترسل للهيئة خلال 24 ساعة للإبلاغ Reporting، وسهم منحن يبين أن العميل يأخذ الفاتورة فوراً والهيئة تستلمها لاحقاً
في المبسطة لا ينتظر الزبون الهيئة، ولهذا يحتاج نظامك أن يختم الفاتورة بنفسه

وتشرح الإرشادات التفصيلية للفوترة الإلكترونية (الإصدار 2، مايو 2023) المسارين: في الاعتماد Clearance ترسل الفاتورة الضريبية للهيئة لتتحقق منها وتضع ختمها التشفيري، ثم تسلم للمشتري، وتنص صراحة على أن الاعتماد لا ينطبق على الفواتير المبسطة. أما الإبلاغ Reporting فهو مشاركة الفواتير المبسطة وإشعاراتها مع الهيئة بعد إصدارها، ويجب أن ترسل كلها إلى منصة فاتورة خلال 24 ساعة من إصدارها، بصيغة XML عبر الواجهات البرمجية API. وتقول الإرشادات أيضاً إن الفاتورة المبسطة في المرحلة الثانية يجب أن تختم بختم تشفيري باستخدام معرف الختم CSID الذي تصدره الهيئة، وأن تحمل رمز QR متوافقاً مع المرحلة الثانية بتسعة حقول بصيغة TLV مرمزة بـ base64.

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

الوثائق التي تحتاجها، وأداة SDK

قبل أي كود جمع المهندس الوثائق من صفحات الهيئة للمطورين، وكلها بتاريخ 19 مايو 2023:

الوثيقةما فيها
معيار تطبيق صيغة XML للفاتورة الإلكترونية، الإصدار 1.2العناصر وقواعد العمل BR وBR-KSA ورموز أنواع الفواتير
معايير تطبيق خصائص الأمان، الإصدار 1.2الختم التشفيري والهاش والهاش السابق ورمز QR
الإرشادات التفصيلية، الإصدار 2الاعتماد والإبلاغ والتخزين والوظائف الممنوعة
قرار الضوابط والمتطلباتالمراحل والمجموعات والالتزامات

وفي صفحة تنزيل SDK توفر الهيئة أداة سطر أوامر بلغة Java تتحقق من الفواتير والإشعارات، وتوقعها، وتحسب هاشها، وتنشئ رمز QR وطلبات الواجهة البرمجية. والملف الذي يشير إليه رابط التنزيل في هذه الصفحة هو zatca-envoice-sdk-203.zip بحجم 40.9 ميجابايت، وفيه الأداة بالإصدار 3.0.8 وتواريخ ملفاتها مايو 2022، ومعها مخططات UBL 2.1، وقواعد Schematron للمعيار الأوروبي EN 16931 ولقواعد المملكة بتاريخ 2021-08-19، وشهادة اختبار ومفتاحها الخاص. ولاحظ أن هذه القواعد أقدم من الإصدار 1.2 من المعيار، وسوف يظهر أثر ذلك في المختبر.

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

الفاتورة المبسطة من الداخل

الفاتورة ملف XML بمعيار UBL 2.1، وهو معيار مفتوح من OASIS لمستندات الأعمال، والهيئة لم تخترع صيغة جديدة، وإنما بنت على UBL وعلى قواعد المعيار الأوروبي EN 16931، ثم أضافت قواعدها الخاصة التي تبدأ بـ BR-KSA. والصورة التالية تبين أجزاء الفاتورة المبسطة بعد توقيعها:

أجزاء الفاتورة المبسطة من الأعلى: ext:UBLExtensions للتوقيع XAdES يضيفه التوقيع، ثم ProfileID وID وUUID وIssueDate وIssueTime لرأس الفاتورة والنوع 388 بالاسم 0200000، ثم AdditionalDocumentReference ICV لعداد الفواتير، ثم AdditionalDocumentReference PIH لهاش الفاتورة السابقة، ثم AdditionalDocumentReference QR لرمز QR بصيغة TLV يضيفه التوقيع، ثم cac:Signature لمرجع التوقيع، ثم AccountingSupplierParty للبائع بالرقم الضريبي والسجل والعنوان الوطني، ثم TaxTotal مرتين وLegalMonetaryTotal للضريبة والإجماليات، ثم InvoiceLine للبنود، والأجزاء الحمراء لا تدخل في الهاش والباقي يدخل بعد التوحيد C14N
ثلاثة أجزاء يضيفها التوقيع ولا تدخل في الهاش، والباقي هو الفاتورة نفسها
  • نوع الفاتورة: الرمز 388 للفاتورة الضريبية، والصفة name="0200000" تقول إنها مبسطة، فالخانتان الأوليان 02، كما في الجدول الذي يذكر أن الفاتورة الضريبية المبسطة رمزها 388 ونوعها الفرعي 02. والإشعار الدائن المبسط 381 والمدين 383 بنفس النوع الفرعي.
  • ProfileID: قيمته reporting:1.0، أي أن هذه الفاتورة تسلك مسار الإبلاغ.
  • UUID: معرف فريد لكل فاتورة إلى جانب رقمها الذي يراه الزبون.
  • ICV: عداد الفواتير Invoice Counter Value، رقم يزيد بواحد مع كل فاتورة يصدرها الجهاز، والإرشادات تطلب ألا يكون تصفيره متاحاً لأي مستخدم.
  • PIH: هاش الفاتورة السابقة Previous Invoice Hash، وهو ما يربط الفواتير في سلسلة كما سنرى.
  • البائع: رقم السجل التجاري بنوع CRN، والعنوان الوطني كاملاً، والرقم الضريبي من 15 خانة، والاسم القانوني بالعربية.
  • الإجماليات: عنصرا TaxTotal، الأول بتفصيل الضريبة بحسب الفئة والنسبة، والثاني بالمبلغ وحده، ثم LegalMonetaryTotal بالصافي والإجمالي مع الضريبة والمستحق.

المختبر: نبني الفاتورة بأنفسنا

كتبنا الفاتورة بمكتبة lxml في Python، لأن الهدف أن نفهم كل عنصر لا أن نعتمد على مكتبة تخفيه. والبيانات كلها أمثلة: الرقم الضريبي 399999999900003 ورقم السجل والعنوان ليست لمكلف حقيقي، والمقهى اسمه «مقهى المثال التجريبي». وهذا أهم جزء في الملف:

def build(number, icv, pih=FIRST_PIH, issue_date="2026-10-27", issue_time="10:15:00",
          seller="مقهى المثال التجريبي", lines=(("قهوة عربية دلة", 1, "45.00"), ("تمر خلاص 500 غرام", 2, "15.00"))):
    inv = etree.Element("Invoice", nsmap=NS)
    el(inv, CBC + "ProfileID", "reporting:1.0")
    el(inv, CBC + "ID", number)
    el(inv, CBC + "UUID", str(uuid.uuid5(uuid.NAMESPACE_URL, f"arabroot-lab/{number}")))
    el(inv, CBC + "IssueDate", issue_date)
    el(inv, CBC + "IssueTime", issue_time)
    el(inv, CBC + "InvoiceTypeCode", "388", name="0200000")          # 388 = tax invoice, 02 = simplified
    el(inv, CBC + "DocumentCurrencyCode", "SAR")
    el(inv, CBC + "TaxCurrencyCode", "SAR")
    r = el(inv, CAC + "AdditionalDocumentReference"); el(r, CBC + "ID", "ICV"); el(r, CBC + "UUID", icv)   # invoice counter
    r = el(inv, CAC + "AdditionalDocumentReference"); el(r, CBC + "ID", "PIH")                             # previous invoice hash
    el(el(r, CAC + "Attachment"), CBC + "EmbeddedDocumentBinaryObject", pih, mimeCode="text/plain")

    sp = el(el(inv, CAC + "AccountingSupplierParty"), CAC + "Party")
    el(el(sp, CAC + "PartyIdentification"), CBC + "ID", "1010010000", schemeID="CRN")
    a = el(sp, CAC + "PostalAddress")
    for tag, v in (("StreetName", "شارع التجربة"), ("BuildingNumber", "1234"), ("PlotIdentification", "5678"), ("CitySubdivisionName", "حي المثال"),
                   ("CityName", "الرياض"), ("PostalZone", "12345")):
        el(a, CBC + tag, v)

وبقية الملف يحسب الصافي والضريبة بالنوع Decimal مع التقريب إلى هللتين، ويكتب عنصري الضريبة والإجماليات والبنود. ولاحظ قيمة FIRST_PIH في رأس الملف: NWZlY2Vi...OQ==، وهي القيمة التي تحملها أول فاتورة في السلسلة لأنه لا توجد فاتورة قبلها، وهي نفس القيمة الموجودة في ملف pih.txt الذي يأتي مع الأداة، وفك ترميزها يعطي هاش SHA-256 للنص «0» مكتوباً بالأرقام الست عشرية.

وشغلنا Python في Container من python:3.13.17-slim، والأداة في Container ثان من eclipse-temurin:11.0.32_9-jre-noble لأنها تحتاج Java، والاثنان يتشاركان مجلد العمل، ومجلد الأداة مركب للقراءة فقط:

docker run -d --name zatca-java -v "$PWD/sdk/zatca-envoice-sdk-203:/sdk:ro" -v "$PWD/work:/work" -w /work \
  -e SDK_CONFIG=/work/config.json eclipse-temurin:11.0.32_9-jre-noble sleep infinity
docker run -d --name zatca-py -v "$PWD/work:/work" -w /work python:3.13.17-slim sleep infinity
docker exec zatca-py pip install -q lxml==6.0.2 cryptography==46.0.3 qrcode==8.2 pillow==12.0.0

والأداة تأتي بسكربت fatoora وسكربت تثبيت يحتاجان jq لكتابة ملف الإعدادات، فبدل ذلك كتبنا ملف config.json بمسارات الـ Container مباشرة، وسكربتاً صغيراً يشغل نفس أمر Java الذي يشغله fatoora، ويحذف رسائل الترحيب ورسائل التحذير من Java 11:

#!/bin/sh
# run the ZATCA SDK CLI (the same java command the fatoora script runs) and keep only its result lines
java -Djdk.sunec.disableNative=false -jar /sdk/Apps/cli-3.0.8-jar-with-dependencies.jar --globalVersion 3.0.8 -certpassword 123456789 "$@" 2>&1 \
  | grep -vE '^WARNING|^\*|^This SDK|^It (can|returns|checks)|^$' | sed -E 's/^[0-9-]+ [0-9:,]+ //'

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

أول تحقق: خمسة أخطاء

بنينا النسخة الأولى من الفاتورة، وكان فيها بند تمر بكميتين وسعر 18 ريالاً، ولم نضع الرقم الإضافي في العنوان، وتحققنا منها بالأمر sh sdk.sh -validate -invoice invoice-v1-unsigned.xml قبل أي توقيع، والمخرج سوف يكون كما يلي:

[INFO] ValidationProcessorImpl - [XSD] validation result : PASSED
[INFO] ValidationProcessorImpl - [EN] validation result : PASSED
[INFO] ValidationProcessorImpl - [KSA] validation result : FAILED
[ERROR] ValidationProcessorImpl - ksa validation errors :
[ERROR] ValidationProcessorImpl - CODE : BR-KSA-09, MESSAGE : [BR-KSA-09]-Seller address must contain additional number (KSA-23), street name (BT-35), building number (KSA-17), postal code (BT-38), city (BT-37), Neighborhood (KSA-3), country code (BT-40). ...
[ERROR] ValidationProcessorImpl - CODE : BR-KSA-60, MESSAGE : [BR-KSA-60]-Cryptographic stamp (KSA-15) must exist in simplified tax invoices and associated credit notes and debit notes (KSA-2, position 1 and 2 = 02).
[ERROR] ValidationProcessorImpl - CODE : BR-KSA-50, MESSAGE : [BR-KSA-50]-The line VAT amount (KSA-11) must be Invoice line net amount (BT-131) x (Line VAT rate (BT-152)/100).
[ERROR] ValidationProcessorImpl - CODE : BR-KSA-64, MESSAGE : [BR-KSA-64]-Seller Address Additional number (KSA-23) must be 4 digits.
[ERROR] ValidationProcessorImpl - CODE : BR-KSA-27, MESSAGE : [BR-KSA-27]-The document must contain aa QR code (KSA-14), and this code must be base64Binary. ...
[INFO] InvoiceValidationService -  *** GLOBAL VALIDATION RESULT = FAILED

في المخرج أعلاه لاحظ التالي:

  • ثلاث طبقات بالترتيب: XSD يتحقق من البنية، أي أن العناصر في أماكنها وبأنواعها، ومرت. ثم EN يطبق قواعد المعيار الأوروبي، مثل أن مجموع البنود يساوي الصافي، ومرت. ثم KSA يطبق قواعد المملكة، وفيها كل الأخطاء.
  • BR-KSA-09 وBR-KSA-64: العنوان الوطني يحتاج الرقم الإضافي Additional Number، وهو في UBL العنصر PlotIdentification بأربعة أرقام. وهذا خطأ يقع فيه كل من ينقل عنوان البائع من نظام لا يعرف العنوان الوطني.
  • BR-KSA-60 وBR-KSA-27: الفاتورة المبسطة يجب أن تحمل ختماً تشفيرياً ورمز QR، وهذا متوقع لأننا لم نوقع بعد، ويؤكد أن المبسطة غير المختومة ليست فاتورة صالحة في هذه المرحلة.
  • BR-KSA-50: ضريبة البند يجب أن تساوي صافي البند في نسبة الضريبة، وهذا ما فعلناه بالضبط: 36.00 في 15% تساوي 5.40. فلماذا الخطأ؟

خطأ لم نرتكبه: 36 في 0.15 لا تساوي 5.40

فتحنا ملف القواعد 20210819_ZATCA_E-invoice_Validation_Rules.xsl وبحثنا عن القاعدة، فوجدنا الشرط كما هو:

test="not((cbc:TaxAmount) = (../cbc:LineExtensionAmount) * (../cac:Item/cac:ClassifiedTaxCategory/cbc:Percent div 100))"

الشرط يضرب المبلغ في 15 مقسومة على 100، ويقارن الناتج بالضريبة المكتوبة بالمساواة التامة، والأرقام هنا تقرأ من النص كأعداد عشرية مزدوجة Double، أي بنفس طريقة الكسور العشرية في المعالج التي شرحناها في الأرقام العشرية Floating Point من الداخل. والرقم 0.15 لا يمثل في النظام الثنائي بدقة، فجربنا الحساب نفسه في Python:

45 6.75 True
36 5.3999999999999995 False
40 6.0 True
12 1.7999999999999998 False

أي أن 45 في 0.15 تعطي 6.75 تماماً فيمر البند الأول، و36 في 0.15 تعطي 5.3999999999999995 فلا تساوي 5.40 ويفشل البند الثاني، رغم أن الضريبة صحيحة إلى الهللة. وللتأكد أن هذا هو السبب وحده، بنينا فاتورة ثانية لا يختلف فيها إلا هذا البند، ووقعناها حتى تختفي أخطاء الختم، فبقي خطأ واحد فقط:

== b. a line of 2 x 18.00: VAT 5.40
[INFO] ValidationProcessorImpl - [XSD] validation result : PASSED
[INFO] ValidationProcessorImpl - [EN] validation result : PASSED
[INFO] ValidationProcessorImpl - [KSA] validation result : FAILED
[ERROR] ValidationProcessorImpl - CODE : BR-KSA-50, MESSAGE : [BR-KSA-50]-The line VAT amount (KSA-11) must be Invoice line net amount (BT-131) x (Line VAT rate (BT-152)/100).

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

بعد الإصلاح والتوقيع: كل الطبقات تمر

أضفنا الرقم الإضافي للعنوان، وغيرنا البند، ثم وقعنا الفاتورة بالأداة نفسها، وهي توقعها بشهادة الاختبار التي تأتي معها، ثم تحققنا من الملف الموقع:

sh sdk.sh -sign -invoice invoice-unsigned.xml -signedInvoice invoice-signed.xml
sh sdk.sh -validate -invoice invoice-signed.xml
[INFO] InvoiceSigningService - invoice has been signed successfully
[INFO] InvoiceSigningService -  *** INVOICE HASH = kvihr8CX4oLlHSfNBFg2PetotT9CGkNkbOGixrwz3y4=

[INFO] ValidationProcessorImpl - [XSD] validation result : PASSED
[INFO] ValidationProcessorImpl - [EN] validation result : PASSED
[INFO] ValidationProcessorImpl - [KSA] validation result : PASSED
[INFO] ValidationProcessorImpl - [QR] validation result : PASSED
[INFO] ValidationProcessorImpl - [SIGNATURE] validation result : PASSED
[INFO] ValidationProcessorImpl - [PIH] validation result : PASSED
[INFO] InvoiceValidationService -  *** GLOBAL VALIDATION RESULT = PASSED

ظهرت ثلاث طبقات جديدة بعد التوقيع: QR يتحقق أن الرمز يطابق الملف، وSIGNATURE يتحقق من التوقيع، وPIH يتحقق أن هاش الفاتورة السابقة هو المتوقع. والسطر المهم في التوقيع هو هاش الفاتورة kvihr8CX...، فهو بصمة الفاتورة كلها، وسوف نحسبه الآن بأنفسنا.

الهاش: ما الذي يدخل فيه بالضبط؟

تقول معايير تطبيق خصائص الأمان في الإصدار 1.2 إن التوقيع في ملفات XML يتبع XAdES، وإن الهاش يحسب بـ SHA-256 بعد تحويلات Transforms محددة: حذف ext:UBLExtensions الذي يحمل التوقيع نفسه، وحذف cac:Signature، وحذف مرجع رمز QR، ثم توحيد النص Canonicalization بطريقة C14N 1.1، وهي طريقة تكتب XML بشكل واحد ثابت، فترتيب الصفات وشكل علامات الاقتباس لا يغير الهاش. والسبب في الحذف منطقي: التوقيع والرمز يحتويان الهاش، فلا يمكن أن يدخلا في حسابه. وكتبنا الدالة كما تصفها الوثيقة:

def keep_tail(e):
    """lxml drops the text after an element (its tail) with the element; the XPath transform keeps that text"""
    if e.tail:
        prev = e.getprevious()
        if prev is not None:
            prev.tail = (prev.tail or "") + e.tail
        else:
            e.getparent().text = (e.getparent().text or "") + e.tail

def invoice_hash(doc):
    """the transform of the Security Features standard: drop UBLExtensions, Signature and the QR reference, canonicalise, SHA-256"""
    d = etree.fromstring(etree.tostring(doc))
    for path in ("//ext:UBLExtensions", "//cac:Signature", "//cac:AdditionalDocumentReference[cbc:ID='QR']"):
        for e in d.xpath(path, namespaces=N):
            keep_tail(e)
            e.getparent().remove(e)
    c14n = etree.tostring(d, method="c14n")
    return hashlib.sha256(c14n).digest(), c14n

والدالة keep_tail لم تكن في النسخة الأولى، وقصتها درس يستحق أن تعرفه. ففي التشغيل الأول خرج هاشنا Y+KeGcxz... وهاش الأداة kvihr8CX...، أي أنهما مختلفان تماماً. فحسبنا الهاش من الملف غير الموقع، فطابق الأداة، فعرفنا أن الخطأ في الحذف لا في الحساب. وقارنا النصين الموحدين سطراً سطراً، فوجدنا الفرق في المسافات فقط: قبل ProfileID سطر جديد ومسافتان في نسخة، وليس في الأخرى. والسبب أن مكتبة lxml عندما تحذف عنصراً تحذف معه النص الذي يأتي بعده، أي المسافات والسطر الجديد، بينما تحويل XPath في المعيار يحذف العنصر ويبقي النص الذي حوله. ومسافة واحدة في ملف من خمسة آلاف بايت تغير الهاش كله، وهذه طبيعة دوال الهاش. وبعد الإصلاح طابق الهاش، كما في الفقرة التالية.

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

رمز QR من الداخل: TLV

تصف الفقرة 4 من معايير خصائص الأمان رمز QR بأنه حقول بصيغة Tag-Length-Value واختصاراً TLV، مرمزة بـ base64، وطولها حتى 700 حرف: لكل حقل بايت للرقم Tag، ثم بايت للطول، والطول هو عدد بايتات القيمة بعد ترميزها UTF-8، ثم القيمة نفسها. والحقول من 1 إلى 5 مطبقة من 4 ديسمبر 2021: اسم البائع، ورقمه الضريبي، ووقت الفاتورة بصيغة ISO 8601 مثل 2022-02-21T12:13:57Z، والإجمالي مع الضريبة، ومجموع الضريبة. ومن 1 يناير 2023 تضاف الحقول من 6 إلى 9: هاش الفاتورة، وتوقيع ECDSA للهاش، والمفتاح العام، وللفواتير المبسطة توقيع الهيئة على شهادة الختم. والصورة التالية تبين أول حقلين بالبايتات الحقيقية من المختبر:

بايتات رمز QR من اليمين: 01 للرقم Tag، ثم 26 للطول أي 38، ثم d9 85 d9 82 للقيمة بترميز UTF-8، ثم 02 للرقم Tag، ثم 0f أي 15، ثم 33 39 39 للرقم الضريبي 399999999900003، وتحتها الخطأ الشائع: الطول بعدد الحروف len(name) يساوي 20 بدل البايتات 38، فيقرأ القارئ 20 بايتاً فينقطع الاسم في منتصف حرف «مقهى المثا�»، ثم يقرأ بايتاً عربياً كأنه Tag فيخرج tag 132 بطول 32 ثم بيانات تتجاوز النهاية
الطول بالبايتات، والاسم العربي يأخذ ضعف عدد حروفه تقريباً

وبنينا الحقول الخمسة الأولى بدالة من أربعة أسطر:

def tlv(fields):
    out = b""
    for tag, value in fields:
        v = value if isinstance(value, bytes) else str(value).encode("utf-8")
        out += bytes([tag, len(v)]) + v                 # tag in one byte, length of the UTF-8 bytes in one byte
    return out

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

== 1. tags 1 to 5 built by hand
  seller name: 20 characters, 38 UTF-8 bytes
  first bytes: 01 26 d9 85 d9 82 d9 87 d9 89 20 d8
  base64: ASbZhdmC2YfZiSDYp9mE2YXYq9in2YQg2KfZhNiq2KzYsdmK2KjZigIPMzk5OTk5OTk5OTAwMDAzAxQyMDI2LTEwLTI3VDEwOjE1OjAwWgQFODYuMjUFBTExLjI1 (124 chars)
== 2. the QR the SDK wrote into the signed invoice
  base64 length 396 chars (the standard allows up to 700), 9 tags
  tag 1 seller name       38 bytes  مقهى المثال التجريبي
  tag 2 VAT number        15 bytes  399999999900003
  tag 3 timestamp         20 bytes  2026-10-27T10:15:00Z
  tag 4 total with VAT     5 bytes  86.25
  tag 5 VAT total          5 bytes  11.25
  tag 6 invoice hash      44 bytes  kvihr8CX4oLlHSfNBFg2PetotT9CGkNkbOGixrwz3y4=
  tag 7                   88 bytes  3056301006072a8648ce3d02...
  tag 8                   32 bytes  25b3a0b8169ddf5d5d284f6d...
  tag 9                   32 bytes  4785739173f752f0c586a568...
  tags 1-5 identical to ours: True
== 3. checking tag 6 ourselves
  canonical XML hashed: 4978 bytes
  our SHA-256 (base64): kvihr8CX4oLlHSfNBFg2PetotT9CGkNkbOGixrwz3y4=
  tag 6 equals base64 of our hash: True

في المخرج أعلاه لاحظ التالي:

  • الاسم 20 حرفاً و38 بايتاً: كل حرف عربي بايتان في UTF-8، والمسافات بايت واحد، ولهذا الطول في البايت الثاني 26، أي 38 بالست عشري. وتذكر أن الطول بايت واحد، أي 255 بايتاً على الأكثر لكل حقل، وهذا يكفي اسماً عربياً بحوالي 125 حرفاً فقط.
  • الحقول الخمسة متطابقة: ولكن بعد إصلاح واحد، ففي التشغيل الأول كتبنا الوقت دون حرف Z في آخره، فلم تتطابق، والأداة تكتبه بالـ Z كما في مثال المعيار، أي بتوقيت UTC. وهذا يعني أن وقت الفاتورة في الرمز يجب أن يكون بتوقيت UTC، فإذا كتبت فيه الساعة بتوقيت السعودية ثم أضفت Z فأنت تقول إن الفاتورة صدرت قبل ثلاث ساعات من وقتها الحقيقي.
  • الحقل 6 هو الهاش نفسه: 44 بايتاً هي نص base64 للهاش، وطابق ما حسبناه من 4,978 بايتاً من XML الموحد، أي أننا فهمنا التحويلات كما تفهمها الأداة.
  • الرمز 396 حرفاً: تحت حد 700 حرف، ولكنه أكبر بثلاث مرات من رمز المرحلة الأولى بخمسة حقول، 124 حرفاً. وهذا مهم لطابعة الإيصالات كما سنرى.

ماذا وجدنا في الحقول 7 و8 و9؟

بحسب جدول الحقول في الإصدار 1.2 من المعيار، الحقل 7 هو توقيع ECDSA للهاش، والحقل 8 المفتاح العام، والحقل 9 توقيع الهيئة على شهادة الختم. ولكن الأطوال في المخرج لا تشبه ذلك، فالحقل 7 طوله 88 بايتاً ويبدأ بـ 3056301006072a8648ce3d02، وهي بداية مفتاح عام بصيغة DER. فتحققنا من كل حقل بأنفسنا بمكتبة cryptography:

== 4. what tags 7, 8 and 9 hold in this SDK version
  tag 7 is the certificate's public key (DER): True | curve secp256k1
  test certificate: CN=TST--970560040-312345678900003,OU=TST,O=3123456789,C=SA | valid until 2024-04-18
  tags 8 and 9 as (r, s) verify an ECDSA-SHA256 signature over the invoice hash: True

أي أن هذا الإصدار من الأداة كتب في الحقل 7 المفتاح العام للشهادة، وفي الحقلين 8 و9 جزأي التوقيع r وs كل منهما 32 بايتاً، والتوقيع صحيح بالمفتاح على منحنى secp256k1 فوق بايتات الهاش. وهذا يختلف عن ترتيب الجدول في المعيار الحالي، ولا نعرف هل هو ترتيب إصدار سابق من المعيار أم خطأ في الأداة، لأن الأداة أقدم من الإصدار 1.2. وفي تشغيل آخر للتوقيع على نفس الفاتورة خرج الحقل 9 بطول 33 بايتاً يبدأ بالبايت 00، وهو سلوك معروف في Java عند تحويل عدد كبير موجب إلى بايتات، وخرج التوقيع مختلفاً عن الأول، وهذا طبيعي في ECDSA لأنه يستخدم رقماً عشوائياً في كل توقيع، فلا تتوقع أن يعطيك توقيع نفس الفاتورة مرتين نفس الرمز. ولاحظ أيضاً أن شهادة الاختبار انتهت في 18 أبريل 2024، ومع ذلك مرت طبقة SIGNATURE في تحققنا، أي أن انتهاء الشهادة لم يمنع نجاح هذه الطبقة في هذه الأداة، أما المنصة فتتحقق من الشهادات بحسب المعيار.

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

والصورة التالية هي الرمز نفسه كما رسمته مكتبة qrcode من النص الذي كتبته الأداة:

رمز QR مربع بالأبيض والأسود من الإصدار 15 بـ 77 في 77 مربعاً، يحمل 396 حرفاً بترميز base64 للحقول التسعة للفاتورة المبسطة التجريبية
الإصدار 15 من QR بـ 77 مربعاً في كل ضلع لـ 396 حرفاً
QR version 15 | modules 77 x 77 | characters 396

ورمز الحقول الخمسة الأولى الذي بنيناه بأيدينا، بـ 124 حرفاً، يخرج بنفس المكتبة والإعداد من الإصدار 8 بـ 49 مربعاً في الضلع، أما هذا فـ 77 مربعاً، وعلى طابعة حرارية بعرض 58 مليمتراً يصبح كل مربع صغيراً جداً. لذلك اطبع رمزاً تجريبياً على طابعة كل فرع وامسحه بتطبيق الهيئة وبعدة جوالات قبل أن تنشر النظام، لأن الرمز الذي لا يقرأ في الإيصال هو مشكلة لا تظهر في أي تحقق آلي.

ماذا لو؟ أخطاء نرتكبها عن قصد

الطول بعدد الحروف

أكثر خطأ يقع فيه من يكتب TLV لأول مرة هو len(name) بدل len(name.encode())، فالنتيجة واحدة مع الأسماء الإنجليزية، ولا يظهر الخطأ إلا مع اسم عربي. فكتبنا الحقل الأول بطول 20، أي عدد الحروف، ثم قرأناه كما يقرؤه أي قارئ:

== a. TLV length counted in characters instead of UTF-8 bytes
  byte 0: tag 1, length 20: 'مقهى المثا�'
  byte 22: tag 132, length 32: 'التجريبي\x02\x0f39999999990000'
  byte 56: tag 51, length None: runs past the end of the data (57 bytes)

القارئ أخذ 20 بايتاً، فانقطع الاسم في منتصف حرف، ثم قرأ البايت التالي، وهو نصف حرف عربي، كأنه رقم حقل 132، ثم ابتلع الرقم الضريبي داخل قيمة ذلك الحقل، ثم تجاوز نهاية البيانات. ولو كان الاسم إنجليزياً لما رأيت شيئاً، ولذلك ضع في اختباراتك دائماً اسماً عربياً لبائع، ويفضل أن يكون طويلاً.

تعديل المبلغ بعد التوقيع

ثم غيرنا في الفاتورة الموقعة المبلغ المستحق PayableAmount من 86.25 إلى 8.63، كأن أحداً عدل الملف بعد إصداره، وتحققنا:

== c. PayableAmount changed after signing
[INFO] ValidationProcessorImpl - [XSD] validation result : PASSED
[INFO] ValidationProcessorImpl - [EN] validation result : PASSED
[INFO] ValidationProcessorImpl - [KSA] validation result : PASSED
[INFO] ValidationProcessorImpl - [QR] validation result : FAILED
[ERROR] ValidationProcessorImpl - qr validation errors :
[ERROR] ValidationProcessorImpl - CODE : hashedXml, MESSAGE : hashedXml does not match with qr code hashedXml

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

سلسلة الهاش PIH

الحقل PIH في كل فاتورة يحمل هاش الفاتورة التي قبلها، فتتكون سلسلة تبينها الصورة التالية بالهاشات الحقيقية من المختبر:

سلسلة من اليمين: قبل أول فاتورة PIH يساوي base64 لـ sha256 للنص 0 أي NWZlY2Vi وهي قيمة ثابتة في المواصفة، ثم الفاتورة 1 بالعداد ICV 1 وPIH NWZlY2Vi وهاشها kvihr8CX4oLl، ثم الفاتورة 2 بالعداد ICV 2 وPIH kvihr8CX4oLl وهاشها oZ0yyvl4I8PH، ثم الفاتورة 3 وPIH oZ0yyvl4، وتحتها أن حذف فاتورة من الوسط أو تعديلها يقطع السلسلة بعدها، وأن الفاتورة 2 نجحت مقابل هاش الفاتورة 1 والفاتورة 1 فشلت مقابله برسالة PIH is inValid
كل فاتورة تشهد على التي قبلها

وبنينا الفاتورة الثانية بعداد 2 وبهاش الأولى في PIH، والأداة تقارن PIH بالقيمة في الملف المحدد في الإعداد pihPath، فوضعنا فيه هاش الأولى، ثم تحققنا من الثانية، ثم من الأولى مقابل نفس الملف:

== d. the second invoice, chained to the first (pihPath = /work/pih.txt)
[INFO] InvoiceSigningService -  *** INVOICE HASH = oZ0yyvl4I8PHBGguHAnO1a8mCQ0IrWYdnMQXIatSoeo=
[INFO] ValidationProcessorImpl - [PIH] validation result : PASSED
[INFO] InvoiceValidationService -  *** GLOBAL VALIDATION RESULT = PASSED
== e. the first invoice validated against the same pih.txt
[INFO] ValidationProcessorImpl - [PIH] validation result : FAILED
[ERROR] ValidationProcessorImpl - CODE : KSA-13, MESSAGE : PIH is inValid
[INFO] InvoiceValidationService -  *** GLOBAL VALIDATION RESULT = FAILED

والسلسلة هي ما يجعل حذف فاتورة صامتاً أمراً مستحيلاً: لو أصدر المقهى الفواتير 1 و2 و3 ثم حذف 2 من سجلاته، فالفاتورة 3 تحمل هاش 2، والهيئة تستلم 3 دون أن تجد 2، والعداد قفز من 1 إلى 3. ولهذا السبب نفسه يجب أن يكون لكل جهاز يصدر الفواتير، أي لكل وحدة EGS، عداده وسلسلته، وأن يحفظ آخر هاش في مكان لا يضيع إذا أعيد تشغيل الجهاز. والسؤال الذي واجهه فريق المقهى: إذا كانت في الفرع ثلاثة أجهزة لوحية، هل تكون سلسلة واحدة؟ والإجابة أن السلسلة الواحدة بين ثلاثة أجهزة تحتاج تنسيقاً في كل فاتورة، وأي انقطاع في الشبكة يوقف البيع، ولهذا الأنسب أن يكون كل جهاز وحدة مستقلة بعدادها وسلسلتها وشهادتها، كما يفترض دليل بوابة المطورين حين يتحدث عن وحدات EGS التي تسجل وتختبر كل منها.

ست طبقات تحقق في أداة SDK وماذا تفحص وما الذي أفشلها في المختبر: XSD لبنية UBL 2.1 ومرت في كل التجارب، وEN 16931 لقواعد الفاتورة الأوروبية ومرت في كل التجارب، وKSA لقواعد المملكة من العنوان الوطني والختم والضريبة وأفشلتها BR-KSA-09 و50 و60 و64 و27، وQR لمطابقة الهاش في الرمز لهاش الملف وأفشلها تعديل المبلغ بعد التوقيع، وSIGNATURE لصحة التوقيع بالمفتاح العام ومرت، وPIH لهاش الفاتورة السابقة وأفشلها التحقق من الفاتورة 1 مقابل سلسلة الفاتورة 2
كل طبقة تمسك نوعاً مختلفاً من الأخطاء

التسجيل Onboarding: من أين يأتي مفتاح الختم؟

الختم التشفيري يحتاج مفتاحاً خاصاً وشهادة تصدرها الهيئة، وكل وحدة تصدر فواتير، أي كل وحدة EGS، تمر بتسجيل خاص بها. ويصف دليل بوابة المطورين خطوات هذا التسجيل في بيئة الاختبار: ترسل الوحدة طلب توقيع شهادة Certificate Signing Request واختصاراً CSR فتحصل على معرف ختم للامتثال Compliance CSID، ثم تجتاز فحوص الامتثال بإرسال مستندات اختبار، ثم تطلب معرف ختم الإنتاج Production CSID الذي تختم به الفواتير الحقيقية. وتحدد معايير خصائص الأمان ما يجب أن يحمله طلب الشهادة في حقوله، ومنها:

الحقلما يحملهفي مثال الأداة
الرقم التسلسلي للوحدة EGS Serial Numberبالصيغة 1-اسم المصنع أو المزود|2-الطراز أو الإصدار|3-الرقم التسلسلي1-TST|2-TST|3-ed22f1d8-...
معرف المنشأة Organization Identifierالرقم الضريبي من 15 خانة يبدأ بـ 3 وينتهي بـ 3312345678900003
الوحدة التنظيمية Organization Unitاسم الفرع، أو رقم العضو في المجموعة الضريبية3123456789
نوع الفواتير Invoice Typeأربع خانات لـ TSCZ، و1100 تعني فواتير ضريبية ومبسطة1111

وفي القصة يعني ذلك أن كل جهاز لوحي في كل فرع يولد مفتاحه بنفسه عند تفعيله، ويرسل طلب الشهادة، ويحفظ الشهادة ومعرف الختم، ولا يخرج مفتاحه من الجهاز أبداً. وفي حقل نوع الفواتير يضع المقهى 0100، لأن أجهزته تصدر فواتير مبسطة فقط. ولاحظ أن الرقم الضريبي في مثالنا 399999999900003 يبدأ بـ 3 وينتهي بـ 3 مثل الصيغة المطلوبة، وهو مع ذلك رقم مثال. وتذكر معايير خصائص الأمان في فقرتها الأخيرة أن الواجهات البرمجية للهيئة تستخدم المصادقة الأساسية في OAuth 2، ومعرف العميل Client ID هو الشهادة الصادرة في التسجيل، والسر Secret يصدر معها، ويجب أن يحفظ بأمان ولا يكشف لأحد، فهو في حكم كلمة المرور لهوية الجهاز.

كيف تلغي فاتورة؟ الإشعار الدائن

في أسبوع التجربة الأول طلب زبون استرداد مبلغ قهوة طلبها بالخطأ، وكان النظام القديم يحذف الفاتورة ويصدر أخرى. وهذا ممنوع الآن كما رأينا، والطريقة الصحيحة إشعار دائن Credit Note، وهو نفس بنية الفاتورة برمز 381 بدل 388، وبنفس الاسم 0200000 لأنه إشعار مبسط. ويطلب معيار XML فيه شيئين إضافيين: القاعدة BR-KSA-56 تجعل مرجع الفاتورة الأصلية في cac:BillingReference إلزامياً، والقاعدة BR-KSA-17 تطلب سبب إصدار الإشعار في cbc:InstructionNote داخل cac:PaymentMeans. والإشعار يأخذ العداد التالي، ويحمل في PIH هاش آخر مستند صدر من الجهاز، أي أنه حلقة في نفس السلسلة، ويبلغ عنه خلال 24 ساعة مثل الفاتورة. والنتيجة أن تاريخ كل بيع يبقى كاملاً: الفاتورة الأصلية، ثم الإشعار الذي ألغاها بسببه، ثم الفاتورة الصحيحة إذا صدرت.

ما يطلبه الدليل من النظام نفسه

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

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

وكل بند من هذه قرار هندسي في نظام المقهى. فالعداد وآخر هاش صارا في جدول لا يقبل إلا الإضافة، والوقت يأخذه النظام من مصدر متزامن لا من الجهاز اللوحي الذي قد يغير موظف تاريخه، ومفتاح الختم لكل جهاز في مخزن المفاتيح الآمن في نظام التشغيل لا في ملف بجانب التطبيق، وزر «حذف الفاتورة» اختفى، وحل محله «إصدار إشعار دائن». وهذه نفس فكرة دفتر القيود الذي لا يحذف منه سطر، التي شرحناها في المحفظة الرقمية من الداخل.

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

كيف رتب الفريق المسار في النظام؟

بعد المختبر كتب الفريق المسار الذي يمر به كل بيع في المقهى، بالترتيب:

  1. البيع: يحسب المبلغ والضريبة بأعداد صحيحة بالهللة أو بالنوع Decimal، لا بالكسور العشرية، كما شرحنا في مقال الأرقام العشرية.
  2. بناء XML: من البيانات مباشرة بمكتبة XML لا بقالب نصي، مع العداد التالي وآخر هاش من جدول الجهاز.
  3. الهاش والختم: على الجهاز نفسه بمفتاحه، ثم بناء رمز QR بالحقول التسعة.
  4. الحفظ قبل الطباعة: الملف الموقع يحفظ ويحدث العداد وآخر هاش في معاملة واحدة، ثم تطبع الفاتورة. والسبب أن الطباعة ثم الفشل في الحفظ تعني فاتورة في يد الزبون لا يعرفها النظام، وثغرة في السلسلة.
  5. الإبلاغ: طابور يرسل الفواتير للمنصة بالترتيب، ويعيد المحاولة، وينبه إذا اقترب عمر أي فاتورة من 24 ساعة.
  6. الرد: رد المنصة يحفظ مع الفاتورة، والرفض لا يعدل الفاتورة، وإنما يصحح بإشعار وفاتورة جديدة.

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

اختبار التوافق في خط البناء

أهم ما خرج به الفريق من المختبر ليس الفاتورة نفسها، وإنما طريقة اختبارها. فكل خطأ وجدناه في هذا المقال، من الرقم الإضافي في العنوان إلى المسافات في الهاش إلى الطول بالحروف، لم يكن ليظهر في اختبار يدوي على فاتورة واحدة بأسماء إنجليزية. لذلك أضاف الفريق الأداة نفسها إلى خط البناء CI: Container من Image الـ Java الصغيرة، ومجلد فيه فواتير اختبار يولدها كود النظام نفسه، وسكربت مثل run_all.sh في المختبر يوقع كل فاتورة ويتحقق منها، ويفشل البناء إذا ظهر FAILED في أي طبقة. والفواتير التي يولدها الاختبار ليست عشوائية، وإنما حالات تتعطل عندها الأنظمة عادة:

  • بائع باسم عربي طويل قريب من حد البايت الواحد في الطول، وبنود بأسماء عربية.
  • كميات وأسعار تعطي ضرائب بكسور، مثل 36 ريالاً، لأننا رأينا ما تفعله الكسور العشرية بقاعدة واحدة على الأقل.
  • فاتورة في الساعة 11:50 مساءً بتوقيت السعودية، لأن تاريخها بتوقيت UTC قد يكون يوماً آخر، فيجب أن يتفق التاريخ في الملف والوقت في الرمز مع ما يراه الزبون.
  • إشعار دائن لفاتورة سابقة، بمرجعها وسببه، في وسط السلسلة.
  • عشر فواتير متتالية من نفس الجهاز، يتحقق الاختبار أن كل PIH فيها هو هاش التي قبلها وأن العداد لا يقفز.

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

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

أخطاء شائعة

الخطأكيف يظهرالبديل
طول TLV بعدد الحروفالاسم العربي يقطع وتختل الحقول بعدهlen(value.encode("utf-8"))
الوقت المحلي مع Zوقت الفاتورة في الرمز متقدم ثلاث ساعاتتحويل الوقت إلى UTC قبل كتابته
إعادة تنسيق XML بعد التوقيعالهاش لا يطابق الرمز ولا التوقيعالحفظ والإرسال بالبايتات كما وقعت
حذف العناصر دون النص الذي بعدهاهاشك يختلف عن هاش الأداةتحويل يبقي النص كما يصفه المعيار
سلسلة واحدة لعدة أجهزةتعارض في العداد عند العمل دون اتصالعداد وسلسلة وشهادة لكل وحدة
الطباعة قبل الحفظفاتورة لدى الزبون لا يعرفها النظامالحفظ والعداد في معاملة ثم الطباعة
المبالغ بالكسور العشريةهللات ناقصة أو زائدة في الإجمالياتDecimal أو أعداد صحيحة بالهللة
مفتاح الختم في ملفمن ينسخه يصدر فواتير باسمكمخزن مفاتيح لا يسمح بالتصدير
الاعتماد على أداة واحدةسلوك نسخة قديمة يبنى عليه النظامالمعيار الحالي ثم بيئة الاختبار في البوابة

قائمة تحقق قبل الربط

البندكيف تتحقق منه
كل فاتورة تمر بطبقات XSD وEN وKSAالتحقق في خط البناء CI على فواتير اختبار متنوعة
الهاش تحسبه بنفسك ويطابق الأداةاختبار يقارن الهاشين لكل نوع فاتورة
رمز QR بأسماء عربية طويلةفك الرمز وقراءة كل حقل بطوله
الوقت في الرمز بتوقيت UTCفاتورة في منتصف الليل بتوقيت السعودية تظهر بتاريخ صحيح
العداد والسلسلة لكل جهازإعادة تشغيل الجهاز لا تغير العداد ولا آخر هاش
لا حذف ولا تعديل للفواتيرالإلغاء بإشعار دائن فقط
طابور الإبلاغ يعمل دون اتصالتنبيه قبل أن تبلغ أقدم فاتورة 24 ساعة
المفتاح لا يغادر الجهازلا يوجد مسار في النظام يقرأ المفتاح الخاص
الرمز يقرأ على طابعة كل فرعمسح فعلي بتطبيق الهيئة وعدة جوالات
الاختبار في بيئة البوابة قبل الإنتاجتسجيل وإرسال فواتير اختبار والردود محفوظة

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • الفاتورة الإلكترونية بيانات منظمة لا ملف ممسوح، والمرحلة الثانية منذ 2023 تربط الأنظمة بالهيئة: الفاتورة الضريبية تعتمد قبل التسليم، والمبسطة تختم وتسلم فوراً ثم يبلغ عنها خلال 24 ساعة.
  • الفاتورة المبسطة UBL 2.1 بنوع 388 واسم 0200000، وفيها عداد وهاش سابق وعنوان وطني كامل بالرقم الإضافي، وأداة SDK تتحقق منها في ست طبقات.
  • في المختبر فشل بند صحيح ضريبته 5.40 في القاعدة BR-KSA-50 لأن القاعدة في هذه النسخة تقارن ناتج 36 في 0.15 بالمساواة التامة، فاقرأ القاعدة قبل أن تغير حسابك.
  • الهاش SHA-256 للـ XML الموحد بعد حذف التوقيع والرمز، وحسبناه بأنفسنا فطابق الأداة kvihr8CX... بعد أن أصلحنا حذف المسافات، وأي تعديل بعد التوقيع ظهر في طبقة QR.
  • رمز QR حقول TLV طولها بالبايتات، والاسم العربي بعشرين حرفاً 38 بايتاً، والرمز بتسعة حقول 396 حرفاً في QR من الإصدار 15، والأداة المنزلة كتبت الحقول 7 و8 و9 بترتيب يختلف عن جدول المعيار الحالي.
  • سلسلة PIH تجعل حذف فاتورة ظاهراً، والنظام نفسه يجب أن يمنع تصفير العداد وتغيير الوقت وحذف الفواتير وتصدير المفتاح.

والمقالات المرتبطة:

دليلبوابات الدفع Payment Gateways في السعودية والبدائل المفتوحةكيف تختار مزود الدفع في المملكة بما تنشره صفحاتهم الرسمية، وأين يقف Stripe والبرمجيات المفتوحة، مع مختبر التحويل بين مزودين.دليلكشف الاحتيال Fraud Detection: قواعد أم تعلم آلة؟قارنا القواعد بنموذجين لتعلم الآلة على بيانات دفع مولدة بنفس سعة المراجعة، ورأينا من يمسك الأنماط المعروفة ومن يتأخر عن النمط الجديد.دليلمعرفة العميل KYC وفحص العقوبات بالأسماء العربيةفحصنا عشرين ألف عميل مولد مقابل قائمة مجلس الأمن، ورأينا كم مدرجاً تفوته المطابقة التامة وكم بريئاً تضيفه كل درجة في العتبة.دليلالمحفظة الرقمية من الداخل: دفتر القيد المزدوج Ledgerكيف تبني محفظة رقمية لا يضيع فيها ريال ولا يخصم مرتين، بالقيد المزدوج والمعاملات والمطابقة مع مزود الدفع.دليلالدفع برمز QR: كيف يعمل ومتى يناسبكبنينا رموز دفع بصيغة EMV بلغة Python، وتحققنا منها على أمثلة Pix الرسمية، ورأينا لماذا لا يحمي CRC من استبدال الملصق.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال وتشغيل المختبر على python:3.13.17-slim (lxml 6.0.2 وcryptography 46.0.3 وqrcode 8.2) وeclipse-temurin:11.0.32_9-jre-noble مع أداة SDK من صفحة التنزيل في الهيئة (الملف zatca-envoice-sdk-203.zip، الأداة 3.0.8)، ومراجعة معيار XML ومعايير خصائص الأمان (الإصدار 1.2) والإرشادات التفصيلية (الإصدار 2) وقرار الضوابط، وكلها بتاريخ 19 مايو 2023، كما ظهرت في 27 أكتوبر 2026.
Could not load content
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.