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

الربط مع منصة فاتورة (المرحلة الثانية) بلغة TypeScript: الشهادة الرقمية والتوقيع والإرسال

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

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

المرحلة الثانية ليست خطوة في بوابة. إنها بروتوكول تشفيري. نظامك يولّد زوج مفاتيح، يحصل على شهادة من زاتكا، يبني الفاتورة بصيغة UBL 2.1، يوحّد صيغتها، يحسب بصمتها، يوقّع البصمة بتوقيع XAdES، يرمّز رمز QR بصيغة TLV، ثم يرسلها إلى بوابة إما تجيزها أو ترفضها. كل مرحلة من هذه المراحل لها طريقة للفشل الصامت، والفشل يظهر في صورة رسالتين مبهمتين إلى حد الجنون: invalid-hash أو invalid-digital-signature.

هذا الدرس يبني هذا المسار كاملًا بلغة TypeScript، ويولي اهتمامًا خاصًا للمواضع الثلاثة التي تنكسر فيها التطبيقات فعليًا.

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

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

وحدة ZatcaClient بأربع مسؤوليات:

  1. التسجيل (Onboarding) — زوج المفاتيح، طلب توقيع الشهادة بامتدادات زاتكا المخصصة، شهادة الامتثال، فواتير فحص الامتثال، ثم شهادة الإنتاج.
  2. بناء المستند — نموذج فاتورة مُعرَّف الأنواع يُصدَّر إلى XML بصيغة UBL 2.1.
  3. التشفير — توحيد الصيغة، بصمة الفاتورة، توقيع XAdES-B-B المغلَّف، وحمولة رمز QR بصيغة TLV.
  4. الإرسال — المقاصة للفواتير الضريبية والإبلاغ للفواتير المبسطة، مع الحفاظ على سلسلة PIH وعدّاد الفواتير عبر الطلبات.

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

  • Node.js إصدار 20 أو أحدث، و TypeScript 5.x
  • OpenSSL 3.x على سطر الأوامر
  • حساب في بوابة فاتورة لمنشأتك المسجلة ضريبيًا، إذا كنت تنوي تجاوز البيئة التجريبية
  • معرفة عملية بمساحات أسماء XML والتشفير غير المتماثل
  • الرقم الضريبي للمنشأة، ورقم السجل التجاري، والعنوان الوطني

الخطوة 1: حدّد مسار كل فاتورة

قبل كتابة أي سطر، اضبط هذا التفرّع بشكل صحيح، لأنه يحدد نقطة النهاية، والالتزام الزمني، وما تسلّمه للمشتري.

فاتورة ضريبية (منشآت وحكومة)فاتورة ضريبية مبسطة (أفراد)
خاصية name في InvoiceTypeCode01000000200000
المسارالمقاصة (Clearance)الإبلاغ (Reporting)
التوقيتقبل تسليم الفاتورة للمشتريخلال 24 ساعة من الإصدار
ما يستلمه المشتريملف XML المُجاز العائد من زاتكاملف XML الموقّع من نظامك
رمز QRمطلوب، دون ختم زاتكامطلوب، ويتضمن ختم زاتكا التشفيري

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

الخانات الخمس التي تلي الرقمين الأولين في رمز النوع هي أعلام — طرف ثالث، صورية، تصدير، ملخص، فوترة ذاتية — كل منها 0 أو 1. معظم الفواتير أصفار بالكامل.

الخطوة 2: تجهيز المشروع

mkdir zatca-integration && cd zatca-integration
npm init -y
npm install xml-crypto xmlbuilder2 node-forge axios zod
npm install -D typescript tsx @types/node
npx tsc --init --target es2022 --module nodenext --strict

البيئات، وستمر بها بهذا الترتيب:

// src/config.ts
export const ENVIRONMENTS = {
  sandbox: {
    base: "https://gw-fatoora.zatca.gov.sa/e-invoicing/developer-portal",
    csrTemplate: "TSTZATCACode-Signing",
  },
  simulation: {
    base: "https://gw-fatoora.zatca.gov.sa/e-invoicing/simulation",
    csrTemplate: "PREZATCACode-Signing",
  },
  production: {
    base: "https://gw-fatoora.zatca.gov.sa/e-invoicing/core",
    csrTemplate: "ZATCACode-Signing",
  },
} as const;
 
