@khaledhajsalem/zatca-node
v1.0.4
Published
A comprehensive Node.js package for ZATCA (Saudi Arabia e-invoicing) invoice processing, signing, and submission
Downloads
149
Maintainers
Readme
ZATCA Node.js Package
A Node.js/TypeScript package for ZATCA (Saudi Arabia) Phase 2 e-invoicing. Handles XML generation (UBL 2.1), digital signing, QR codes, and API submission — all without database dependencies.
Table of Contents
- Requirements
- Installation
- Key Concepts
- How It Works (Lifecycle)
- Step 1: Generate CSR & Private Key
- Step 2: Get Your Certificate from ZATCA
- Step 3: Create & Submit an Invoice
- Invoice Types
- Credit & Debit Notes
- Data Reference
- Error Handling
- Package Structure
- Testing
- Contributing
- License
Requirements
- Node.js 18.0+
- OpenSSL (for CSR generation)
Installation
npm install @khaledhajsalem/zatca-nodeKey Concepts
Before using this package, understand these ZATCA-specific terms:
| Term | What it means |
|------|--------------|
| Standard Invoice | B2B/B2G invoice. Must be cleared by ZATCA before you can send it to the buyer. Type name: 0100000. |
| Simplified Invoice | B2C invoice (e.g., retail receipt). Must be reported to ZATCA within 24 hours. Type name: 0200000. |
| Clearance | ZATCA validates and approves a Standard invoice in real-time. You get back a "cleared" XML. |
| Reporting | You send a Simplified invoice to ZATCA for record-keeping. No real-time approval needed. |
| PIH (Previous Invoice Hash) | SHA-256 hash of the previous invoice. Creates a tamper-proof chain. First invoice uses 'MA==' (base64 encoded '0'). |
| ICV (Invoice Counter Value) | Sequential counter starting at 1. Must increment for every invoice. |
| CSR | Certificate Signing Request — you generate this and send it to ZATCA to get your signing certificate. |
| OTP | One-Time Password — ZATCA gives you this when you register your device on the Fatoora portal. |
Invoice Type Codes
| Code | Type | Method |
|------|------|--------|
| 388 | Tax Invoice | .taxInvoice() |
| 381 | Credit Note | .creditNote() |
| 383 | Debit Note | .debitNote() |
| 386 | Prepayment Invoice | .prepaymentInvoice() |
Party Identification Schemes
Both seller and buyer support these identification types:
| Scheme ID | Description |
|-----------|-------------|
| CRN | Commercial Registration Number |
| VAT | VAT Number |
| TIN | Tax Identification Number |
| NAT | National ID |
| IQA | Iqama Number |
| GCC | GCC ID |
| PAS | Passport ID |
| MOM | MOMRAH License |
| MLS | MHRSD License |
| SAG | MISA License |
| 700 | 700 Number |
| OTH | Other ID |
How It Works (Lifecycle)
Here is the complete flow from setup to invoice submission:
┌─────────────────────────────────────────────────────────────────┐
│ ONE-TIME SETUP │
│ │
│ 1. Generate CSR + Private Key (CertificateBuilder) │
│ 2. Submit CSR to ZATCA with OTP → get Compliance Certificate │
│ 3. Run compliance tests with the compliance certificate │
│ 4. Request Production Certificate → get Production Certificate │
│ │
│ Save: certificate.pem, private.pem, secret key │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ FOR EVERY INVOICE │
│ │
│ 1. Create InvoiceData (number, date, type, PIH, ICV) │
│ 2. Create SellerData (company name, VAT, address) │
│ 3. Create BuyerData (customer name, VAT, address) │
│ 4. Create InvoiceLineData items (name, qty, price, tax%) │
│ 5. Call calculateTotals() on lines, then on invoice │
│ 6. Submit via zatcaManager.processInvoice() │
│ → Standard invoice? ZATCA clears it (real-time) │
│ → Simplified invoice? ZATCA reports it │
│ 7. Save the invoice_hash as PIH for the next invoice │
└─────────────────────────────────────────────────────────────────┘Step 1: Generate CSR & Private Key
Do this once when setting up your system with ZATCA.
import { CertificateBuilder } from '@khaledhajsalem/zatca-node';
const builder = new CertificateBuilder();
builder.setOrganizationIdentifier('300000000000003') // 15 digits, starts & ends with 3
.setSerialNumber('MySolution', 'Model1', 'SN001')
.setCommonName('Your Company Name')
.setCountryName('SA')
.setOrganizationName('Your Company Name')
.setOrganizationalUnitName('IT Department')
.setAddress('123 Main Street, Riyadh, Saudi Arabia')
.setInvoiceType(1100) // 4 digits: [Standard][Simplified][future][future] — 1100 = both standard & simplified
.setProduction(false) // false = sandbox/simulation, true = production
.setBusinessCategory('Legal Entity');
builder.generateAndSave('storage/certificate.csr', 'storage/private.pem');What happens next:
- Log in to the ZATCA Fatoora Portal
- Register your device — ZATCA gives you an OTP
- Use the OTP to request a compliance certificate (see Step 2)
Step 2: Get Your Certificate from ZATCA
import { ZatcaAPIService } from '@khaledhajsalem/zatca-node';
import * as fs from 'fs';
// Initialize API service
const apiService = new ZatcaAPIService('sandbox'); // 'sandbox', 'simulation', or 'production'
// ── Step 2a: Request Compliance Certificate ──
const csr = fs.readFileSync('storage/certificate.csr', 'utf8');
const otp = '123456'; // OTP from ZATCA Fatoora Portal
const complianceResult = await apiService.requestComplianceCertificate(csr, otp);
// Save the compliance certificate
fs.writeFileSync('storage/certificate.pem', complianceResult.getCertificate());
const complianceSecret = complianceResult.getSecret(); // Save this — it's your API secret
const complianceRequestId = complianceResult.getRequestId(); // Need this for production certificate
// ── Step 2b: Run Compliance Tests ──
// Submit test invoices using the compliance certificate (see Step 3)
// Once all tests pass...
// ── Step 2c: Request Production Certificate ──
const complianceCert = fs.readFileSync('storage/certificate.pem', 'utf8');
const productionResult = await apiService.requestProductionCertificate(
complianceCert, // Compliance certificate
complianceSecret, // Compliance secret
complianceRequestId // Request ID from step 2a
);
// Save the production certificate — use this for all real invoices
fs.writeFileSync('storage/certificate.pem', productionResult.getCertificate());
const productionSecret = productionResult.getSecret(); // Save this as your new API secretStep 3: Create & Submit an Invoice
This is the main workflow you'll use for every invoice.
Simplified Tax Invoice (B2C)
import { ZatcaManager, InvoiceData, SellerData, BuyerData, InvoiceLineData } from '@khaledhajsalem/zatca-node';
// ── 1. Initialize ZatcaManager ──
const zatcaManager = new ZatcaManager({
environment: 'sandbox', // 'sandbox', 'simulation', or 'production'
certificate_path: __dirname + '/storage/certificate.pem', // Path to your certificate file
private_key_path: __dirname + '/storage/private.pem', // Path to your private key file
secret: 'CkYsEXfV8c1gFHAtFWoZv73pGMvh/Qyo4LzKM2h/8Hg=', // API secret from ZATCA
});
// ── 2. Create Invoice Data ──
const invoiceData = new InvoiceData();
invoiceData
.setInvoiceNumber('INV-001') // Your invoice number
.simplified() // B2C invoice (reporting) — or .standard() for B2B (clearance)
.taxInvoice() // Tax Invoice (388) — or .creditNote(), .debitNote(), .prepaymentInvoice()
.setIssueDate('2025-01-15') // Issue date in Y-m-d format
.setIssueTime('10:30:00') // Issue time in H:i:s format
.setDueDate('2025-02-15') // Due date in Y-m-d format
.setCurrencyCode('SAR') // Currency code (ISO 4217)
.setDocumentCurrencyCode('SAR') // Document currency (usually same as above)
.setTaxCurrencyCode('SAR') // Tax currency (usually same as above)
.setInvoiceCounter('1') // ICV: sequential counter, must increment per invoice
.setPreviousInvoiceHash('MA=='); // PIH: 'MA==' for first invoice, then use hash from previous invoice
// ── 3. Create Seller Data ──
const seller = new SellerData();
seller.setRegistrationName('Your Company Name') // Company legal name
.setVatNumber('399999999900003') // VAT number (15 digits)
.setPartyIdentification('1010203020') // Identification value (e.g., CRN number)
.setPartyIdentificationId('CRN') // Identification type — see "Party Identification Schemes" above
.setStreetName('Main Street') // Street name
.setBuildingNumber('1234') // Building number
.setCityName('Riyadh') // City
.setPostalZone('12345') // Postal/ZIP code
.setCountryCode('SA') // 2-letter country code
.setPlotIdentification('PLOT-001') // Plot identification (optional)
.setCitySubdivisionName('District 1'); // District name (optional)
invoiceData.setSeller(seller);
// ── 4. Create Buyer Data ──
const buyer = new BuyerData();
buyer.setRegistrationName('Customer Company')
.setVatNumber('300000000000003') // VAT number (optional for simplified invoices)
.setPartyIdentification('1010203030')
.setPartyIdentificationId('CRN')
.setStreetName('Customer Street')
.setBuildingNumber('4567')
.setCityName('Jeddah')
.setPostalZone('54321')
.setCountryCode('SA');
invoiceData.setBuyer(buyer);
// ── 5. Add Line Items ──
const line1 = new InvoiceLineData();
line1.setId(1) // Line number (sequential)
.setItemName('Product 1') // Item name
.setDescription('High-quality product') // Description (optional)
.setQuantity(2) // Quantity
.setUnitPrice(100.00) // Unit price (tax-exclusive)
.setTaxPercent(15.0) // VAT percentage
.calculateTotals(); // Auto-calculates: lineExtension, taxAmount, taxExclusive, taxInclusive
const line2 = new InvoiceLineData();
line2.setId(2)
.setItemName('Product 2')
.setQuantity(1)
.setUnitPrice(50.00)
.setTaxPercent(15.0)
.calculateTotals();
invoiceData.addLine(line1);
invoiceData.addLine(line2);
// ── 6. Calculate Invoice Totals ──
invoiceData.calculateTotals(); // Sums all line items into invoice-level totals
// ── 7. Submit to ZATCA ──
const result = await zatcaManager.processInvoice(invoiceData);
// ── 8. Use the Result ──
console.log(result.uuid); // Invoice UUID (generated automatically)
console.log(result.invoice_hash); // Invoice hash — SAVE THIS as PIH for your next invoice
console.log(result.qr_code); // Base64-encoded QR code
console.log(result.xml); // Signed XML string
console.log(result.is_clearance_required); // true for standard, false for simplified
// API response from ZATCA:
console.log(result.response['validationResults']); // { status: 'PASS' | 'WARNING' | 'ERROR', ... }
console.log(result.response['reportingStatus']); // For simplified: 'REPORTED'
console.log(result.response['clearanceStatus']); // For standard: 'CLEARED'Standard Tax Invoice (B2B)
The only difference from simplified is the invoice type — everything else is the same:
const invoiceData = new InvoiceData();
invoiceData
.setInvoiceNumber('INV-002')
.standard() // ← This is the only change (B2B, requires clearance)
.taxInvoice()
.setIssueDate('2025-01-15')
.setIssueTime('10:30:00')
.setCurrencyCode('SAR')
.setDocumentCurrencyCode('SAR')
.setTaxCurrencyCode('SAR')
.setInvoiceCounter('2') // ICV: second invoice
.setPreviousInvoiceHash(previousInvoiceHash); // PIH: hash from INV-001
// ... seller, buyer, lines same as above ...
const result = await zatcaManager.processInvoice(invoiceData);
// For standard invoices, ZATCA returns a cleared XML:
if (result.response['clearanceStatus'] === 'CLEARED') {
const clearedXml = result.xml; // Use this XML (not your original)
}Invoice Types
Summary
| Type | Code | Name | Clearance? | Method |
|------|------|------|-----------|--------|
| Standard Tax Invoice | 388 | 0100000 | Yes (real-time) | .standard().taxInvoice() |
| Simplified Tax Invoice | 388 | 0200000 | No (report within 24h) | .simplified().taxInvoice() |
| Standard Credit Note | 381 | 0100000 | Yes | .standard().creditNote() |
| Simplified Credit Note | 381 | 0200000 | No | .simplified().creditNote() |
| Standard Debit Note | 383 | 0100000 | Yes | .standard().debitNote() |
| Simplified Debit Note | 383 | 0200000 | No | .simplified().debitNote() |
| Prepayment Invoice | 386 | — | Depends on standard/simplified | .prepaymentInvoice() |
Credit & Debit Notes
Credit and debit notes must reference the original invoice using addBillingReference(). Payment means are optional but recommended.
// ── Credit Note (returns/refunds) ──
const creditNote = new InvoiceData();
creditNote
.setInvoiceNumber('CN-001')
.simplified() // or .standard()
.creditNote() // Type code 381
.setIssueDate('2025-01-20')
.setIssueTime('14:00:00')
.setCurrencyCode('SAR')
.setDocumentCurrencyCode('SAR')
.setTaxCurrencyCode('SAR')
.setInvoiceCounter('3')
.setPreviousInvoiceHash(previousHash)
// REQUIRED: Reference to the original invoice
.addBillingReference({
id: 'INV-001', // Original invoice number
uuid: '63decc4e-cc4d-4e3b-878c-b772560bb5f1', // Original invoice UUID
})
// OPTIONAL: Payment means (reason for the note)
.addPaymentMeans({
code: '10', // Payment method code
instruction_note: 'Returns', // Reason: Returns, Correction, Cancellation, etc.
});
// ... set seller, buyer, lines, calculateTotals(), then submit
const result = await zatcaManager.processInvoice(creditNote);// ── Debit Note (additional charges) ──
const debitNote = new InvoiceData();
debitNote
.setInvoiceNumber('DN-001')
.standard() // or .simplified()
.debitNote() // Type code 383
.setIssueDate('2025-01-20')
.setIssueTime('14:00:00')
.setCurrencyCode('SAR')
.setDocumentCurrencyCode('SAR')
.setTaxCurrencyCode('SAR')
.setInvoiceCounter('4')
.setPreviousInvoiceHash(previousHash)
.addBillingReference({
id: 'INV-001',
uuid: '63decc4e-cc4d-4e3b-878c-b772560bb5f1',
})
.addPaymentMeans({
code: '10',
instruction_note: 'Addition',
});
// ... set seller, buyer, lines, calculateTotals(), then submit
const result = await zatcaManager.processInvoice(debitNote);Data Reference
InvoiceData — All Setters
| Method | Type | Required | Description |
|--------|------|----------|-------------|
| setInvoiceNumber(num) | string | Yes | Your invoice number |
| standard() | — | Yes* | Set as Standard (B2B). One of standard/simplified required |
| simplified() | — | Yes | Set as Simplified (B2C) |
| taxInvoice() | — | Yes* | Tax Invoice (388). *One of tax/credit/debit/prepayment required |
| creditNote() | — | — | Credit Note (381) |
| debitNote() | — | — | Debit Note (383) |
| prepaymentInvoice() | — | — | Prepayment (386) |
| setIssueDate(date) | string | Yes | Format: Y-m-d |
| setIssueTime(time) | string | Yes | Format: H:i:s |
| setDueDate(date) | string | No | Format: Y-m-d |
| setCurrencyCode(code) | string | Yes | ISO 4217 (e.g., SAR) |
| setDocumentCurrencyCode(code) | string | Yes | Usually same as currency code |
| setTaxCurrencyCode(code) | string | Yes | Usually same as currency code |
| setInvoiceCounter(icv) | string | Yes | Sequential counter starting at 1 |
| setPreviousInvoiceHash(pih) | string | Yes | 'MA==' for first invoice |
| setSeller(seller) | SellerData | Yes | Seller information |
| setBuyer(buyer) | BuyerData | Yes | Buyer information |
| addLine(line) | InvoiceLineData | Yes | At least one line required |
| calculateTotals() | — | Yes | Call after adding all lines |
| addBillingReference(ref) | object | For CN/DN | Keys: id, uuid |
| addPaymentMeans(pm) | object | No | Keys: code, instruction_note |
| addAllowance(allowance) | object | No | Document-level discount |
| addCharge(charge) | object | No | Document-level charge |
SellerData — All Setters
| Method | Type | Required | Description |
|--------|------|----------|-------------|
| setRegistrationName(name) | string | Yes | Company legal name |
| setVatNumber(vat) | string | Yes | 15-digit VAT number |
| setPartyIdentification(value) | string | Yes | ID value (e.g., CRN number) |
| setPartyIdentificationId(scheme) | string | Yes | Scheme: CRN, VAT, TIN, etc. |
| setStreetName(street) | string | Yes | Street name |
| setBuildingNumber(num) | string | Yes | Building number |
| setCityName(city) | string | Yes | City name |
| setPostalZone(zip) | string | Yes | Postal/ZIP code |
| setCountryCode(code) | string | Yes | 2-letter code (e.g., SA) |
| setPlotIdentification(plot) | string | No | Plot identification |
| setCitySubdivisionName(district) | string | No | District/subdivision name |
BuyerData — All Setters
Same methods as SellerData. For simplified invoices, setVatNumber() is optional.
InvoiceLineData — All Setters
| Method | Type | Required | Description |
|--------|------|----------|-------------|
| setId(id) | number | Yes | Line number (sequential: 1, 2, 3...) |
| setItemName(name) | string | Yes | Item name |
| setDescription(desc) | string | No | Item description |
| setQuantity(qty) | number | Yes | Quantity |
| setUnitPrice(price) | number | Yes | Unit price (tax-exclusive) |
| setTaxPercent(pct) | number | Yes | VAT percentage (e.g., 15.0) |
| setUnitCode(code) | string | No | Unit code (default: EA) |
| setItemCode(code) | string | No | Item code |
| calculateTotals() | — | Yes | Auto-calculates all amounts from qty × price × tax% |
| setAllowanceAmount(amt) | number | No | Line-level discount (set before calculateTotals) |
| setChargeAmount(amt) | number | No | Line-level charge (set before calculateTotals) |
Tip: Call
calculateTotals()on each line item, then callcalculateTotals()on the invoice. This auto-fillslineExtensionAmount,taxAmount,taxExclusiveAmount,taxInclusiveAmount, and all invoice-level totals.
Previous Invoice Hash (PIH) Chain
Every invoice references the hash of the previous invoice to create a tamper-proof chain:
// Invoice 1 (first invoice — no previous)
invoice1.setPreviousInvoiceHash('MA=='); // base64('0')
const result1 = await zatcaManager.processInvoice(invoice1);
const hash1 = result1.invoice_hash; // Save this!
// Invoice 2 (references invoice 1)
invoice2.setPreviousInvoiceHash(hash1);
const result2 = await zatcaManager.processInvoice(invoice2);
const hash2 = result2.invoice_hash; // Save this!
// Invoice 3 (references invoice 2)
invoice3.setPreviousInvoiceHash(hash2);
// ... and so onError Handling
import { ZatcaException, CertificateBuilderException, ZatcaApiException } from '@khaledhajsalem/zatca-node';
try {
const result = await zatcaManager.processInvoice(invoiceData);
} catch (error) {
if (error instanceof CertificateBuilderException) {
// Certificate generation errors
console.error('Certificate error:', error.message);
console.error('Details:', error.getContext());
} else if (error instanceof ZatcaApiException) {
// ZATCA API errors (network, validation, auth)
console.error('API error:', error.message);
console.error('Details:', error.getContext());
} else if (error instanceof ZatcaException) {
// General package errors (missing config, file not found, etc.)
console.error('Error:', error.message);
console.error('Details:', error.getContext());
}
}All exceptions extend ZatcaException and provide a getContext() method with structured error details.
Package Structure
zatca-node/
├── src/
│ ├── data/ # Data transfer objects
│ │ ├── InvoiceData.ts # Invoice header, totals, references
│ │ ├── SellerData.ts # Seller name, VAT, address
│ │ ├── BuyerData.ts # Buyer name, VAT, address
│ │ └── InvoiceLineData.ts # Line item: name, qty, price, tax
│ ├── exceptions/ # Exception classes
│ │ ├── ZatcaException.ts # Base exception (all others extend this)
│ │ ├── CertificateBuilderException.ts
│ │ ├── ZatcaApiException.ts
│ │ └── ZatcaStorageException.ts
│ ├── services/ # External services
│ │ ├── ZatcaAPIService.ts # ZATCA API client (clearance, reporting, compliance)
│ │ └── Storage.ts # File storage helper
│ ├── support/ # Internal support classes
│ │ ├── Certificate.ts # Certificate loading & hashing
│ │ ├── CertificateBuilder.ts # CSR & private key generation
│ │ ├── InvoiceExtension.ts # UBL XML extension handling
│ │ ├── InvoiceSignatureBuilder.ts # XMLDsig signature builder
│ │ ├── InvoiceSigner.ts # Signs XML, generates QR & hash
│ │ ├── QRCodeGenerator.ts # TLV-encoded QR code generation
│ │ └── qrcode-tags/ # Individual QR code tag classes
│ ├── ZatcaInvoice.ts # UBL 2.1 XML generator
│ ├── ZatcaManager.ts # Main orchestrator (the class you use)
│ └── index.ts # Package exports
├── examples/
│ ├── basic-usage.ts # Complete working example
│ ├── certificate-generation.ts # CSR generation example
│ └── invoice-types.ts # Standard, simplified, credit, debit, prepayment
├── tests/
│ └── ZatcaInvoice.test.ts
├── package.json
├── tsconfig.json
└── README.mdTesting
npm testContributing
- Fork the repository
- Create a feature branch
- Make your changes
- Add tests for new functionality
- Ensure all tests pass
- Submit a pull request
Acknowledgments
This package is a Node.js/TypeScript port of zatca-php.
License
This package is open-sourced software licensed under the MIT license.
Support
- Email: [email protected]
- GitHub Issues: Create an issue
