zatca-qr 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.en.md +101 -0
- package/README.md +222 -0
- package/package.json +59 -0
- package/src/index.d.ts +116 -0
- package/src/index.js +532 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# سجل التغييرات
|
|
2
|
+
|
|
3
|
+
## 1.0.0 — 2026-09-29
|
|
4
|
+
|
|
5
|
+
الإصدار الأول.
|
|
6
|
+
|
|
7
|
+
- `encodeZatcaTlv(data, { strict })` — ترميز TLV ثم Base64، مع ترتيب العلامات تصاعدياً.
|
|
8
|
+
- `buildTlvBytes(data)` — الوصول إلى البايتات الخام للفحص والاختبار.
|
|
9
|
+
- `decodeZatcaTlv(base64)` — فك الترميز إلى حقول مقروءة، مع تنبيهات للعلامات المكرّرة
|
|
10
|
+
والناقصة والخارجة عن المواصفة، وتسامح مع Base64 الآمن للروابط.
|
|
11
|
+
- `validateZatcaInvoice(data)` — فحص المواصفة: الرقم الضريبي، الطابع الزمني، المبالغ،
|
|
12
|
+
حدود البايتات، واكتمال حقول مرحلة الربط.
|
|
13
|
+
- `toZatcaAmount(value)` — تنسيق المبلغ بفاصلة نقطية وبمنزلتين.
|
|
14
|
+
- دعم علامات مرحلة الربط 6 و7 و8 و9.
|
|
15
|
+
- 38 اختباراً بلا أي تبعية، منها فيكتور مرجعي بفاتورة مبسّطة.
|
|
16
|
+
- صفحة تجريبية عربية تعمل في المتصفح بلا خادم.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 VibeIO — https://www.vibeio.dev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.en.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# zatca-qr
|
|
2
|
+
|
|
3
|
+
**ZATCA (Saudi FATOORA) e-invoice QR payloads — zero dependencies, no server, tested against the specification.**
|
|
4
|
+
|
|
5
|
+
[](https://github.com/Exeerkit/zatca-qr/actions/workflows/ci.yml)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://github.com/exeerkit/zatca-qr/releases)
|
|
8
|
+
|
|
9
|
+
> The Arabic-first README is the primary one: [README.md](README.md).
|
|
10
|
+
|
|
11
|
+
## The problem
|
|
12
|
+
|
|
13
|
+
Every tax invoice in Saudi Arabia must carry a QR payload built as a **TLV** sequence and then
|
|
14
|
+
**Base64** encoded. The most common — and hardest to spot — bug is measuring field length in
|
|
15
|
+
**characters** instead of **UTF-8 bytes**:
|
|
16
|
+
|
|
17
|
+
| Seller name | Characters | UTF-8 bytes | Result |
|
|
18
|
+
| --- | --- | --- | --- |
|
|
19
|
+
| `Acme Saudi` | 10 | 10 | ✅ valid |
|
|
20
|
+
| `شركة النخبة` | 10 | 20 | ❌ payload rejected |
|
|
21
|
+
| `ش` × 128 | 128 | 256 | ❌ exceeds the 255-byte TLV limit |
|
|
22
|
+
|
|
23
|
+
This library counts bytes correctly, decodes payloads for verification, and validates the invoice
|
|
24
|
+
before you issue it.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm install github:exeerkit/zatca-qr # available now, straight from the repository
|
|
30
|
+
npm install zatca-qr # once the package is published to npm
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Zero dependencies. ESM only. Runs in Node 18+, browsers, Cloudflare Workers, Deno and Bun.
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { encodeZatcaTlv, toZatcaAmount, validateZatcaInvoice } from 'zatca-qr'
|
|
39
|
+
|
|
40
|
+
const invoice = {
|
|
41
|
+
sellerName: 'النخبة Trading Est.',
|
|
42
|
+
vatNumber: '300000000000003', // 15 digits, starts and ends with 3
|
|
43
|
+
timestamp: new Date(), // or an ISO 8601 string with a time zone
|
|
44
|
+
totalWithVat: toZatcaAmount(1150), // '1150.00'
|
|
45
|
+
vatTotal: toZatcaAmount(150), // '150.00'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const report = validateZatcaInvoice(invoice) // errors block, warnings inform
|
|
49
|
+
const payload = encodeZatcaTlv(invoice, { strict: true }) // pass to any QR renderer
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Verify a payload you received
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { decodeZatcaTlv } from 'zatca-qr'
|
|
56
|
+
|
|
57
|
+
const decoded = decodeZatcaTlv(payloadFromScanner)
|
|
58
|
+
decoded.phase // 1 or 2
|
|
59
|
+
decoded.data // { sellerName, vatNumber, timestamp, totalWithVat, vatTotal, ... }
|
|
60
|
+
decoded.fields // each field: tag, name, value, byteLength
|
|
61
|
+
decoded.warnings // duplicate tags, missing required tags, out-of-spec tags
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Tags
|
|
65
|
+
|
|
66
|
+
| Tag | Field | Phase |
|
|
67
|
+
| --- | --- | --- |
|
|
68
|
+
| `1` | Seller name (UTF-8) | 1 (required) |
|
|
69
|
+
| `2` | VAT registration number (15 digits) | 1 (required) |
|
|
70
|
+
| `3` | Invoice timestamp (ISO 8601 with zone) | 1 (required) |
|
|
71
|
+
| `4` | Invoice total including VAT (dot decimal) | 1 (required) |
|
|
72
|
+
| `5` | VAT amount | 1 (required) |
|
|
73
|
+
| `6` | Invoice XML hash (Base64 SHA-256) | 2 (optional) |
|
|
74
|
+
| `7` | Digital signature (Base64 ECDSA) | 2 (optional) |
|
|
75
|
+
| `8` | Public key (Base64) | 2 (optional) |
|
|
76
|
+
| `9` | ZATCA stamp | after clearance only |
|
|
77
|
+
|
|
78
|
+
## Phase 2: what this library does and does not do
|
|
79
|
+
|
|
80
|
+
It packs and unpacks the payload. It does **not** hash your XML, sign anything, call the FATOORA
|
|
81
|
+
platform, or manage CSID certificates — you supply `invoiceHash`, `signature` and `publicKey` from
|
|
82
|
+
your own stack. Tags 6, 7 and 8 must be provided together; `stamp` is only accepted alongside them.
|
|
83
|
+
|
|
84
|
+
## Try it
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
git clone https://github.com/exeerkit/zatca-qr
|
|
88
|
+
cd zatca-qr
|
|
89
|
+
python3 -m http.server 8080 # open http://localhost:8080/examples/demo.html
|
|
90
|
+
node examples/node.mjs # CLI example, nothing to install
|
|
91
|
+
node --test # 38 tests, zero dependencies
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## References
|
|
95
|
+
|
|
96
|
+
- [ZATCA detailed technical guideline (PDF)](https://zatca.gov.sa/en/E-Invoicing/Introduction/Guidelines/Documents/E-invoicing-Detailed-Technical-Guideline.pdf)
|
|
97
|
+
- [ZATCA developers portal](https://zatca.gov.sa/en/E-Invoicing/SystemsDevelopers/Pages/default.aspx)
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
MIT. Built by [VibeIO](https://www.vibeio.dev).
|
package/README.md
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
<div dir="rtl" align="right">
|
|
2
|
+
|
|
3
|
+
# zatca-qr
|
|
4
|
+
|
|
5
|
+
**تولّد رمز QR لفواتير زاتكا السعودية وتقرأه. بدون مكتبات خارجية وبدون خادم.**
|
|
6
|
+
|
|
7
|
+
[](https://github.com/Exeerkit/zatca-qr/actions/workflows/ci.yml)
|
|
8
|
+

|
|
9
|
+
[](LICENSE)
|
|
10
|
+
[](https://github.com/exeerkit/zatca-qr/releases)
|
|
11
|
+
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<div dir="rtl" align="right">
|
|
15
|
+
|
|
16
|
+
## المشكلة
|
|
17
|
+
|
|
18
|
+
في السعودية كل فاتورة ضريبية لازم يكون عليها رمز QR. الرمز مكتوب بصيغة اسمها **TLV** وبعدها
|
|
19
|
+
**Base64**. إذا كان الرمز غلط، نظام المشتري يرفض الفاتورة.
|
|
20
|
+
|
|
21
|
+
الخطأ المشهور هو حساب طول الحقل بعدد **الحروف**. لكن TLV يحسب الطول بعدد **البايتات**.
|
|
22
|
+
الحرف العربي = بايتين. لذلك:
|
|
23
|
+
|
|
24
|
+
| اسم البائع | عدد الحروف | عدد البايتات | النتيجة |
|
|
25
|
+
| --- | --- | --- | --- |
|
|
26
|
+
| `Acme Saudi` | 10 | 10 | ✅ سليم |
|
|
27
|
+
| `شركة النخبة` | 10 | 20 | ❌ الرمز يُرفض |
|
|
28
|
+
| `ش` × 128 | 128 | 256 | ❌ أكبر من حد TLV (255) |
|
|
29
|
+
|
|
30
|
+
هذه المكتبة تحسب البايتات صح، وتقرأ الرمز لو وصلك من جهة ثانية، وتفحص الفاتورة قبل ما ترسلها.
|
|
31
|
+
|
|
32
|
+
## التثبيت
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm install github:exeerkit/zatca-qr # متاح الآن من المستودع
|
|
36
|
+
npm install zatca-qr # بعد نشر الحزمة على npm
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
بدون أي مكتبة خارجية. تعمل في Node 18 وأحدث، وفي المتصفح، وفي Cloudflare Workers وDeno وBun.
|
|
40
|
+
|
|
41
|
+
## الاستخدام
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { encodeZatcaTlv, toZatcaAmount, validateZatcaInvoice } from 'zatca-qr'
|
|
45
|
+
|
|
46
|
+
const invoice = {
|
|
47
|
+
sellerName: 'مؤسسة النخبة التجارية',
|
|
48
|
+
vatNumber: '300000000000003', // 15 رقماً، يبدأ بـ3 وينتهي بـ3
|
|
49
|
+
timestamp: new Date(), // أو نص ISO 8601 مع منطقة زمنية
|
|
50
|
+
totalWithVat: toZatcaAmount(1150), // يطلع '1150.00'
|
|
51
|
+
vatTotal: toZatcaAmount(150), // يطلع '150.00'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 1) افحص الفاتورة قبل الإرسال
|
|
55
|
+
const report = validateZatcaInvoice(invoice)
|
|
56
|
+
if (!report.valid) throw new Error(report.errors[0].message)
|
|
57
|
+
|
|
58
|
+
// 2) خذ نص الرمز
|
|
59
|
+
const payload = encodeZatcaTlv(invoice, { strict: true })
|
|
60
|
+
|
|
61
|
+
// 3) حطه في أي مولد QR
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### مع React
|
|
65
|
+
|
|
66
|
+
```tsx
|
|
67
|
+
import { QRCodeSVG } from 'qrcode.react'
|
|
68
|
+
import { encodeZatcaTlv, type ZatcaInvoiceData } from 'zatca-qr'
|
|
69
|
+
|
|
70
|
+
export function InvoiceQr({ invoice }: { invoice: ZatcaInvoiceData }) {
|
|
71
|
+
return <QRCodeSVG value={encodeZatcaTlv(invoice)} size={220} level="M" />
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### على Cloudflare Workers
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
import { encodeZatcaTlv } from 'zatca-qr'
|
|
79
|
+
|
|
80
|
+
export default {
|
|
81
|
+
fetch(request: Request) {
|
|
82
|
+
const url = new URL(request.url)
|
|
83
|
+
const payload = encodeZatcaTlv({
|
|
84
|
+
sellerName: url.searchParams.get('seller') ?? '',
|
|
85
|
+
vatNumber: url.searchParams.get('vat') ?? '',
|
|
86
|
+
timestamp: new Date(),
|
|
87
|
+
totalWithVat: url.searchParams.get('total') ?? '0.00',
|
|
88
|
+
vatTotal: url.searchParams.get('vat_amount') ?? '0.00',
|
|
89
|
+
})
|
|
90
|
+
return Response.json({ qrPayloadBase64: payload })
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## اقرأ رمز وصل إليك
|
|
96
|
+
|
|
97
|
+
تنفع لما تفحص رمز من نظام ثاني، أو تكتشف رمز مكتوب يدوي غلط.
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
import { decodeZatcaTlv } from 'zatca-qr'
|
|
101
|
+
|
|
102
|
+
const decoded = decodeZatcaTlv(payloadFromScanner)
|
|
103
|
+
console.log(decoded.phase) // 1 أو 2
|
|
104
|
+
console.log(decoded.data.sellerName) // 'مؤسسة النخبة التجارية'
|
|
105
|
+
console.log(decoded.fields) // كل حقل مع طوله بالبايتات
|
|
106
|
+
console.log(decoded.warnings) // حقل مكرر؟ حقل ناقص؟ حقول غريبة؟
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
تقرأ المكتبة أيضاً Base64 الآمن للروابط، وترد الرموز الغريبة بدون ما تكسر الباقي.
|
|
110
|
+
|
|
111
|
+
## جدول الحقول
|
|
112
|
+
|
|
113
|
+
| الرقم | الحقل | النوع | المرحلة |
|
|
114
|
+
| --- | --- | --- | --- |
|
|
115
|
+
| `1` | اسم البائع | نص UTF-8 | الأولى (مطلوب) |
|
|
116
|
+
| `2` | الرقم الضريبي | 15 رقماً يبدأ وينتهي بـ3 | الأولى (مطلوب) |
|
|
117
|
+
| `3` | وقت الفاتورة | ISO 8601 مع منطقة زمنية | الأولى (مطلوب) |
|
|
118
|
+
| `4` | الإجمالي مع الضريبة | رقم بفاصلة نقطية | الأولى (مطلوب) |
|
|
119
|
+
| `5` | مبلغ الضريبة | رقم بفاصلة نقطية | الأولى (مطلوب) |
|
|
120
|
+
| `6` | بصمة ملف الفاتورة | Base64 (SHA-256) | الثانية (اختياري) |
|
|
121
|
+
| `7` | التوقيع الرقمي | Base64 (ECDSA) | الثانية (اختياري) |
|
|
122
|
+
| `8` | المفتاح العام | Base64 | الثانية (اختياري) |
|
|
123
|
+
| `9` | ختم الهيئة | Base64 | بعد الاعتماد فقط |
|
|
124
|
+
|
|
125
|
+
## المرحلة الثانية: وش تسوي المكتبة وش ما تسوي
|
|
126
|
+
|
|
127
|
+
**بصراحة:** المكتبة تغلّف الحمولة وتفكها فقط. ما تحسب لك بصمة الملف، وما توقّع، وما تتصل
|
|
128
|
+
بهيئة الزكاة. البصمة والتوقيع تجيبهم من شهادتك (CSID) وتعطيها للمكتبة:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
const payload = encodeZatcaTlv({
|
|
132
|
+
...invoice,
|
|
133
|
+
invoiceHash, // بصمة ملف الفاتورة XML
|
|
134
|
+
signature, // التوقيع من شهادة CSID
|
|
135
|
+
publicKey, // المفتاح العام من نفس الشهادة
|
|
136
|
+
// stamp تضيفه بعد اعتماد الهيئة
|
|
137
|
+
})
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
قاعدة بسيطة: الحقول 6 و7 و8 تجي مع بعض. وختم الهيئة ما ينقبل بدونهم.
|
|
141
|
+
|
|
142
|
+
## أخطاء ترفض الفاتورة
|
|
143
|
+
|
|
144
|
+
| الغلط | الصح |
|
|
145
|
+
| --- | --- |
|
|
146
|
+
| وقت بدون منطقة (`2026-04-18 13:30`) | `2026-04-18T13:30:00+03:00` أو `...Z` |
|
|
147
|
+
| فاصلة عربية (`1150,00`) أو فاصلة آلاف (`1,150.00`) | `1150.00` |
|
|
148
|
+
| مبلغ بدون منزلتين (`115`) | `115.00` استخدم `toZatcaAmount` |
|
|
149
|
+
| ترتيب الحقول غلط | المكتبة ترتبها صح دايم |
|
|
150
|
+
| رقم ضريبي من 14 رقماً | 15 رقماً يبدأ وينتهي بـ3 |
|
|
151
|
+
|
|
152
|
+
العملة في هذا الحقل ريال سعودي دايم، حتى لو الفاتورة بعملة ثانية في ملف XML.
|
|
153
|
+
|
|
154
|
+
## جرّبها
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
git clone https://github.com/exeerkit/zatca-qr
|
|
158
|
+
cd zatca-qr
|
|
159
|
+
python3 -m http.server 8080
|
|
160
|
+
# افتح http://localhost:8080/examples/demo.html
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
أو افتح النسخة الجاهزة: <https://exeerkit.github.io/zatca-qr/>
|
|
164
|
+
|
|
165
|
+
صفحة عربية تولّد الرمز قدامك وتفككه بايت بايت، وكل شي يصير في متصفحك بدون ما يطلع حرف من
|
|
166
|
+
جهازك.
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
node examples/node.mjs # مثال سريع بدون تثبيت
|
|
170
|
+
node --test # 38 اختبار بدون مكتبات
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## الاختبارات
|
|
174
|
+
|
|
175
|
+
تشغل بدون أي تثبيت. فيها مثال جاهز لفاتورة بسيطة تحققنا منه بايت بايت (66 بايت):
|
|
176
|
+
|
|
177
|
+
```text
|
|
178
|
+
01 0A "Acme Saudi" 02 0F "300000000000003" 03 14 "2026-04-18T10:30:00Z" 04 06 "115.00" 05 05 "15.00"
|
|
179
|
+
→ AQpBY21lIFNhdWRpAg8zMDAwMDAwMDAwMDAwMDMDFDIwMjYtMDQtMThUMTA6MzA6MDBaBAYxMTUuMDAFBTE1LjAw
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
وفيه كمان اختبارات لطول الحروف العربية، والرموز المقطوعة، وBase64 الغلط، والتطابق بين
|
|
183
|
+
الملفات.
|
|
184
|
+
|
|
185
|
+
## أسئلة متكررة
|
|
186
|
+
|
|
187
|
+
**تكفي للمرحلة الثانية؟**
|
|
188
|
+
لا. هي تغلّف وتفك الرمز. التوقيع والشهادات شيء ثاني تتعامل معه بنفسك.
|
|
189
|
+
|
|
190
|
+
**ليش ESM فقط؟**
|
|
191
|
+
عشان تشتغل في المتصفح مباشرة بدون أدوات بناء. لو تستخدم CommonJS استخدم `await import('zatca-qr')`.
|
|
192
|
+
|
|
193
|
+
**ترسل بياناتي لخادم؟**
|
|
194
|
+
لا. ما فيها أي اتصال بالنت أصلاً.
|
|
195
|
+
|
|
196
|
+
**أقدر استخدمها في نظام تجاري؟**
|
|
197
|
+
نعم، الرخصة MIT. والتأكد أن فاتورتك مطابقة هو مسؤوليتك، ولهذا موجود `validateZatcaInvoice`.
|
|
198
|
+
|
|
199
|
+
## المراجع
|
|
200
|
+
|
|
201
|
+
- [دليل زاتكا الفني (PDF)](https://zatca.gov.sa/en/E-Invoicing/Introduction/Guidelines/Documents/e-invoicing-detailed-technical-guideline.pdf)
|
|
202
|
+
- [بوابة مطوري الهيئة](https://zatca.gov.sa/en/E-Invoicing/SystemsDevelopers/Pages/default.aspx)
|
|
203
|
+
|
|
204
|
+
## الرخصة
|
|
205
|
+
|
|
206
|
+
MIT. استخدمها كيف ما تبي.
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<div dir="rtl" align="right">
|
|
213
|
+
|
|
214
|
+
## مين اللي سواها؟
|
|
215
|
+
|
|
216
|
+
هذه المكتبة جزء صغير من قالب **[VibeIO](https://www.vibeio.dev)** لبناء تطبيقات SaaS عربية.
|
|
217
|
+
القالب يجهز لك: دخول، فوترة، دفع، بريد، وملف `AGENTS.md` يوجه وكيلك الذكي.
|
|
218
|
+
|
|
219
|
+
حطيناها مفتوحة المصدر لأن مشكلة رمز زاتكا تواجه كل مطور سعودي. إذا عجبك الكود هنا، الأصل
|
|
220
|
+
كله في [vibeio.dev](https://www.vibeio.dev).
|
|
221
|
+
|
|
222
|
+
</div>
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "zatca-qr",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "ZATCA (Saudi FATOORA) e-invoice QR: encode, decode and validate in TLV + Base64. Byte-accurate Arabic text, zero dependencies. مولّد وقارئ رمز فاتورة زاتكا.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"types": "./src/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.d.ts",
|
|
11
|
+
"import": "./src/index.js",
|
|
12
|
+
"default": "./src/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"README.md",
|
|
18
|
+
"README.en.md",
|
|
19
|
+
"CHANGELOG.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"sideEffects": false,
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "node --test",
|
|
25
|
+
"demo": "node examples/node.mjs"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"zatca",
|
|
29
|
+
"zatca-qr",
|
|
30
|
+
"fatoora",
|
|
31
|
+
"e-invoicing",
|
|
32
|
+
"saudi-arabia",
|
|
33
|
+
"vat",
|
|
34
|
+
"qr",
|
|
35
|
+
"qrcode",
|
|
36
|
+
"qr-decoder",
|
|
37
|
+
"tlv",
|
|
38
|
+
"base64",
|
|
39
|
+
"invoice",
|
|
40
|
+
"validate",
|
|
41
|
+
"arabic",
|
|
42
|
+
"rtl",
|
|
43
|
+
"ksa",
|
|
44
|
+
"tax"
|
|
45
|
+
],
|
|
46
|
+
"author": "VibeIO <hello@vibeio.dev>",
|
|
47
|
+
"license": "MIT",
|
|
48
|
+
"homepage": "https://www.vibeio.dev",
|
|
49
|
+
"repository": {
|
|
50
|
+
"type": "git",
|
|
51
|
+
"url": "git+https://github.com/exeerkit/zatca-qr.git"
|
|
52
|
+
},
|
|
53
|
+
"bugs": {
|
|
54
|
+
"url": "https://github.com/exeerkit/zatca-qr/issues"
|
|
55
|
+
},
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": ">=18"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* تعريفات الأنواع — zatca-qr
|
|
3
|
+
* Types for the ZATCA e-invoice QR encoder/decoder.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** العلامات المعرَّفة في المواصفة. */
|
|
7
|
+
export type ZatcaTag = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
|
|
8
|
+
|
|
9
|
+
/** حقول المرحلة الأولى: إلزامية على كل فاتورة ضريبية. */
|
|
10
|
+
export interface ZatcaPhase1Fields {
|
|
11
|
+
/** اسم البائع كما في السجل التجاري (Tag 1) — عربي أو لاتيني. */
|
|
12
|
+
sellerName: string
|
|
13
|
+
/** الرقم الضريبي: 15 رقماً يبدأ وينتهي بـ3 (Tag 2). */
|
|
14
|
+
vatNumber: string
|
|
15
|
+
/** طابع زمني ISO 8601 مع منطقة زمنية، أو كائن Date (Tag 3). */
|
|
16
|
+
timestamp: string | Date
|
|
17
|
+
/** إجمالي الفاتورة شامل الضريبة (Tag 4). */
|
|
18
|
+
totalWithVat: string | number
|
|
19
|
+
/** إجمالي الضريبة (Tag 5). */
|
|
20
|
+
vatTotal: string | number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* حقول مرحلة الربط: تُقدَّم الثلاثة الأولى معاً.
|
|
25
|
+
* المكتبة لا توقّع ولا تتصل بالهيئة، بل تنقل القيم التي ينتجها مخزنك (CSID).
|
|
26
|
+
*/
|
|
27
|
+
export interface ZatcaPhase2Fields {
|
|
28
|
+
/** Tag 6 — بصمة SHA-256 لملف الفاتورة XML بترميز Base64. */
|
|
29
|
+
invoiceHash?: string
|
|
30
|
+
/** Tag 7 — التوقيع الرقمي ECDSA بترميز Base64. */
|
|
31
|
+
signature?: string
|
|
32
|
+
/** Tag 8 — المفتاح العام للشهادة بترميز Base64. */
|
|
33
|
+
publicKey?: string
|
|
34
|
+
/** Tag 9 — ختم الهيئة، يُضاف بعد التخليص فقط. */
|
|
35
|
+
stamp?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type ZatcaInvoiceData = ZatcaPhase1Fields & ZatcaPhase2Fields
|
|
39
|
+
|
|
40
|
+
/** حقل واحد بعد فك ترميز TLV. */
|
|
41
|
+
export interface ZatcaDecodedField {
|
|
42
|
+
tag: number
|
|
43
|
+
/** اسم الحقل المعروف، أو null للعلامات خارج المواصفة. */
|
|
44
|
+
name: string | null
|
|
45
|
+
value: string
|
|
46
|
+
byteLength: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** ملاحظة واحدة من الفحص: خطأ مانع أو تنبيه. */
|
|
50
|
+
export interface ZatcaValidationIssue {
|
|
51
|
+
code: string
|
|
52
|
+
field: string | null
|
|
53
|
+
message: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ZatcaValidationResult {
|
|
57
|
+
valid: boolean
|
|
58
|
+
errors: ZatcaValidationIssue[]
|
|
59
|
+
warnings: ZatcaValidationIssue[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ZatcaDecodeWarning {
|
|
63
|
+
code: string
|
|
64
|
+
message: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** ناتج فك ترميز حمولة رمز QR. */
|
|
68
|
+
export interface DecodedZatcaQr {
|
|
69
|
+
/** 2 إن كانت العلامات 6 و7 و8 موجودة، وإلا 1. */
|
|
70
|
+
phase: 1 | 2
|
|
71
|
+
fields: ZatcaDecodedField[]
|
|
72
|
+
unknown: ZatcaDecodedField[]
|
|
73
|
+
data: Record<string, string>
|
|
74
|
+
warnings: ZatcaDecodeWarning[]
|
|
75
|
+
byteLength: number
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** أرقام العلامات كما في المواصفة. */
|
|
79
|
+
export declare const ZATCA_TAGS: Readonly<{
|
|
80
|
+
SELLER_NAME: 1
|
|
81
|
+
VAT_NUMBER: 2
|
|
82
|
+
TIMESTAMP: 3
|
|
83
|
+
TOTAL_WITH_VAT: 4
|
|
84
|
+
VAT_TOTAL: 5
|
|
85
|
+
INVOICE_HASH: 6
|
|
86
|
+
SIGNATURE: 7
|
|
87
|
+
PUBLIC_KEY: 8
|
|
88
|
+
STAMP: 9
|
|
89
|
+
}>
|
|
90
|
+
|
|
91
|
+
/** أسماء الحقول المقابلة لكل علامة. */
|
|
92
|
+
export declare const ZATCA_TAG_NAMES: Readonly<Record<number, string>>
|
|
93
|
+
|
|
94
|
+
/** خطأ المكتبة، يحمل رمزاً برمجياً في `code`. */
|
|
95
|
+
export declare class ZatcaQrError extends Error {
|
|
96
|
+
readonly code: string
|
|
97
|
+
constructor(message: string, code?: string)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** يبني بايتات TLV الخام. */
|
|
101
|
+
export declare function buildTlvBytes(data: ZatcaInvoiceData): Uint8Array
|
|
102
|
+
|
|
103
|
+
/** يرمّز البيانات إلى حمولة Base64 جاهزة لرمز QR. */
|
|
104
|
+
export declare function encodeZatcaTlv(
|
|
105
|
+
data: ZatcaInvoiceData,
|
|
106
|
+
options?: { strict?: boolean },
|
|
107
|
+
): string
|
|
108
|
+
|
|
109
|
+
/** يفكّ ترميز حمولة Base64 إلى حقول مقروءة. */
|
|
110
|
+
export declare function decodeZatcaTlv(base64: string): DecodedZatcaQr
|
|
111
|
+
|
|
112
|
+
/** يفحص البيانات مقابل المواصفة ويفصل الأخطاء عن التنبيهات. */
|
|
113
|
+
export declare function validateZatcaInvoice(data: ZatcaInvoiceData): ZatcaValidationResult
|
|
114
|
+
|
|
115
|
+
/** ينسّق مبلغاً بفاصلة نقطية وبمنزلتين. */
|
|
116
|
+
export declare function toZatcaAmount(value: number | string): string
|
package/src/index.js
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* zatca-qr — رمز الاستجابة السريعة لفواتير هيئة الزكاة والضريبة والجمارك (ZATCA).
|
|
3
|
+
* ---------------------------------------------------------------------------
|
|
4
|
+
* مكتبة بلا أي تبعية، تُنتج وتقرأ حمولة رمز QR كما تفرضها الهيئة على كل فاتورة
|
|
5
|
+
* ضريبية في المملكة: تسلسل TLV ثم ترميز Base64.
|
|
6
|
+
*
|
|
7
|
+
* Zero-dependency ZATCA (Saudi FATOORA) e-invoice QR payloads.
|
|
8
|
+
* Encodes and decodes the TLV sequence mandated on every tax invoice in Saudi
|
|
9
|
+
* Arabia (Phase 1, with optional Phase 2 integration fields).
|
|
10
|
+
*
|
|
11
|
+
* Works in: Node 18+, browsers, Cloudflare Workers, Deno, Bun. ESM only.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* أرقام العلامات (Tags) كما تعرّفها مواصفة الهيئة.
|
|
16
|
+
* 1..5 إلزامية في المرحلة الأولى، و6..9 تُضاف في مرحلة الربط.
|
|
17
|
+
*/
|
|
18
|
+
export const ZATCA_TAGS = Object.freeze({
|
|
19
|
+
SELLER_NAME: 1,
|
|
20
|
+
VAT_NUMBER: 2,
|
|
21
|
+
TIMESTAMP: 3,
|
|
22
|
+
TOTAL_WITH_VAT: 4,
|
|
23
|
+
VAT_TOTAL: 5,
|
|
24
|
+
INVOICE_HASH: 6,
|
|
25
|
+
SIGNATURE: 7,
|
|
26
|
+
PUBLIC_KEY: 8,
|
|
27
|
+
STAMP: 9,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
/** أسماء الحقول المقابلة لكل علامة (تُستخدم في فك الترميز). */
|
|
31
|
+
export const ZATCA_TAG_NAMES = Object.freeze({
|
|
32
|
+
1: 'sellerName',
|
|
33
|
+
2: 'vatNumber',
|
|
34
|
+
3: 'timestamp',
|
|
35
|
+
4: 'totalWithVat',
|
|
36
|
+
5: 'vatTotal',
|
|
37
|
+
6: 'invoiceHash',
|
|
38
|
+
7: 'signature',
|
|
39
|
+
8: 'publicKey',
|
|
40
|
+
9: 'stamp',
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
/** أقصى طول لقيمة واحدة: بايت واحد للطول يعني 255 بايتاً كحد أعلى. */
|
|
44
|
+
const MAX_FIELD_BYTES = 255
|
|
45
|
+
|
|
46
|
+
/** الحقول الخمسة الإلزامية بترتيب علاماتها. */
|
|
47
|
+
const REQUIRED_FIELDS = Object.freeze([
|
|
48
|
+
['sellerName', ZATCA_TAGS.SELLER_NAME],
|
|
49
|
+
['vatNumber', ZATCA_TAGS.VAT_NUMBER],
|
|
50
|
+
['timestamp', ZATCA_TAGS.TIMESTAMP],
|
|
51
|
+
['totalWithVat', ZATCA_TAGS.TOTAL_WITH_VAT],
|
|
52
|
+
['vatTotal', ZATCA_TAGS.VAT_TOTAL],
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
/** حقول مرحلة الربط: الثلاثة الأولى تُضاف معاً أو لا تُضاف إطلاقاً. */
|
|
56
|
+
const PHASE_2_REQUIRED_FIELDS = Object.freeze([
|
|
57
|
+
['invoiceHash', ZATCA_TAGS.INVOICE_HASH],
|
|
58
|
+
['signature', ZATCA_TAGS.SIGNATURE],
|
|
59
|
+
['publicKey', ZATCA_TAGS.PUBLIC_KEY],
|
|
60
|
+
])
|
|
61
|
+
|
|
62
|
+
/** ختم الهيئة يُضاف بعد التخليص فقط، فيأتي وحده من غير إخوته. */
|
|
63
|
+
const PHASE_2_OPTIONAL_FIELDS = Object.freeze([['stamp', ZATCA_TAGS.STAMP]])
|
|
64
|
+
|
|
65
|
+
/** الرقم الضريبي السعودي: 15 رقماً يبدأ بـ3 وينتهي بـ3. */
|
|
66
|
+
const VAT_NUMBER_PATTERN = /^3\d{13}3$/
|
|
67
|
+
|
|
68
|
+
/** طابع زمني ISO 8601 مع منطقة زمنية صريحة (Z أو +03:00). */
|
|
69
|
+
const ISO_WITH_ZONE_PATTERN =
|
|
70
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/
|
|
71
|
+
|
|
72
|
+
/** رقم عشري بفاصلة نقطية، بلا فواصل آلاف وبلا فاصلة عشرية عربية. */
|
|
73
|
+
const PLAIN_DECIMAL_PATTERN = /^-?\d+(?:\.\d+)?$/
|
|
74
|
+
|
|
75
|
+
/** حجم يُنبّه عنده: رموز QR الطويلة تفشل مع بعض الماسحات الميدانية. */
|
|
76
|
+
const PAYLOAD_SIZE_WARNING_BYTES = 1000
|
|
77
|
+
|
|
78
|
+
const textEncoder = new TextEncoder()
|
|
79
|
+
const textDecoder = new TextDecoder()
|
|
80
|
+
|
|
81
|
+
/** خطأ المكتبة الوحيد، يحمل رمزاً برمجياً لتفرّع المعالجة في تطبيقك. */
|
|
82
|
+
export class ZatcaQrError extends Error {
|
|
83
|
+
/**
|
|
84
|
+
* @param {string} message وصف إنجليزي واضح للسبب.
|
|
85
|
+
* @param {string} [code] رمز الخطأ (FIELD_TOO_LONG، PHASE2_INCOMPLETE، ...).
|
|
86
|
+
*/
|
|
87
|
+
constructor(message, code = 'ZATCA_QR_ERROR') {
|
|
88
|
+
super(message)
|
|
89
|
+
this.name = 'ZatcaQrError'
|
|
90
|
+
this.code = code
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* -------------------------------------------------------------------------- */
|
|
95
|
+
/* أدوات داخلية */
|
|
96
|
+
/* -------------------------------------------------------------------------- */
|
|
97
|
+
|
|
98
|
+
/** الحقول التي يُقبل فيها الرقم مباشرة: المبالغ فقط. */
|
|
99
|
+
const NUMERIC_FIELDS = new Set(['totalWithVat', 'vatTotal'])
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* يحوّل قيمة حقل إلى النص الذي سيُشفَّر.
|
|
103
|
+
* التواريخ تتحول إلى ISO 8601 بـ UTC، وهو ما تقبله الهيئة دائماً.
|
|
104
|
+
* الأرقام تُقبل في حقلي المبالغ فقط، حتى لا يتحول اسم بائع بالخطأ إلى رقم.
|
|
105
|
+
*/
|
|
106
|
+
function toFieldText(field, value) {
|
|
107
|
+
if (value === undefined || value === null || value === '') {
|
|
108
|
+
throw new ZatcaQrError(`Field "${field}" is required.`, 'MISSING_FIELD')
|
|
109
|
+
}
|
|
110
|
+
if (value instanceof Date) {
|
|
111
|
+
if (Number.isNaN(value.getTime())) {
|
|
112
|
+
throw new ZatcaQrError(`Field "${field}" is an invalid Date.`, 'INVALID_FIELD')
|
|
113
|
+
}
|
|
114
|
+
return value.toISOString()
|
|
115
|
+
}
|
|
116
|
+
if (typeof value === 'number') {
|
|
117
|
+
if (!NUMERIC_FIELDS.has(field)) {
|
|
118
|
+
throw new ZatcaQrError(
|
|
119
|
+
`Field "${field}" must be a string, not a number.`,
|
|
120
|
+
'INVALID_FIELD',
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
if (!Number.isFinite(value)) {
|
|
124
|
+
throw new ZatcaQrError(
|
|
125
|
+
`Field "${field}" must be a finite number.`,
|
|
126
|
+
'INVALID_FIELD',
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
return String(value)
|
|
130
|
+
}
|
|
131
|
+
if (typeof value === 'string') return value
|
|
132
|
+
throw new ZatcaQrError(
|
|
133
|
+
`Field "${field}" must be a string${NUMERIC_FIELDS.has(field) ? ', number' : ''} or Date (got ${typeof value}).`,
|
|
134
|
+
'INVALID_FIELD',
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* يرمّز حقلاً واحداً بصيغة TLV: [Tag, Length, ...Value].
|
|
140
|
+
* الطول يُحسب بالبايتات لا بالأحرف — وهذا موضع الخطأ الأشهر مع النص العربي.
|
|
141
|
+
*/
|
|
142
|
+
function encodeField(tag, text) {
|
|
143
|
+
const valueBytes = textEncoder.encode(text)
|
|
144
|
+
if (valueBytes.length > MAX_FIELD_BYTES) {
|
|
145
|
+
throw new ZatcaQrError(
|
|
146
|
+
`Tag ${tag} is ${valueBytes.length} UTF-8 bytes long, but the TLV length byte allows at most ${MAX_FIELD_BYTES}.`,
|
|
147
|
+
'FIELD_TOO_LONG',
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
const field = new Uint8Array(2 + valueBytes.length)
|
|
151
|
+
field[0] = tag
|
|
152
|
+
field[1] = valueBytes.length
|
|
153
|
+
field.set(valueBytes, 2)
|
|
154
|
+
return field
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** يحوّل بايتات إلى Base64 (يعمل في المتصفح وNode معاً). */
|
|
158
|
+
function bytesToBase64(bytes) {
|
|
159
|
+
let binary = ''
|
|
160
|
+
const chunkSize = 0x8000 // تقسيم لتفادي تجاوز حد وسائط String.fromCharCode
|
|
161
|
+
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
162
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize))
|
|
163
|
+
}
|
|
164
|
+
return btoa(binary)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** يقرأ Base64 إلى بايتات، ويتسامح مع Base64 الآمن للروابط وبعض المسافات. */
|
|
168
|
+
function base64ToBytes(base64) {
|
|
169
|
+
if (typeof base64 !== 'string') {
|
|
170
|
+
throw new ZatcaQrError('Payload must be a Base64 string.', 'INVALID_BASE64')
|
|
171
|
+
}
|
|
172
|
+
// نتسامح مع Base64 الآمن للروابط ومع الحشو المحذوف: بعض الماسحات تحذفهما.
|
|
173
|
+
const compact = base64
|
|
174
|
+
.replace(/\s+/g, '')
|
|
175
|
+
.replace(/-/g, '+')
|
|
176
|
+
.replace(/_/g, '/')
|
|
177
|
+
const body = compact.replace(/=+$/, '')
|
|
178
|
+
if (body.length === 0) {
|
|
179
|
+
throw new ZatcaQrError('Payload is empty.', 'INVALID_BASE64')
|
|
180
|
+
}
|
|
181
|
+
if (!/^[A-Za-z0-9+/]+$/.test(body)) {
|
|
182
|
+
throw new ZatcaQrError('Payload contains characters outside Base64.', 'INVALID_BASE64')
|
|
183
|
+
}
|
|
184
|
+
if (body.length % 4 === 1) {
|
|
185
|
+
throw new ZatcaQrError('Payload is not valid Base64 (impossible length).', 'INVALID_BASE64')
|
|
186
|
+
}
|
|
187
|
+
const normalized = body.padEnd(Math.ceil(body.length / 4) * 4, '=')
|
|
188
|
+
const binary = atob(normalized)
|
|
189
|
+
const bytes = new Uint8Array(binary.length)
|
|
190
|
+
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
|
|
191
|
+
return bytes
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** يجمع حقول الفاتورة في قائمة [tag, text] مرتّبة تصاعدياً بالعلامة. */
|
|
195
|
+
function collectFields(data) {
|
|
196
|
+
if (data === null || typeof data !== 'object') {
|
|
197
|
+
throw new ZatcaQrError('Invoice data must be an object.', 'INVALID_FIELD')
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const entries = REQUIRED_FIELDS.map(([field, tag]) => [tag, toFieldText(field, data[field])])
|
|
201
|
+
|
|
202
|
+
const hasPhase2 = PHASE_2_REQUIRED_FIELDS.some(
|
|
203
|
+
([field]) => data[field] !== undefined && data[field] !== null,
|
|
204
|
+
)
|
|
205
|
+
// ختم الهيئة بلا إخوته خطأ بنيوي: لا معنى لختم على فاتورة غير موقّعة.
|
|
206
|
+
const hasStampAlone =
|
|
207
|
+
data.stamp !== undefined && data.stamp !== null && !hasPhase2
|
|
208
|
+
|
|
209
|
+
if (hasStampAlone) {
|
|
210
|
+
throw new ZatcaQrError(
|
|
211
|
+
'Field "stamp" requires "invoiceHash", "signature" and "publicKey" as well.',
|
|
212
|
+
'PHASE2_INCOMPLETE',
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (hasPhase2) {
|
|
217
|
+
const missing = PHASE_2_REQUIRED_FIELDS.filter(
|
|
218
|
+
([field]) => data[field] === undefined || data[field] === null || data[field] === '',
|
|
219
|
+
)
|
|
220
|
+
if (missing.length > 0) {
|
|
221
|
+
throw new ZatcaQrError(
|
|
222
|
+
`Phase 2 fields must be provided together; missing: ${missing
|
|
223
|
+
.map(([field]) => field)
|
|
224
|
+
.join(', ')}.`,
|
|
225
|
+
'PHASE2_INCOMPLETE',
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
for (const [field, tag] of PHASE_2_REQUIRED_FIELDS) {
|
|
229
|
+
entries.push([tag, toFieldText(field, data[field])])
|
|
230
|
+
}
|
|
231
|
+
if (data.stamp !== undefined && data.stamp !== null && data.stamp !== '') {
|
|
232
|
+
for (const [field, tag] of PHASE_2_OPTIONAL_FIELDS) {
|
|
233
|
+
entries.push([tag, toFieldText(field, data[field])])
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return entries.sort((a, b) => a[0] - b[0])
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/* -------------------------------------------------------------------------- */
|
|
242
|
+
/* الواجهة العامة */
|
|
243
|
+
/* -------------------------------------------------------------------------- */
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* يبني بايتات TLV الخام من بيانات الفاتورة.
|
|
247
|
+
* تُفيد في الاختبارات وفي الفحص البايتي عند رفض الرمز من نظام محاسبي.
|
|
248
|
+
*
|
|
249
|
+
* @param {import('./index.d.ts').ZatcaInvoiceData} data
|
|
250
|
+
* @returns {Uint8Array}
|
|
251
|
+
*/
|
|
252
|
+
export function buildTlvBytes(data) {
|
|
253
|
+
const parts = collectFields(data).map(([tag, text]) => encodeField(tag, text))
|
|
254
|
+
const total = parts.reduce((sum, part) => sum + part.length, 0)
|
|
255
|
+
const buffer = new Uint8Array(total)
|
|
256
|
+
let offset = 0
|
|
257
|
+
for (const part of parts) {
|
|
258
|
+
buffer.set(part, offset)
|
|
259
|
+
offset += part.length
|
|
260
|
+
}
|
|
261
|
+
return buffer
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* الدالة الرئيسية: يحوّل بيانات الفاتورة إلى نص Base64 يُمرَّر لمولّد QR.
|
|
266
|
+
*
|
|
267
|
+
* @param {import('./index.d.ts').ZatcaInvoiceData} data
|
|
268
|
+
* @param {{ strict?: boolean }} [options] مع `strict` تُرفض الفاتورة إن خالفت
|
|
269
|
+
* المواصفة (رقم ضريبي غير صحيح، طابع زمني بلا منطقة، فاصلة عشرية عربية...).
|
|
270
|
+
* @returns {string} حمولة Base64 جاهزة لرمز QR.
|
|
271
|
+
*/
|
|
272
|
+
export function encodeZatcaTlv(data, options = {}) {
|
|
273
|
+
if (options.strict === true) {
|
|
274
|
+
const { errors } = validateZatcaInvoice(data)
|
|
275
|
+
if (errors.length > 0) {
|
|
276
|
+
throw new ZatcaQrError(errors[0].message, errors[0].code)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return bytesToBase64(buildTlvBytes(data))
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* يفكّ ترميز حمولة Base64 إلى بايتات TLV ثم إلى حقول مقروءة.
|
|
284
|
+
* يتسامح مع العلامات غير المعروفة (يضعها في `unknown`) بدل أن يرفض الرمز.
|
|
285
|
+
*
|
|
286
|
+
* @param {string} base64 حمولة رمز QR.
|
|
287
|
+
* @returns {import('./index.d.ts').DecodedZatcaQr}
|
|
288
|
+
*/
|
|
289
|
+
export function decodeZatcaTlv(base64) {
|
|
290
|
+
const bytes = base64ToBytes(base64)
|
|
291
|
+
const fields = []
|
|
292
|
+
const unknown = []
|
|
293
|
+
const seen = new Set()
|
|
294
|
+
const warnings = []
|
|
295
|
+
|
|
296
|
+
let offset = 0
|
|
297
|
+
while (offset < bytes.length) {
|
|
298
|
+
if (offset + 2 > bytes.length) {
|
|
299
|
+
throw new ZatcaQrError(
|
|
300
|
+
`Truncated TLV: found ${bytes.length - offset} trailing byte(s) where a tag and length were expected.`,
|
|
301
|
+
'TRUNCATED_TLV',
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
const tag = bytes[offset]
|
|
305
|
+
const length = bytes[offset + 1]
|
|
306
|
+
const start = offset + 2
|
|
307
|
+
const end = start + length
|
|
308
|
+
|
|
309
|
+
if (end > bytes.length) {
|
|
310
|
+
throw new ZatcaQrError(
|
|
311
|
+
`Truncated TLV: tag ${tag} declares ${length} bytes but only ${bytes.length - start} remain.`,
|
|
312
|
+
'TRUNCATED_TLV',
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
if (tag === 0) {
|
|
316
|
+
throw new ZatcaQrError('Invalid TLV: tag 0 is not defined.', 'INVALID_TLV')
|
|
317
|
+
}
|
|
318
|
+
if (seen.has(tag)) {
|
|
319
|
+
warnings.push({
|
|
320
|
+
code: 'DUPLICATE_TAG',
|
|
321
|
+
message: `Tag ${tag} appears more than once; validators may reject the payload.`,
|
|
322
|
+
})
|
|
323
|
+
}
|
|
324
|
+
seen.add(tag)
|
|
325
|
+
|
|
326
|
+
const value = textDecoder.decode(bytes.subarray(start, end))
|
|
327
|
+
const name = ZATCA_TAG_NAMES[tag]
|
|
328
|
+
const field = { tag, name: name ?? null, value, byteLength: length }
|
|
329
|
+
if (name) fields.push(field)
|
|
330
|
+
else unknown.push(field)
|
|
331
|
+
|
|
332
|
+
offset = end
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const data = {}
|
|
336
|
+
for (const field of fields) data[field.name] = field.value
|
|
337
|
+
|
|
338
|
+
const isPhase2 = [6, 7, 8].every((tag) => seen.has(tag))
|
|
339
|
+
for (const tag of [1, 2, 3, 4, 5]) {
|
|
340
|
+
if (!seen.has(tag)) {
|
|
341
|
+
warnings.push({
|
|
342
|
+
code: 'MISSING_REQUIRED_TAG',
|
|
343
|
+
message: `Tag ${tag} (${ZATCA_TAG_NAMES[tag]}) is missing; the payload is not a valid Phase 1 QR.`,
|
|
344
|
+
})
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (unknown.length > 0) {
|
|
348
|
+
warnings.push({
|
|
349
|
+
code: 'UNKNOWN_TAGS',
|
|
350
|
+
message: `Payload contains ${unknown.length} tag(s) outside the specification: ${unknown
|
|
351
|
+
.map((field) => field.tag)
|
|
352
|
+
.join(', ')}.`,
|
|
353
|
+
})
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return { phase: isPhase2 ? 2 : 1, fields, unknown, data, warnings, byteLength: bytes.length }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* يفحص البيانات مقابل المواصفة قبل الإصدار، ويفصل الأخطاء عن التنبيهات.
|
|
361
|
+
* الأخطاء تمنع الفاتورة من القبول، والتنبيهات تُصدر لكنها تستحق مراجعة.
|
|
362
|
+
*
|
|
363
|
+
* @param {import('./index.d.ts').ZatcaInvoiceData} data
|
|
364
|
+
* @returns {import('./index.d.ts').ZatcaValidationResult}
|
|
365
|
+
*/
|
|
366
|
+
export function validateZatcaInvoice(data) {
|
|
367
|
+
/** @type {import('./index.d.ts').ZatcaValidationIssue[]} */
|
|
368
|
+
const errors = []
|
|
369
|
+
/** @type {import('./index.d.ts').ZatcaValidationIssue[]} */
|
|
370
|
+
const warnings = []
|
|
371
|
+
|
|
372
|
+
if (data === null || typeof data !== 'object') {
|
|
373
|
+
errors.push({ code: 'INVALID_FIELD', field: null, message: 'Invoice data must be an object.' })
|
|
374
|
+
return { valid: false, errors, warnings }
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
for (const [field] of REQUIRED_FIELDS) {
|
|
378
|
+
const value = data[field]
|
|
379
|
+
if (value === undefined || value === null || value === '') {
|
|
380
|
+
errors.push({ code: 'MISSING_FIELD', field, message: `Field "${field}" is required.` })
|
|
381
|
+
continue
|
|
382
|
+
}
|
|
383
|
+
// الطول بالبايتات: 130 حرفاً عربياً = 260 بايتاً، وهذا يتجاوز حد TLV.
|
|
384
|
+
try {
|
|
385
|
+
const bytes = textEncoder.encode(toFieldText(field, value)).length
|
|
386
|
+
if (bytes > MAX_FIELD_BYTES) {
|
|
387
|
+
errors.push({
|
|
388
|
+
code: 'FIELD_TOO_LONG',
|
|
389
|
+
field,
|
|
390
|
+
message: `Field "${field}" is ${bytes} UTF-8 bytes long; the TLV limit is ${MAX_FIELD_BYTES}.`,
|
|
391
|
+
})
|
|
392
|
+
}
|
|
393
|
+
} catch (error) {
|
|
394
|
+
errors.push({ code: error.code ?? 'INVALID_FIELD', field, message: error.message })
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const vatNumber = data.vatNumber
|
|
399
|
+
if (typeof vatNumber === 'string' && vatNumber !== '' && !VAT_NUMBER_PATTERN.test(vatNumber)) {
|
|
400
|
+
errors.push({
|
|
401
|
+
code: 'VAT_NUMBER_FORMAT',
|
|
402
|
+
field: 'vatNumber',
|
|
403
|
+
message:
|
|
404
|
+
'VAT number must be exactly 15 digits, starting and ending with 3 (e.g. 300000000000003).',
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const timestamp = data.timestamp
|
|
409
|
+
if (timestamp instanceof Date) {
|
|
410
|
+
// toISOString يرمي استثناءً على تاريخ غير صحيح، لذلك نفحصه قبل الاستدعاء
|
|
411
|
+
if (Number.isNaN(timestamp.getTime())) {
|
|
412
|
+
errors.push({
|
|
413
|
+
code: 'TIMESTAMP_FORMAT',
|
|
414
|
+
field: 'timestamp',
|
|
415
|
+
message: 'Timestamp Date is invalid.',
|
|
416
|
+
})
|
|
417
|
+
}
|
|
418
|
+
} else if (
|
|
419
|
+
typeof timestamp === 'string' &&
|
|
420
|
+
timestamp !== '' &&
|
|
421
|
+
!ISO_WITH_ZONE_PATTERN.test(timestamp)
|
|
422
|
+
) {
|
|
423
|
+
errors.push({
|
|
424
|
+
code: 'TIMESTAMP_FORMAT',
|
|
425
|
+
field: 'timestamp',
|
|
426
|
+
message:
|
|
427
|
+
'Timestamp must be ISO 8601 with an explicit time zone, e.g. 2026-04-18T10:30:00Z or 2026-04-18T13:30:00+03:00.',
|
|
428
|
+
})
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
for (const field of ['totalWithVat', 'vatTotal']) {
|
|
432
|
+
const value = data[field]
|
|
433
|
+
if (value === undefined || value === null || value === '') continue
|
|
434
|
+
if (typeof value !== 'string' && typeof value !== 'number') {
|
|
435
|
+
errors.push({
|
|
436
|
+
code: 'AMOUNT_FORMAT',
|
|
437
|
+
field,
|
|
438
|
+
message: `Field "${field}" must be a decimal amount as a string or a number (got ${
|
|
439
|
+
Array.isArray(value) ? 'array' : typeof value
|
|
440
|
+
}).`,
|
|
441
|
+
})
|
|
442
|
+
continue
|
|
443
|
+
}
|
|
444
|
+
const text = String(value).trim()
|
|
445
|
+
if (!PLAIN_DECIMAL_PATTERN.test(text)) {
|
|
446
|
+
errors.push({
|
|
447
|
+
code: 'AMOUNT_FORMAT',
|
|
448
|
+
field,
|
|
449
|
+
message: `Field "${field}" must use a dot decimal separator with no thousands separators (got "${text}").`,
|
|
450
|
+
})
|
|
451
|
+
continue
|
|
452
|
+
}
|
|
453
|
+
const decimals = text.includes('.') ? text.split('.')[1].length : 0
|
|
454
|
+
if (decimals !== 2) {
|
|
455
|
+
warnings.push({
|
|
456
|
+
code: 'AMOUNT_PRECISION',
|
|
457
|
+
field,
|
|
458
|
+
message: `Field "${field}" has ${decimals} decimal place(s); the specification examples use exactly 2.`,
|
|
459
|
+
})
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const total = Number(data.totalWithVat)
|
|
464
|
+
const vat = Number(data.vatTotal)
|
|
465
|
+
if (Number.isFinite(total) && Number.isFinite(vat) && vat > total) {
|
|
466
|
+
warnings.push({
|
|
467
|
+
code: 'VAT_EXCEEDS_TOTAL',
|
|
468
|
+
field: 'vatTotal',
|
|
469
|
+
message: 'VAT amount is greater than the invoice total including VAT.',
|
|
470
|
+
})
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const phase2Missing = PHASE_2_REQUIRED_FIELDS.filter(
|
|
474
|
+
([field]) => data[field] === undefined || data[field] === null || data[field] === '',
|
|
475
|
+
)
|
|
476
|
+
const hasAnyPhase2 = PHASE_2_REQUIRED_FIELDS.some(
|
|
477
|
+
([field]) => data[field] !== undefined && data[field] !== null && data[field] !== '',
|
|
478
|
+
)
|
|
479
|
+
if (hasAnyPhase2 && phase2Missing.length > 0) {
|
|
480
|
+
errors.push({
|
|
481
|
+
code: 'PHASE2_INCOMPLETE',
|
|
482
|
+
field: phase2Missing[0][0],
|
|
483
|
+
message: `Phase 2 fields must be provided together; missing: ${phase2Missing
|
|
484
|
+
.map(([field]) => field)
|
|
485
|
+
.join(', ')}.`,
|
|
486
|
+
})
|
|
487
|
+
}
|
|
488
|
+
if (
|
|
489
|
+
(data.stamp !== undefined && data.stamp !== null && data.stamp !== '') &&
|
|
490
|
+
phase2Missing.length > 0
|
|
491
|
+
) {
|
|
492
|
+
errors.push({
|
|
493
|
+
code: 'PHASE2_INCOMPLETE',
|
|
494
|
+
field: 'stamp',
|
|
495
|
+
message: 'Field "stamp" requires "invoiceHash", "signature" and "publicKey" as well.',
|
|
496
|
+
})
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (errors.length === 0) {
|
|
500
|
+
const size = buildTlvBytes(data).length
|
|
501
|
+
if (size > PAYLOAD_SIZE_WARNING_BYTES) {
|
|
502
|
+
warnings.push({
|
|
503
|
+
code: 'PAYLOAD_TOO_LARGE',
|
|
504
|
+
field: null,
|
|
505
|
+
message: `TLV payload is ${size} bytes; long payloads can fail on field scanners.`,
|
|
506
|
+
})
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return { valid: errors.length === 0, errors, warnings }
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* ينسّق مبلغاً بالصيغة التي تتوقعها الهيئة: فاصلة نقطية وبمنزلتين.
|
|
515
|
+
* الأرقام تُقرَّب إلى منزلتين، والنصوص تُترك كما هي بعد إزالة الفراغات.
|
|
516
|
+
*
|
|
517
|
+
* @param {number|string} value
|
|
518
|
+
* @returns {string}
|
|
519
|
+
*/
|
|
520
|
+
export function toZatcaAmount(value) {
|
|
521
|
+
if (typeof value === 'number') {
|
|
522
|
+
if (!Number.isFinite(value)) {
|
|
523
|
+
throw new ZatcaQrError('Amount must be a finite number.', 'AMOUNT_FORMAT')
|
|
524
|
+
}
|
|
525
|
+
return value.toFixed(2)
|
|
526
|
+
}
|
|
527
|
+
if (typeof value === 'string') return value.trim()
|
|
528
|
+
throw new ZatcaQrError(
|
|
529
|
+
`Amount must be a number or a string (got ${typeof value}).`,
|
|
530
|
+
'AMOUNT_FORMAT',
|
|
531
|
+
)
|
|
532
|
+
}
|