export type EnvName = keyof typeof ENVIRONMENTS;

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

الخطوة 3: توليد زوج المفاتيح وطلب توقيع الشهادة

زاتكا تشترط ECDSA على منحنى secp256k1. ليس P-256 ولا P-384. إن ولّدت المفتاح بمنحنى خاطئ، يفشل التسجيل عند مرحلة الشهادة برسالة تبدو وكأنها مشكلة تنسيق.

openssl ecparam -name secp256k1 -genkey -noout -out private-key.pem

طلب توقيع الشهادة هو موضع الثقل الأكبر الخاص بزاتكا. فهو يحمل امتدادات OID مخصصة تُرمّز هويتك ونوع الفواتير التي يصدرها جهازك.

# csr-config.cnf
oid_section = OIDs
 
[OIDs]
certificateTemplateName = 1.3.6.1.4.1.311.20.2
 
[req]
default_bits       = 2048
distinguished_name = req_distinguished_name
prompt             = no
req_extensions     = req_ext
 
[req_distinguished_name]
C  = SA
OU = Riyadh Branch
O  = Noqta Trading Company
CN = EGS-886431145-101
 
[req_ext]
certificateTemplateName = ASN1:PRINTABLESTRING:TSTZATCACode-Signing
subjectAltName          = dirName:alt_names
 
[alt_names]
SN = 1-Noqta|2-POS|3-1a2b3c4d-0000-0000-0000-9f8e7d6c5b4a
UID = 399999999900003
title = 1100
registeredAddress = King Fahd Road, Riyadh 12345
businessCategory = Trading

حقلان يستحقان التوضيح:

  • SN هو الرقم التسلسلي لوحدة إصدار الفواتير (EGS)، بالصيغة الصارمة 1-SOLUTIONNAME|2-MODEL|3-UUID. الشرطات العمودية والبادئات الرقمية جزء من الصيغة، لا مجرد عرض تنسيقي.
  • title قناع ثنائي من أربعة محارف يصف ما تُصدره الوحدة: الخانة الأولى للفواتير الضريبية والثانية للمبسطة. 1100 تعني أن الوحدة تصدر النوعين. 0100 تعني المبسطة فقط.

توليد الطلب:

openssl req -new -sha256 -key private-key.pem -config csr-config.cnf -out csr.pem

الخطوة 4: التسجيل — من شهادة الامتثال إلى شهادة الإنتاج

التسجيل ثلاثة نداءات API مع دفعة فواتير اختبارية بينها. احصل على رمز التحقق (OTP) من بوابة فاتورة أولًا؛ صلاحيته نحو ساعة.

// src/onboarding.ts
import axios from "axios";
import { ENVIRONMENTS, type EnvName } from "./config.js";
 
const headers = (extra: Record<string, string> = {}) => ({
  "Accept-Version": "V2",
  "Accept-Language": "en",
  "Content-Type": "application/json",
  ...extra,
});
 
const basic = (token: string, secret: string) =>
  "Basic " + Buffer.from(`${token}:${secret}`).toString("base64");
 
export interface Csid {
  binarySecurityToken: string;
  secret: string;
  requestID: string;
}
 
/** 4أ — مبادلة طلب الشهادة ورمز التحقق بشهادة امتثال. */
export async function requestComplianceCsid(
  env: EnvName,
  csrPem: string,
  otp: string
): Promise<Csid> {
  const csrBase64 = Buffer.from(
    csrPem.replace(/-----(BEGIN|END) CERTIFICATE REQUEST-----/g, "").replace(/\s/g, "")
  ).toString("base64");
 
  const res = await axios.post(
    `${ENVIRONMENTS[env].base}/compliance`,
    { csr: csrBase64 },
    { headers: headers({ OTP: otp }) }
  );
 
  return {
    binarySecurityToken: res.data.binarySecurityToken,
    secret: res.data.secret,
    requestID: String(res.data.requestID),
  };
}
 
/** 4ب — كل فاتورة فحص امتثال تمر من هنا. */
export async function submitComplianceInvoice(
  env: EnvName,
  ccsid: Csid,
  payload: { invoiceHash: string; uuid: string; invoice: string }
) {
  const res = await axios.post(
    `${ENVIRONMENTS[env].base}/compliance/invoices`,
    payload,
    {
      headers: headers({
        Authorization: basic(ccsid.binarySecurityToken, ccsid.secret),
      }),
      validateStatus: () => true,
    }
  );
  return res.data;
}
 
/** 4ج — مبادلة معرّف طلب الامتثال بشهادة الإنتاج. */
export async function requestProductionCsid(
  env: EnvName,
  ccsid: Csid
): Promise<Csid> {
  const res = await axios.post(
    `${ENVIRONMENTS[env].base}/production/csids`,
    { compliance_request_id: ccsid.requestID },
    {
      headers: headers({
        Authorization: basic(ccsid.binarySecurityToken, ccsid.secret),
      }),
    }
  );
  return {
    binarySecurityToken: res.data.binarySecurityToken,
    secret: res.data.secret,
    requestID: String(res.data.requestID),
  };
}

بين 4أ و4ج عليك اجتياز فحوص الامتثال. المستندات المطلوبة تعتمد على قناع title في طلب شهادتك — الوحدة المُعلَن أنها تصدر النوعين عليها اجتياز الستة جميعًا:

// ضريبية: فاتورة 388، إشعار مدين 383، إشعار دائن 381
// مبسطة: فاتورة 388، إشعار مدين 383، إشعار دائن 381
const COMPLIANCE_MATRIX = [
  { typeName: "0100000", typeCode: "388" },
  { typeName: "0100000", typeCode: "383" },
  { typeName: "0100000", typeCode: "381" },
  { typeName: "0200000", typeCode: "388" },
  { typeName: "0200000", typeCode: "383" },
  { typeName: "0200000", typeCode: "381" },
] as const;

هذه الفواتير الست تشكّل سلسلة PIH خاصة بها. بصمة كل واحدة تصبح بصمة الفاتورة السابقة للتي تليها. أرسلها بترتيب مختلف وستفشل.

قيمة binarySecurityToken العائدة إليك هي شهادة X.509 مرمّزة بصيغة base64. فُكّ ترميزها مرة واحدة واحتفظ بها بصيغة PEM — مرحلة التوقيع تحتاج جسم الشهادة ورقمها التسلسلي واسم مُصدرها.

الخطوة 5: بناء فاتورة UBL 2.1

مخطط زاتكا هو UBL 2.1 مع ملف تعريف سعودي. الأجزاء التي تحمل معنى بروتوكوليًا، لا معنى تجاريًا، هي التالية:

<cbc:ProfileID>reporting:1.0</cbc:ProfileID>
<cbc:ID>INV-2026-000412</cbc:ID>
<cbc:UUID>9f2c8e1a-4b7d-4f3a-9c21-77b1a0e5d3f8</cbc:UUID>
<cbc:IssueDate>2026-08-10</cbc:IssueDate>
<cbc:IssueTime>14:32:07</cbc:IssueTime>
<cbc:InvoiceTypeCode name="0100000">388</cbc:InvoiceTypeCode>
<cbc:DocumentCurrencyCode>SAR</cbc:DocumentCurrencyCode>
<cbc:TaxCurrencyCode>SAR</cbc:TaxCurrencyCode>
 
<cac:AdditionalDocumentReference>
  <cbc:ID>ICV</cbc:ID>
  <cbc:UUID>412</cbc:UUID>
</cac:AdditionalDocumentReference>
<cac:AdditionalDocumentReference>
  <cbc:ID>PIH</cbc:ID>
  <cac:Attachment>
    <cbc:EmbeddedDocumentBinaryObject mimeCode="text/plain">
      NWZlY2ViNjZmZmM4NmYzOGQ5NTI3ODZjNmQ2OTZjNzljMmRiYzIzOWRkNGU5MWI0NjcyOWQ3M2EyN2ZiNTdlOQ==
    </cbc:EmbeddedDocumentBinaryObject>
  </cac:Attachment>
</cac:AdditionalDocumentReference>
  • ICV هو عدّاد الفواتير — عدد صحيح متزايد بصرامة لكل وحدة إصدار، لا يُصفَّر ولا يُعاد استخدامه أبدًا.
  • PIH هي بصمة الفاتورة السابقة. القيمة الحرفية أعلاه هي البذرة المعروفة: ترميز base64 لبصمة SHA-256 للمحرف 0. فاتورتك الأولى فقط على وحدة معينة هي التي تستخدمها.

فخ المنطقة الزمنية. يُعبَّر عن IssueDate و IssueTime بالتوقيت السعودي المحلي (UTC+3)، بينما SigningTime داخل التوقيع طابع زمني بصيغة ISO بتوقيت UTC. خادم يعمل بتوقيت UTC ويُنسّق الحقلين من كائن Date واحد سينتج فواتير متأخرة ثلاث ساعات، وهو ما يجتاز التحقق بصمت ثم يفشل في مراجعة بعد سنوات. نسّق الحقلين بشكل منفصل ومقصود.

اجعل الفاتورة بيانات مُعرَّفة الأنواع تُصدَّر مرة واحدة، بدل تجميع نصوص متفرقة عبر الشيفرة:

// src/invoice.ts
import { create } from "xmlbuilder2";
import { z } from "zod";
 
export const InvoiceInput = z.object({
  id: z.string().min(1),
  uuid: z.string().uuid(),
  issuedAt: z.date(),
  typeName: z.enum(["0100000", "0200000"]),
  typeCode: z.enum(["388", "383", "381"]),
  icv: z.number().int().positive(),
  pih: z.string().min(1),
  seller: z.object({ name: z.string(), vat: z.string().length(15), crn: z.string() }),
  buyer: z.object({ name: z.string(), vat: z.string().optional() }).optional(),
  lines: z.array(
    z.object({
      name: z.string(),
      quantity: z.number().positive(),
      unitPrice: z.number().nonnegative(),
      vatRate: z.number().min(0).max(1),
    })
  ).min(1),
});
 
export type InvoiceInput = z.infer<typeof InvoiceInput>;
 
/** التوقيت السعودي المحلي، مُنسَّقًا في حقلين منفصلين. */
function riyadhParts(d: Date) {
  const fmt = new Intl.DateTimeFormat("en-CA", {
    timeZone: "Asia/Riyadh",
    year: "numeric", month: "2-digit", day: "2-digit",
    hour: "2-digit", minute: "2-digit", second: "2-digit",
    hourCycle: "h23",
  }).formatToParts(d);
  const p = Object.fromEntries(fmt.map((x) => [x.type, x.value]));
  return {
    date: `${p.year}-${p.month}-${p.day}`,
    time: `${p.hour}:${p.minute}:${p.second}`,
  };
}
 
export function buildInvoiceXml(input: InvoiceInput): string {
  const data = InvoiceInput.parse(input);
  const { date, time } = riyadhParts(data.issuedAt);
 
  const lineTotal = data.lines.reduce((s, l) => s + l.quantity * l.unitPrice, 0);
  const vatTotal = data.lines.reduce(
    (s, l) => s + l.quantity * l.unitPrice * l.vatRate, 0
  );
 
  const doc = create({ version: "1.0", encoding: "UTF-8" })
    .ele("Invoice", {
      xmlns: "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2",
      "xmlns:cac":
        "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2",
      "xmlns:cbc":
        "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2",
      "xmlns:ext":
        "urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2",
    });
 
  // يُنشأ UBLExtensions فارغًا هنا؛ الموقّع يملؤه في الخطوة 7.
  doc.ele("ext:UBLExtensions").up();
 
  doc.ele("cbc:ProfileID").txt("reporting:1.0").up();
  doc.ele("cbc:ID").txt(data.id).up();
  doc.ele("cbc:UUID").txt(data.uuid).up();
  doc.ele("cbc:IssueDate").txt(date).up();
  doc.ele("cbc:IssueTime").txt(time).up();
  doc.ele("cbc:InvoiceTypeCode", { name: data.typeName }).txt(data.typeCode).up();
  doc.ele("cbc:DocumentCurrencyCode").txt("SAR").up();
  doc.ele("cbc:TaxCurrencyCode").txt("SAR").up();
 
  // ... AccountingSupplierParty و AccountingCustomerParty و TaxTotal
  // و LegalMonetaryTotal و InvoiceLine تتبع النمط نفسه.
 
  return doc.end({ prettyPrint: false });
}

المجاميع تُقرَّب إلى منزلتين عشريتين في ملف XML، وزاتكا تتحقق منها تقاطعيًا: قيمة LegalMonetaryTotal/TaxInclusiveAmount يجب أن تساوي TaxExclusiveAmount مضافًا إليها مجموع المبالغ الضريبية الفرعية، بدقة الهللة. تقريب كل بند على حدة ثم جمعه يُنتج فروقًا في الفواتير الكبيرة. اجمع أولًا ثم قرّب مرة واحدة.

الخطوة 6: توحيد الصيغة والبصمة — حيث تنكسر معظم التطبيقات

بصمة الفاتورة ليست SHA-256 لنص XML لديك. إنها SHA-256 لملف XML بعد توحيد صيغته وحذف ثلاثة عناصر منه:

  1. ext:UBLExtensions — حاوية التوقيع
  2. عنصر cac:AdditionalDocumentReference الذي قيمة cbc:ID فيه هي QR
  3. cac:Signature

توحيد الصيغة هو C14N 1.1 بدون تعليقات. المسافات البيضاء وترتيب الخصائص وتصريحات مساحات الأسماء كلها تغيّر البايتات التي تُحسب بصمتها، وهذا بالضبط سبب أن النص المُجمَّع يدويًا لا يطابق أبدًا تقريبًا.

// src/hash.ts
import { createHash } from "node:crypto";
import { DOMParser, XMLSerializer } from "@xmldom/xmldom";
import * as xpath from "xpath";
import { SignedXml } from "xml-crypto";
 
const NS = {
  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",
};
 
export function canonicalizeForHash(xml: string): string {
  const doc = new DOMParser().parseFromString(xml, "text/xml");
  const select = xpath.useNamespaces(NS);
 
  const toRemove = [
    ...select("//ext:UBLExtensions", doc),
    ...select("//cac:Signature", doc),
    ...select(
      "//cac:AdditionalDocumentReference[cbc:ID='QR']",
      doc
    ),
  ] as Node[];
 
  for (const node of toRemove) node.parentNode?.removeChild(node);
 
  const canon = new (SignedXml as any).CanonicalizationAlgorithms[
    "http://www.w3.org/2006/12/xml-c14n11"
  ]();
  return canon.process(doc.documentElement, {});
}
 
/** ترميز base64 لبصمة SHA-256 — هذا ما تسميه الواجهة invoiceHash. */
export function invoiceHash(xml: string): string {
  return createHash("sha256")
    .update(canonicalizeForHash(xml), "utf8")
    .digest("base64");
}

نصيحة تصحيح توفّر أيامًا. حين تُعيد البوابة invalid-hash، أفرغ البايتات الموحَّدة إلى ملف وقارنها بمخرجات SDK الرسمية من زاتكا للفاتورة نفسها. الحزمة تتضمن أداة تحقق من سطر الأوامر لهذه المقارنة تحديدًا. سطر جديد زائد واحد كافٍ لكسر التطابق، ولن تكشفه أي قراءة متكررة لشيفرتك.

الخطوة 7: توقيع XAdES

التوقيع مُغلَّف داخل ext:UBLExtensions ويتبع XAdES-B-B. يحتوي على كتلة SignedInfo تشير إلى بصمة الفاتورة، وكتلة SignedProperties تشير إلى الشهادة.

// src/sign.ts
import { createSign, createHash, createPrivateKey } from "node:crypto";
 
export interface SigningMaterial {
  privateKeyPem: string;
  certificatePem: string;    // مفكوك من binarySecurityToken
  certificateSerial: string; // عشري، لا ست عشري
  issuerName: string;        // كما هو في الشهادة تمامًا
}
 
export function signedPropertiesDigest(
  m: SigningMaterial,
  signingTimeIso: string
): { xml: string; digest: string } {
  const certDigest = createHash("sha256")
    .update(m.certificatePem.replace(/-----[^-]+-----|\s/g, ""))
    .digest("base64");
 
  const xml =
    `<xades:SignedProperties Id="xadesSignedProperties">` +
    `<xades:SignedSignatureProperties>` +
    `<xades:SigningTime>${signingTimeIso}</xades:SigningTime>` +
    `<xades:SigningCertificate><xades:Cert>` +
    `<xades:CertDigest>` +
    `<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>` +
    `<ds:DigestValue>${certDigest}</ds:DigestValue>` +
    `</xades:CertDigest>` +
    `<xades:IssuerSerial>` +
    `<ds:X509IssuerName>${m.issuerName}</ds:X509IssuerName>` +
    `<ds:X509SerialNumber>${m.certificateSerial}</ds:X509SerialNumber>` +
    `</xades:IssuerSerial>` +
    `</xades:Cert></xades:SigningCertificate>` +
    `</xades:SignedSignatureProperties>` +
    `</xades:SignedProperties>`;
 
  const digest = createHash("sha256").update(xml, "utf8").digest("base64");
  return { xml, digest };
}
 
/** ECDSA-SHA256 على كتلة SignedInfo بعد توحيد صيغتها. */
export function signSignedInfo(canonicalSignedInfo: string, keyPem: string): string {
  const signer = createSign("SHA256");
  signer.update(canonicalSignedInfo, "utf8");
  signer.end();
  return signer.sign(createPrivateKey(keyPem)).toString("base64");
}

ثلاث تفاصيل تُولّد تذاكر دعم:

  • الرقم التسلسلي للشهادة يجب أن يكون عشريًا. OpenSSL يطبعه بالنظام الست عشري افتراضيًا. التحويل بـ parseInt الساذج يفقد الدقة في الأرقام التي تتجاوز 15 خانة — استخدم BigInt.
  • بصمة الشهادة تُحسب على جسم base64 بعد حذف ترويسة PEM وتذييلها وكل فواصل الأسطر.
  • اسم المُصدر يجب أن يتطابق بايتًا ببايت، بما في ذلك ترتيب مكوّنات الاسم المميز. إعادة تركيبه من حقول مُحلَّلة بترتيب مختلف تنتج توقيعًا يبدو صحيحًا لكن زاتكا ترفضه.

الخطوة 8: رمز QR بصيغة TLV

حمولة رمز QR بصيغة Tag-Length-Value: بايت للوسم، بايت للطول، ثم القيمة. للفاتورة المبسطة الموقّعة، تسعة وسوم مطلوبة.

// src/qr.ts
function tlv(tag: number, value: Buffer): Buffer {
  if (value.length > 255) throw new Error(`TLV tag ${tag} exceeds 255 bytes`);
  return Buffer.concat([Buffer.from([tag, value.length]), value]);
}
 
export interface QrInput {
  sellerName: string;
  vatNumber: string;
  timestampIso: string;     // UTC بصيغة ISO 8601 وينتهي بحرف Z
  totalWithVat: string;     // منزلتان عشريتان، كما تُطبع
  vatTotal: string;
  invoiceHashBase64: string;
  signatureBase64: string;
  publicKeyDer: Buffer;
  zatcaStampSignature?: Buffer; // للفواتير المبسطة فقط
}
 
export function buildQr(q: QrInput): string {
  const parts = [
    tlv(1, Buffer.from(q.sellerName, "utf8")),
    tlv(2, Buffer.from(q.vatNumber, "utf8")),
    tlv(3, Buffer.from(q.timestampIso, "utf8")),
    tlv(4, Buffer.from(q.totalWithVat, "utf8")),
    tlv(5, Buffer.from(q.vatTotal, "utf8")),
    tlv(6, Buffer.from(q.invoiceHashBase64, "utf8")),
    tlv(7, Buffer.from(q.signatureBase64, "base64")),
    tlv(8, q.publicKeyDer),
  ];
  if (q.zatcaStampSignature) parts.push(tlv(9, q.zatcaStampSignature));
  return Buffer.concat(parts).toString("base64");
}

الوسم 1 هو اسم البائع بترميز UTF-8، وهذا يعني للاسم التجاري العربي أن طوله بالبايت يقارب ضعف عدد محارفه. سقف 255 بايت لكل وسم حقيقي، وأسماء الشركات العربية الطويلة تبلغه فعلًا — اقتطع عند حدّ محرف كامل، لا في منتصف بايت، وإلا فُكّ رمز QR إلى محارف مشوّهة.

النص الناتج بصيغة base64 يعود إلى الفاتورة داخل AdditionalDocumentReference بقيمة cbc:ID تساوي QR، وذلك بعد حساب البصمة — ولهذا تحذفه خطوة البصمة.

الخطوة 9: الإرسال — المقاصة والإبلاغ

// src/submit.ts
import axios from "axios";
import { ENVIRONMENTS, type EnvName } from "./config.js";
 
export type SubmitResult = {
  ok: boolean;
  status: "PASS" | "WARNING" | "ERROR" | "UNKNOWN";
  clearedInvoiceXml?: string;
  warnings: string[];
  errors: string[];
};
 
export async function submitInvoice(
  env: EnvName,
  pcsid: { binarySecurityToken: string; secret: string },
  payload: { invoiceHash: string; uuid: string; invoice: string },
  mode: "clearance" | "reporting"
): Promise<SubmitResult> {
  const path =
    mode === "clearance" ? "/invoices/clearance/single" : "/invoices/reporting/single";
 
  const res = await axios.post(`${ENVIRONMENTS[env].base}${path}`, payload, {
    headers: {
      "Accept-Version": "V2",
      "Accept-Language": "en",
      "Content-Type": "application/json",
      "Clearance-Status": mode === "clearance" ? "1" : "0",
      Authorization:
        "Basic " +
        Buffer.from(`${pcsid.binarySecurityToken}:${pcsid.secret}`).toString("base64"),
    },
    validateStatus: () => true,
    timeout: 30_000,
  });
 
  const v = res.data?.validationResults ?? {};
  const warnings = (v.warningMessages ?? []).map((m: any) => `${m.code}: ${m.message}`);
  const errors = (v.errorMessages ?? []).map((m: any) => `${m.code}: ${m.message}`);
 
  return {
    ok: res.status === 200 && errors.length === 0,
    status: v.status ?? "UNKNOWN",
    clearedInvoiceXml: res.data?.clearedInvoice
      ? Buffer.from(res.data.clearedInvoice, "base64").toString("utf8")
      : undefined,
    warnings,
    errors,
  };
}

استجابة HTTP 200 مع status: "WARNING" هي نجاح. الفاتورة أُجيزت أو أُبلغ عنها، والتحذيرات إرشادية. الأنظمة التي تعامل أي warningMessages غير فارغة كفشل تنتهي بإعادة إرسال فواتير قُبلت أصلًا، فتكسر تسلسل ICV، ويتتالى الأثر ليصبح خطأ بصمة في كل مستند لاحق.

الخطوة 10: احفظ السلسلة، لا الفاتورة وحدها

قيمتان يجب أن تنجوا من إعادة تشغيل العمليات والنشر والانهيارات، لكل وحدة إصدار:

// src/state.ts — مخطط مبدئي؛ اسنده إلى مخزن يدعم المعاملات
export interface EgsState {
  egsUuid: string;
  lastIcv: number;
  lastInvoiceHash: string; // يصبح PIH التالي
}
 
export async function nextDocument(
  db: Db,
  egsUuid: string,
  build: (icv: number, pih: string) => Promise<{ hash: string; xml: string }>
) {
  return db.transaction(async (tx) => {
    const state = await tx.selectForUpdate("egs_state", { egsUuid });
    const icv = state.lastIcv + 1;
    const { hash, xml } = await build(icv, state.lastInvoiceHash);
    await tx.update("egs_state", { egsUuid }, { lastIcv: icv, lastInvoiceHash: hash });
    return { icv, hash, xml };
  });
}

قفل الصف مهم. فاتورتان متزامنتان تقرآن lastIcv نفسه ستبنيان مستندين يدّعيان العدّاد نفسه والبصمة السابقة نفسها. إحداهما ستُجاز، والأخرى ستفشل، والأسوأ أن السلسلة صارت متفرّعة بشكل لا يمكن إصلاحه دون مراجعة زاتكا. إن كان إصدار فواتيرك موزعًا على عدة عمّال، فالعدّاد يجب أن يأتي من مصدر واحد متسلسل.

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

اختبار التنفيذ

تدرّج في البيئات بالترتيب، ولا تتخطَّ الوسطى:

  1. البيئة التجريبية (Sandbox) — تتحقق من البنية وآليات التوقيع بشهادة اختبار مشتركة. تغذية راجعة سريعة، بلا هوية حقيقية.
  2. بيئة المحاكاة (Simulation) — تسجيل كامل بطلب شهادتك الحقيقي ورمز تحقق حقيقي، على بيانات غير إنتاجية. هنا تظهر الأخطاء الخاصة بالبيئة، وبالأخص نص قالب طلب الشهادة.
  3. الإنتاج — فقط بعد اجتياز المحاكاة من الطرف إلى الطرف لكل أنواع المستندات التي يُعلنها قناع title.

مجموعة اختبارات انحدار صغيرة تستحق أن تمتلكها قبل أن تلمس الإنتاج:

// tests/hash.test.ts
import { describe, it, expect } from "vitest";
import { invoiceHash } from "../src/hash.js";
import { readFileSync } from "node:fs";
 
describe("invoice hash", () => {
  it("يطابق مخرجات SDK زاتكا للفاتورة المرجعية", () => {
    const xml = readFileSync("fixtures/standard-invoice.xml", "utf8");
    // القيمة التي أنتجتها أداة تحقق زاتكا للملف نفسه.
    expect(invoiceHash(xml)).toBe(readFileSync("fixtures/standard-invoice.hash", "utf8").trim());
  });
 
  it("لا يتأثر بوجود مرجع رمز QR", () => {
    const withQr = readFileSync("fixtures/standard-invoice-with-qr.xml", "utf8");
    const withoutQr = readFileSync("fixtures/standard-invoice.xml", "utf8");
    expect(invoiceHash(withQr)).toBe(invoiceHash(withoutQr));
  });
});

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

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

العَرَضالسبب الأرجح
invalid-hashاختلاف في توحيد الصيغة — غالبًا لم يُحذف عنصر QR أو Signature، أو استُخدم C14N 1.0 بدل 1.1
invalid-digital-signatureالرقم التسلسلي أُرسل ست عشريًا، أو أُعيد ترتيب اسم المُصدر، أو منحنى خاطئ عند توليد المفتاح
رفض التسجيل رغم صحة طلب الشهادةعدم تطابق قالب البيئة — قالب البيئة التجريبية أُرسل إلى المحاكاة أو الإنتاج
عدم تطابق PIH في الفاتورة الثانيةبصمة الفاتورة الأولى سُجّلت قبل التوقيع، أو إرسال فاشل قدّم العدّاد رغم فشله
رفض المجاميع لعدم الاتساقتقريب كل بند على حدة ثم جمعه بدل الجمع ثم التقريب مرة واحدة
رمز QR يُظهر عربية مشوّهةالوسم 1 اقتُطع عند إزاحة بايت داخل محرف متعدد البايتات
كل شيء يمر في التجريبية ويفشل في الإنتاجما زلت تستخدم شهادة الاختبار المشتركة بدل شهادة الإنتاج الخاصة بك

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

الخلاصة

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

المراحل الثلاث التي تستحق مبالغة في الإتقان هي توحيد الصيغة، ومادة الشهادة داخل التوقيع، وحفظ سلسلة ICV و PIH. أتقنها وسيصبح بقية المسار عمل REST اعتياديًا. أخطئ في واحدة منها وستقضي أسبوعًا تقرأ رسالة لا تقول إلا invalid-hash.

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